diff --git a/.cargo/audit.toml b/.cargo/audit.toml deleted file mode 100644 index 423cf594..00000000 --- a/.cargo/audit.toml +++ /dev/null @@ -1,9 +0,0 @@ -# cargo-audit reads this file directly from the repository root context. -# -# RUSTSEC-2026-0194 / RUSTSEC-2026-0195 are denial-of-service advisories in -# quick-xml. quick-xml is transitive only through self_update's GitHub backend -# (`self_update -> quick-xml`) and parses trusted GitHub release metadata fetched -# over TLS, not user-controlled XML. self_update 0.44 still constrains quick-xml -# to ^0.38; remove these exceptions as soon as it accepts quick-xml >=0.41. -[advisories] -ignore = ["RUSTSEC-2026-0194", "RUSTSEC-2026-0195"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8faf4d3d..7f31e1c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,7 +193,8 @@ jobs: # FSEvents is the one native-recursive watcher backend no other job runs: # Linux uses inotify and Windows ReadDirectoryChangesW. This job runs the - # symlink-following scan and watcher tests on macOS (#770). + # symlink-following scan and watcher tests on macOS (#770), and the burst + # that may drop or coalesce events (#1397). macos-watcher: name: macOS Watcher needs: workspace-version @@ -229,6 +230,9 @@ jobs: cargo test -p codegraph-extract --locked --test scan_symlinks cargo test -p codegraph-watch --locked symlink + - name: Run the event-loss convergence test + run: cargo test -p codegraph-watch --locked --test event_loss + ui: name: UI runs-on: ubuntu-24.04 diff --git a/Cargo.lock b/Cargo.lock index 1b9c896f..ebc3ba7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,15 +103,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "assert_cmd" version = "2.2.2" @@ -497,6 +488,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2 0.11.0", ] [[package]] @@ -737,6 +729,7 @@ dependencies = [ "codegraph-store", "codegraph-watch", "notify", + "rayon", "rusqlite", "serde", "serde_json", @@ -1026,17 +1019,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "difflib" version = "0.4.0" @@ -2105,6 +2087,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -2336,15 +2330,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.11" @@ -2367,9 +2352,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", @@ -2732,9 +2717,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -2893,16 +2878,14 @@ dependencies = [ [[package]] name = "self_update" -version = "0.44.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e79722b5a505d4ddc77527455a97244e9e8c4c07533ff44cf4421cce7bb6d17" +checksum = "9253f1675d280b3e348d63a03ad6af3fa32d515b0959406758342561655c4aa0" dependencies = [ - "either", "flate2", "http", "indicatif", "log", - "quick-xml", "regex", "reqwest", "self-replace", @@ -3278,6 +3261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "libc", "num-conv", "num_threads", @@ -3940,6 +3924,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -4612,24 +4602,25 @@ dependencies = [ [[package]] name = "zip" -version = "6.0.0" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ - "arbitrary", "crc32fast", "flate2", "indexmap", "memchr", + "nix", "time", + "typed-path", "zopfli", ] [[package]] name = "zipsign-api" -version = "0.1.5" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" +checksum = "00d866e1d567fd67738668647c2e06587851e0e3583df84298cc363c14602316" dependencies = [ "base64 0.22.1", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index deb92c43..df06ac5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,12 +75,12 @@ indicatif = { version = "0.18", features = ["rayon"] } jsonc-parser = { version = "0.34", features = ["cst", "serde"] } # Self-update from GitHub Releases (mise-style `codegraph self-update`) -# self_update 0.43+ moved to quick-xml ^0.38 (fixes RUSTSEC-2026-0194/0195) and -# indicatif ^0.18; the builder API used by cmd_self_update is unchanged (no break -# until the 1.0.0-rc line). 0.44 also made the HTTP client optional: `reqwest` must -# be listed explicitly, and `rustls` gates on it (`reqwest?/rustls`), so keep both -# to get reqwest+rustls (no native-tls/OpenSSL) as before. Keep default-features=false. -self_update = { version = "0.44", default-features = false, features = ["reqwest", "rustls", "archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate"] } +# self_update 1.3.1 extracts zip archives through zip ^9 (RUSTSEC-2026-0367/0368/ +# 0369 hit zip 6, which 0.44 required). Since 1.0 each backend, the progress bar +# and the tar.gz decoder are features of their own (`github`, `progress-bar`, +# `compression-tar-gz`), and quick-xml only comes with the `s3` backend. The HTTP +# client stays reqwest with rustls (no native-tls/OpenSSL). Keep default-features=false. +self_update = { version = "1.3.1", default-features = false, features = ["reqwest", "rustls", "github", "progress-bar", "archive-tar", "archive-zip", "compression-tar-gz", "compression-zip-deflate"] } # Benchmarking criterion = "0.8" diff --git a/Makefile b/Makefile index c69dfc3b..87b4c72c 100644 --- a/Makefile +++ b/Makefile @@ -119,7 +119,7 @@ guardrail: script-tests: @for test_script in scripts/tests/*.test.sh; do \ - echo "Running $$test_script"; bash "$$test_script"; \ + echo "Running $$test_script"; bash "$$test_script" || exit 1; \ done # Keep all supported local/CI entry points byte-for-byte equivalent in ordering. diff --git a/crates/codegraph-bench/Cargo.toml b/crates/codegraph-bench/Cargo.toml index e82403da..2c3a2311 100644 --- a/crates/codegraph-bench/Cargo.toml +++ b/crates/codegraph-bench/Cargo.toml @@ -23,6 +23,7 @@ serde_json = { workspace = true } anyhow = { workspace = true } clap = { workspace = true } rusqlite = { workspace = true } +sha2 = { workspace = true } codegraph-core = { path = "../codegraph-core" } codegraph-extract = { path = "../codegraph-extract" } diff --git a/crates/codegraph-bench/fixtures/commonjs/index.js b/crates/codegraph-bench/fixtures/commonjs/index.js new file mode 100644 index 00000000..855394be --- /dev/null +++ b/crates/codegraph-bench/fixtures/commonjs/index.js @@ -0,0 +1,5 @@ +'use strict'; + +const app = require('./lib/app'); + +module.exports = app; diff --git a/crates/codegraph-bench/fixtures/commonjs/lib/app.js b/crates/codegraph-bench/fixtures/commonjs/lib/app.js new file mode 100644 index 00000000..c36311ff --- /dev/null +++ b/crates/codegraph-bench/fixtures/commonjs/lib/app.js @@ -0,0 +1,15 @@ +'use strict'; + +const { default: Service } = require('./service'); +const util = require('./util'), + format = require('./format'); + +function createApp() { + const service = new Service(); + return util.wrap(format.title(service.name())); +} + +module.exports = createApp; +module.exports.setCharset = function setCharset(value) { + return format.title(value); +}; diff --git a/crates/codegraph-bench/fixtures/commonjs/lib/chain.js b/crates/codegraph-bench/fixtures/commonjs/lib/chain.js new file mode 100644 index 00000000..79ea0135 --- /dev/null +++ b/crates/codegraph-bench/fixtures/commonjs/lib/chain.js @@ -0,0 +1,7 @@ +'use strict'; + +const wrap = require('./util').wrap; + +module.exports = function chain(value) { + return require('./format').title(wrap(value)); +}; diff --git a/crates/codegraph-bench/fixtures/commonjs/lib/format.js b/crates/codegraph-bench/fixtures/commonjs/lib/format.js new file mode 100644 index 00000000..af81dba2 --- /dev/null +++ b/crates/codegraph-bench/fixtures/commonjs/lib/format.js @@ -0,0 +1,7 @@ +'use strict'; + +function title(value) { + return String(value).toUpperCase(); +} + +module.exports = { title }; diff --git a/crates/codegraph-bench/fixtures/commonjs/lib/service.js b/crates/codegraph-bench/fixtures/commonjs/lib/service.js new file mode 100644 index 00000000..5718c66a --- /dev/null +++ b/crates/codegraph-bench/fixtures/commonjs/lib/service.js @@ -0,0 +1,9 @@ +'use strict'; + +class Service { + name() { + return 'service'; + } +} + +exports.default = Service; diff --git a/crates/codegraph-bench/fixtures/commonjs/lib/util.js b/crates/codegraph-bench/fixtures/commonjs/lib/util.js new file mode 100644 index 00000000..c35c629d --- /dev/null +++ b/crates/codegraph-bench/fixtures/commonjs/lib/util.js @@ -0,0 +1,5 @@ +'use strict'; + +exports.wrap = function wrap(value) { + return '[' + value + ']'; +}; diff --git a/crates/codegraph-bench/fixtures/commonjs/package.json b/crates/codegraph-bench/fixtures/commonjs/package.json new file mode 100644 index 00000000..ae3c8557 --- /dev/null +++ b/crates/codegraph-bench/fixtures/commonjs/package.json @@ -0,0 +1,8 @@ +{ + "name": "commonjs-fixture", + "private": true, + "main": "index.js", + "dependencies": { + "supertest": "6.3.0" + } +} diff --git a/crates/codegraph-bench/fixtures/commonjs/test/app.test.js b/crates/codegraph-bench/fixtures/commonjs/test/app.test.js new file mode 100644 index 00000000..779be076 --- /dev/null +++ b/crates/codegraph-bench/fixtures/commonjs/test/app.test.js @@ -0,0 +1,12 @@ +'use strict'; + +const request = require('supertest'); +const createApp = require('../'); +const { setCharset } = require('../lib/app'); + +describe('app', () => { + it('builds', () => { + request(createApp()); + setCharset('utf-8'); + }); +}); diff --git a/crates/codegraph-bench/fixtures/csharp/src/App/Nested.cs b/crates/codegraph-bench/fixtures/csharp/src/App/Nested.cs new file mode 100644 index 00000000..3021f82f --- /dev/null +++ b/crates/codegraph-bench/fixtures/csharp/src/App/Nested.cs @@ -0,0 +1,26 @@ +namespace Outer +{ + namespace Inner + { + public class Deep + { + public void Dive() { Surface(); } + public void Surface() { } + } + } + + public class Shallow + { + public void Call() { new Inner.Deep().Dive(); } + } +} + +namespace JetBrains.Annotations +{ + public class Guard { } +} + +namespace Second +{ + public class Two { } +} diff --git a/crates/codegraph-bench/fixtures/csharp/src/App/Partial.cs b/crates/codegraph-bench/fixtures/csharp/src/App/Partial.cs new file mode 100644 index 00000000..eec4955d --- /dev/null +++ b/crates/codegraph-bench/fixtures/csharp/src/App/Partial.cs @@ -0,0 +1,12 @@ +namespace App +{ + public partial class Report + { + public void Render() { Header(); } + } + + public partial class Report + { + private void Header() { } + } +} diff --git a/crates/codegraph-bench/fixtures/csharp/src/App/Store.cs b/crates/codegraph-bench/fixtures/csharp/src/App/Store.cs new file mode 100644 index 00000000..d18f2478 --- /dev/null +++ b/crates/codegraph-bench/fixtures/csharp/src/App/Store.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using static System.Math; +using App.Support; + +namespace App +{ + public interface IStore + { + void Save(int x); + } + + public class BaseStore + { + protected int Seed() { return 1; } + } + + // base_list: one class base plus one interface. + public class Store : BaseStore, IStore + { + private readonly TextWriter _innerWriter; + private int seed = Seed(); + private readonly TypeMap map = new(); + + public Store(TextWriter writer) { _innerWriter = writer; } + + public int Count { get { return Measure(); } } + public int Size => Measure(); + public TypeMap Map { get; set; } + + public void Save(int x) + { + _innerWriter.WriteLine(x); + Log("saved"); + var squared = Pow(x, 2); + } + + public void Log(string message) => Log(message, 0); + public void Log(string message, int level) { Flush(); } + + private int Measure() { return seed; } + private void Flush() { } + } + + public class User + { + public Store Make(Store s) + { + TypeMap t = new TypeMap(); + var store = new Store(Console.Out); + store.Save(1); + return store; + } + } +} diff --git a/crates/codegraph-bench/fixtures/csharp/src/Support/TypeMap.cs b/crates/codegraph-bench/fixtures/csharp/src/Support/TypeMap.cs new file mode 100644 index 00000000..35fc3878 --- /dev/null +++ b/crates/codegraph-bench/fixtures/csharp/src/Support/TypeMap.cs @@ -0,0 +1,13 @@ +namespace App.Support +{ + public class TypeMap + { + public int Lookup(string key) { return 0; } + } + + public class Other + { + public void Flush() { } + public void WriteLine(int value) { } + } +} diff --git a/crates/codegraph-bench/fixtures/java/src/main/java/app/a/Field.java b/crates/codegraph-bench/fixtures/java/src/main/java/app/a/Field.java new file mode 100644 index 00000000..c632ab40 --- /dev/null +++ b/crates/codegraph-bench/fixtures/java/src/main/java/app/a/Field.java @@ -0,0 +1,5 @@ +package app.a; + +public class Field { + public String name() { return "a"; } +} diff --git a/crates/codegraph-bench/fixtures/java/src/main/java/app/b/EmailVerificationService.java b/crates/codegraph-bench/fixtures/java/src/main/java/app/b/EmailVerificationService.java new file mode 100644 index 00000000..1a27bd73 --- /dev/null +++ b/crates/codegraph-bench/fixtures/java/src/main/java/app/b/EmailVerificationService.java @@ -0,0 +1,5 @@ +package app.b; + +public class EmailVerificationService { + public boolean verify(String token) { return token != null; } +} diff --git a/crates/codegraph-bench/fixtures/java/src/main/java/app/b/Field.java b/crates/codegraph-bench/fixtures/java/src/main/java/app/b/Field.java new file mode 100644 index 00000000..f22e1a64 --- /dev/null +++ b/crates/codegraph-bench/fixtures/java/src/main/java/app/b/Field.java @@ -0,0 +1,5 @@ +package app.b; + +public class Field { + public String name() { return "b"; } +} diff --git a/crates/codegraph-bench/fixtures/java/src/main/java/app/shapes/Main.java b/crates/codegraph-bench/fixtures/java/src/main/java/app/shapes/Main.java new file mode 100644 index 00000000..73252388 --- /dev/null +++ b/crates/codegraph-bench/fixtures/java/src/main/java/app/shapes/Main.java @@ -0,0 +1,35 @@ +package app.shapes; + +import java.util.ArrayList; +import java.util.List; + +public class Main { + enum Op { + ADD { + int apply(int a, int b) { return combine(a, b); } + }, + SUB { + int apply(int a, int b) { return a - b; } + }; + + abstract int apply(int a, int b); + + static int combine(int a, int b) { return a + b; } + } + + Square build(Square p) { + Square s = new Square(2); + List all = new ArrayList<>(); + all.add(s); + Runnable r = new Runnable() { + @Override + public void run() { helper(); } + }; + r.run(); + return s; + } + + int picks() { return Square.pick(1) + Square.pick("x"); } + + void helper() { } +} diff --git a/crates/codegraph-bench/fixtures/java/src/main/java/app/shapes/Shape.java b/crates/codegraph-bench/fixtures/java/src/main/java/app/shapes/Shape.java new file mode 100644 index 00000000..ad1d372a --- /dev/null +++ b/crates/codegraph-bench/fixtures/java/src/main/java/app/shapes/Shape.java @@ -0,0 +1,5 @@ +package app.shapes; + +public interface Shape { + int area(); +} diff --git a/crates/codegraph-bench/fixtures/java/src/main/java/app/shapes/Square.java b/crates/codegraph-bench/fixtures/java/src/main/java/app/shapes/Square.java new file mode 100644 index 00000000..a976e2c6 --- /dev/null +++ b/crates/codegraph-bench/fixtures/java/src/main/java/app/shapes/Square.java @@ -0,0 +1,19 @@ +package app.shapes; + +import static app.util.MathUtil.square; + +public class Square extends Base implements Shape { + private final int side; + + public Square(int side) { this.side = side; } + + @Override + public int area() { return square(side); } + + public static int pick(int x) { return x; } + public static int pick(String s) { return s.length(); } +} + +class Base { + void start() { } +} diff --git a/crates/codegraph-bench/fixtures/java/src/main/java/app/util/MathUtil.java b/crates/codegraph-bench/fixtures/java/src/main/java/app/util/MathUtil.java new file mode 100644 index 00000000..cf84f96c --- /dev/null +++ b/crates/codegraph-bench/fixtures/java/src/main/java/app/util/MathUtil.java @@ -0,0 +1,7 @@ +package app.util; + +public final class MathUtil { + private MathUtil() { } + + public static int square(int x) { return x * x; } +} diff --git a/crates/codegraph-bench/fixtures/java/src/test/java/app/MainTest.java b/crates/codegraph-bench/fixtures/java/src/test/java/app/MainTest.java new file mode 100644 index 00000000..1e614898 --- /dev/null +++ b/crates/codegraph-bench/fixtures/java/src/test/java/app/MainTest.java @@ -0,0 +1,21 @@ +package app; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import app.a.Field; +import java.lang.reflect.Method; + +public class MainTest { + static class Field { + String id() { return "nested"; } + } + + void checks() throws Exception { + Object service = mock(Object.class); + verify(service); + app.a.Field real = new app.a.Field(); + real.name(); + Method m = Object.class.getMethod("toString"); + } +} diff --git a/crates/codegraph-bench/fixtures/mobile/bridge/ios/Caller.m b/crates/codegraph-bench/fixtures/mobile/bridge/ios/Caller.m new file mode 100644 index 00000000..3f68a4b6 --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/bridge/ios/Caller.m @@ -0,0 +1,8 @@ +#import "App-Swift.h" + +@implementation Caller +- (void)run { + Greeter *greeter = [[Greeter alloc] init]; + [greeter greet:@"world"]; +} +@end diff --git a/crates/codegraph-bench/fixtures/mobile/bridge/ios/Greeter.swift b/crates/codegraph-bench/fixtures/mobile/bridge/ios/Greeter.swift new file mode 100644 index 00000000..9bd404fe --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/bridge/ios/Greeter.swift @@ -0,0 +1,7 @@ +import Foundation + +@objc class Greeter: NSObject { + @objc func greet(_ name: String) -> String { + return "Hello \(name)" + } +} diff --git a/crates/codegraph-bench/fixtures/mobile/expo-modules/android/src/main/java/expo/modules/hello/HelloModule.kt b/crates/codegraph-bench/fixtures/mobile/expo-modules/android/src/main/java/expo/modules/hello/HelloModule.kt new file mode 100644 index 00000000..43206156 --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/expo-modules/android/src/main/java/expo/modules/hello/HelloModule.kt @@ -0,0 +1,16 @@ +package expo.modules.hello + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class HelloModule : Module() { + override fun definition() = ModuleDefinition { + Name("Hello") + + Function("hello") { name: String -> + greet(name) + } + } +} + +fun greet(name: String): String = "Hello $name" diff --git a/crates/codegraph-bench/fixtures/mobile/expo-modules/ios/HelloModule.swift b/crates/codegraph-bench/fixtures/mobile/expo-modules/ios/HelloModule.swift new file mode 100644 index 00000000..1ec80a83 --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/expo-modules/ios/HelloModule.swift @@ -0,0 +1,15 @@ +import ExpoModulesCore + +public class HelloModule: Module { + public func definition() -> ModuleDefinition { + Name("Hello") + + Function("hello") { (name: String) -> String in + return greet(name) + } + } +} + +func greet(_ name: String) -> String { + return "Hello \(name)" +} diff --git a/crates/codegraph-bench/fixtures/mobile/expo-modules/package.json b/crates/codegraph-bench/fixtures/mobile/expo-modules/package.json new file mode 100644 index 00000000..24cf2742 --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/expo-modules/package.json @@ -0,0 +1 @@ +{ "name": "expo-hello", "private": true, "dependencies": { "expo-modules-core": "1.11.0" } } diff --git a/crates/codegraph-bench/fixtures/mobile/expo-modules/src/index.ts b/crates/codegraph-bench/fixtures/mobile/expo-modules/src/index.ts new file mode 100644 index 00000000..70d93e6c --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/expo-modules/src/index.ts @@ -0,0 +1,7 @@ +import { requireNativeModule } from 'expo-modules-core'; + +const HelloModule = requireNativeModule('Hello'); + +export function hello(name: string): string { + return HelloModule.hello(name); +} diff --git a/crates/codegraph-bench/fixtures/mobile/flutter/lib/main.dart b/crates/codegraph-bench/fixtures/mobile/flutter/lib/main.dart new file mode 100644 index 00000000..4ee78287 --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/flutter/lib/main.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +void main() => runApp(const HelloApp()); + +class HelloApp extends StatelessWidget { + const HelloApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp(home: Scaffold(body: Text(greeting()))); + } +} + +String greeting() => 'Hello from Flutter'; diff --git a/crates/codegraph-bench/fixtures/mobile/flutter/pubspec.yaml b/crates/codegraph-bench/fixtures/mobile/flutter/pubspec.yaml new file mode 100644 index 00000000..ef732098 --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/flutter/pubspec.yaml @@ -0,0 +1,11 @@ +name: hello_flutter +description: A Flutter app the mobile corpus uses to trigger Flutter detection. +publish_to: none +version: 1.0.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter diff --git a/crates/codegraph-bench/fixtures/mobile/rn/android/app/src/main/java/com/example/CameraModule.java b/crates/codegraph-bench/fixtures/mobile/rn/android/app/src/main/java/com/example/CameraModule.java new file mode 100644 index 00000000..ec5729e9 --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/rn/android/app/src/main/java/com/example/CameraModule.java @@ -0,0 +1,21 @@ +package com.example; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +public class CameraModule extends ReactContextBaseJavaModule { + @Override + public String getName() { + return "CameraModule"; + } + + @ReactMethod + public void takePicture(Promise promise) { + promise.resolve(capture()); + } + + private String capture() { + return "file://photo.jpg"; + } +} diff --git a/crates/codegraph-bench/fixtures/mobile/rn/ios/CameraModule.m b/crates/codegraph-bench/fixtures/mobile/rn/ios/CameraModule.m new file mode 100644 index 00000000..c9f9499a --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/rn/ios/CameraModule.m @@ -0,0 +1,22 @@ +#import +#import + +@interface CameraModule : RCTEventEmitter +@end + +@implementation CameraModule + +RCT_EXPORT_MODULE(); + +RCT_EXPORT_METHOD(takePicture:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +{ + resolve([self capture]); + [self sendEventWithName:@"onPictureTaken" body:@{}]; +} + +- (NSString *)capture +{ + return @"file://photo.jpg"; +} + +@end diff --git a/crates/codegraph-bench/fixtures/mobile/rn/package.json b/crates/codegraph-bench/fixtures/mobile/rn/package.json new file mode 100644 index 00000000..de057a3a --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/rn/package.json @@ -0,0 +1 @@ +{ "name": "rn-app", "private": true, "dependencies": { "react": "18.2.0", "react-native": "0.73.0" } } diff --git a/crates/codegraph-bench/fixtures/mobile/rn/src/Camera.tsx b/crates/codegraph-bench/fixtures/mobile/rn/src/Camera.tsx new file mode 100644 index 00000000..40d6a52b --- /dev/null +++ b/crates/codegraph-bench/fixtures/mobile/rn/src/Camera.tsx @@ -0,0 +1,15 @@ +import { NativeEventEmitter, NativeModules, requireNativeComponent } from 'react-native'; + +const { CameraModule } = NativeModules; +export const PICTURE_TAKEN = 'onPictureTaken'; +const emitter = new NativeEventEmitter(CameraModule); + +export const CameraView = requireNativeComponent('RCTCameraView'); + +export function takePicture(): Promise { + return CameraModule.takePicture(); +} + +export function listen(callback: (uri: string) => void) { + return emitter.addListener(PICTURE_TAKEN, callback); +} diff --git a/crates/codegraph-bench/fixtures/objc/Sources/Base.h b/crates/codegraph-bench/fixtures/objc/Sources/Base.h new file mode 100644 index 00000000..003089bb --- /dev/null +++ b/crates/codegraph-bench/fixtures/objc/Sources/Base.h @@ -0,0 +1,9 @@ +#import + +@protocol Doer +- (void)work; +@end + +@interface Base : NSObject +- (void)ping; +@end diff --git a/crates/codegraph-bench/fixtures/objc/Sources/Base.m b/crates/codegraph-bench/fixtures/objc/Sources/Base.m new file mode 100644 index 00000000..6e18f595 --- /dev/null +++ b/crates/codegraph-bench/fixtures/objc/Sources/Base.m @@ -0,0 +1,7 @@ +#import "Base.h" + +@implementation Base +- (void)ping { + NSLog(@"ping"); +} +@end diff --git a/crates/codegraph-bench/fixtures/objc/Sources/Sub.m b/crates/codegraph-bench/fixtures/objc/Sources/Sub.m new file mode 100644 index 00000000..56341e94 --- /dev/null +++ b/crates/codegraph-bench/fixtures/objc/Sources/Sub.m @@ -0,0 +1,16 @@ +#import "Base.h" + +static void cancel(void) { } + +@interface Sub : Base +- (void)work; +@end + +@implementation Sub +- (void)work { + [self ping]; + [super ping]; + [Base new]; + cancel(); +} +@end diff --git a/crates/codegraph-bench/fixtures/php/src/Controller.php b/crates/codegraph-bench/fixtures/php/src/Controller.php new file mode 100644 index 00000000..f7fd4e5c --- /dev/null +++ b/crates/codegraph-bench/fixtures/php/src/Controller.php @@ -0,0 +1,41 @@ + trans('title'), + 'price' => formatprice(300), + 'name' => $name->render() . $other->render(), + ]; + } + + public function parentCall(): string + { + return parent::describe(); + } +} + +class Base +{ + public function describe(): string + { + return 'base'; + } +} diff --git a/crates/codegraph-bench/fixtures/php/src/Field/Base.php b/crates/codegraph-bench/fixtures/php/src/Field/Base.php new file mode 100644 index 00000000..265e308f --- /dev/null +++ b/crates/codegraph-bench/fixtures/php/src/Field/Base.php @@ -0,0 +1,11 @@ +label() . self::suffix(); + } + + private static function suffix(): string + { + return '!'; + } +} diff --git a/crates/codegraph-bench/fixtures/php/src/Field/Registry.php b/crates/codegraph-bench/fixtures/php/src/Field/Registry.php new file mode 100644 index 00000000..7da0fb85 --- /dev/null +++ b/crates/codegraph-bench/fixtures/php/src/Field/Registry.php @@ -0,0 +1,21 @@ +assertEquals(3, count($controller->show())); + } +} diff --git a/crates/codegraph-bench/fixtures/python_bases/pkg/__init__.py b/crates/codegraph-bench/fixtures/python_bases/pkg/__init__.py new file mode 100644 index 00000000..56e3adaf --- /dev/null +++ b/crates/codegraph-bench/fixtures/python_bases/pkg/__init__.py @@ -0,0 +1,3 @@ +from .models import User + +__all__ = ["User"] diff --git a/crates/codegraph-bench/fixtures/python_bases/pkg/models/__init__.py b/crates/codegraph-bench/fixtures/python_bases/pkg/models/__init__.py new file mode 100644 index 00000000..f4a2da08 --- /dev/null +++ b/crates/codegraph-bench/fixtures/python_bases/pkg/models/__init__.py @@ -0,0 +1 @@ +from .user import * diff --git a/crates/codegraph-bench/fixtures/python_bases/pkg/models/base.py b/crates/codegraph-bench/fixtures/python_bases/pkg/models/base.py new file mode 100644 index 00000000..66fe1827 --- /dev/null +++ b/crates/codegraph-bench/fixtures/python_bases/pkg/models/base.py @@ -0,0 +1,16 @@ +class Mixin: + def describe(self): + return type(self).__name__ + + +class Base: + @classmethod + def create(cls): + return cls() + + def ping(self): + return 1 + + +class Meta(type): + pass diff --git a/crates/codegraph-bench/fixtures/python_bases/pkg/models/user.py b/crates/codegraph-bench/fixtures/python_bases/pkg/models/user.py new file mode 100644 index 00000000..da79a545 --- /dev/null +++ b/crates/codegraph-bench/fixtures/python_bases/pkg/models/user.py @@ -0,0 +1,12 @@ +from .base import Base, Meta, Mixin + + +class User(Base, Mixin, metaclass=Meta): + def run(self): + super().ping() + return self.describe() + + +class Admin(User): + def run(self): + return super().run() diff --git a/crates/codegraph-bench/fixtures/python_bases/pkg/service.py b/crates/codegraph-bench/fixtures/python_bases/pkg/service.py new file mode 100644 index 00000000..97ce395f --- /dev/null +++ b/crates/codegraph-bench/fixtures/python_bases/pkg/service.py @@ -0,0 +1,8 @@ +from pkg import models +from pkg.models import User + + +def make_user(): + user = User.create() + other = models.User() + return user.run(), other.run() diff --git a/crates/codegraph-bench/fixtures/python_bases/tests/conftest.py b/crates/codegraph-bench/fixtures/python_bases/tests/conftest.py new file mode 100644 index 00000000..0f79d83f --- /dev/null +++ b/crates/codegraph-bench/fixtures/python_bases/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from pkg.models.user import Admin + + +@pytest.fixture +def admin(): + return Admin() diff --git a/crates/codegraph-bench/fixtures/python_bases/tests/test_service.py b/crates/codegraph-bench/fixtures/python_bases/tests/test_service.py new file mode 100644 index 00000000..a7753b5b --- /dev/null +++ b/crates/codegraph-bench/fixtures/python_bases/tests/test_service.py @@ -0,0 +1,9 @@ +from pkg.service import make_user + + +def test_admin(admin): + assert admin.run() + + +def test_make_user(): + assert make_user() diff --git a/crates/codegraph-bench/fixtures/routers/apps/angular/package.json b/crates/codegraph-bench/fixtures/routers/apps/angular/package.json new file mode 100644 index 00000000..54dbe2a7 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/angular/package.json @@ -0,0 +1 @@ +{ "name": "angular-app", "private": true, "dependencies": { "@angular/core": "17.2.0", "@angular/router": "17.2.0" } } diff --git a/crates/codegraph-bench/fixtures/routers/apps/angular/src/app/app.routes.ts b/crates/codegraph-bench/fixtures/routers/apps/angular/src/app/app.routes.ts new file mode 100644 index 00000000..cbaa9aa5 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/angular/src/app/app.routes.ts @@ -0,0 +1,7 @@ +import { Routes } from '@angular/router'; +import { HomeComponent } from './home/home.component'; + +export const routes: Routes = [ + { path: '', component: HomeComponent }, + { path: 'lazy', loadComponent: () => import('./home/home.component').then((m) => m.HomeComponent) }, +]; diff --git a/crates/codegraph-bench/fixtures/routers/apps/angular/src/app/home/home.component.ts b/crates/codegraph-bench/fixtures/routers/apps/angular/src/app/home/home.component.ts new file mode 100644 index 00000000..4b54b5cf --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/angular/src/app/home/home.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-home', + template: ``, +}) +export class HomeComponent { + constructor(private router: Router) {} + + title(): string { + return 'home'; + } + + open(): void { + this.router.navigate(['/lazy']); + } +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/expo/app/_layout.tsx b/crates/codegraph-bench/fixtures/routers/apps/expo/app/_layout.tsx new file mode 100644 index 00000000..5b8152d8 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/expo/app/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function Layout() { + return ; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/expo/app/api/hello+api.ts b/crates/codegraph-bench/fixtures/routers/apps/expo/app/api/hello+api.ts new file mode 100644 index 00000000..1e577c45 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/expo/app/api/hello+api.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ hello: 'world' }); +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/expo/app/index.tsx b/crates/codegraph-bench/fixtures/routers/apps/expo/app/index.tsx new file mode 100644 index 00000000..7e169c1e --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/expo/app/index.tsx @@ -0,0 +1,6 @@ +import { router } from 'expo-router'; +import { Button } from 'react-native'; + +export default function Index() { + return ; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/next/app/layout.tsx b/crates/codegraph-bench/fixtures/routers/apps/next/app/layout.tsx new file mode 100644 index 00000000..b3b059da --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/next/app/layout.tsx @@ -0,0 +1,3 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/next/app/page.tsx b/crates/codegraph-bench/fixtures/routers/apps/next/app/page.tsx new file mode 100644 index 00000000..b9b61343 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/next/app/page.tsx @@ -0,0 +1,5 @@ +import Link from 'next/link'; + +export default function Page() { + return Dashboard; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/next/components/posts.tsx b/crates/codegraph-bench/fixtures/routers/apps/next/components/posts.tsx new file mode 100644 index 00000000..8dd4433a --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/next/components/posts.tsx @@ -0,0 +1,3 @@ +export function Posts() { + return
    ; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/next/package.json b/crates/codegraph-bench/fixtures/routers/apps/next/package.json new file mode 100644 index 00000000..ae5a8c8b --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/next/package.json @@ -0,0 +1 @@ +{ "name": "next-app", "private": true, "dependencies": { "next": "14.1.0", "react": "18.2.0" } } diff --git a/crates/codegraph-bench/fixtures/routers/apps/next/pages/legacy.tsx b/crates/codegraph-bench/fixtures/routers/apps/next/pages/legacy.tsx new file mode 100644 index 00000000..9b79d16a --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/next/pages/legacy.tsx @@ -0,0 +1,3 @@ +export default function Legacy() { + return

    legacy

    ; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/nuxt/package.json b/crates/codegraph-bench/fixtures/routers/apps/nuxt/package.json new file mode 100644 index 00000000..1d539853 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/nuxt/package.json @@ -0,0 +1 @@ +{ "name": "nuxt-app", "private": true, "dependencies": { "nuxt": "3.10.0" } } diff --git a/crates/codegraph-bench/fixtures/routers/apps/nuxt/pages/index.vue b/crates/codegraph-bench/fixtures/routers/apps/nuxt/pages/index.vue new file mode 100644 index 00000000..08e5fa82 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/nuxt/pages/index.vue @@ -0,0 +1,6 @@ + + diff --git a/crates/codegraph-bench/fixtures/routers/apps/nuxt/pages/users/[id].vue b/crates/codegraph-bench/fixtures/routers/apps/nuxt/pages/users/[id].vue new file mode 100644 index 00000000..897a2f60 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/nuxt/pages/users/[id].vue @@ -0,0 +1,5 @@ + + diff --git a/crates/codegraph-bench/fixtures/routers/apps/react-router/package.json b/crates/codegraph-bench/fixtures/routers/apps/react-router/package.json new file mode 100644 index 00000000..1a9a657c --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/react-router/package.json @@ -0,0 +1 @@ +{ "name": "react-router-app", "private": true, "dependencies": { "react": "18.2.0", "react-router-dom": "6.22.0" } } diff --git a/crates/codegraph-bench/fixtures/routers/apps/react-router/src/main.tsx b/crates/codegraph-bench/fixtures/routers/apps/react-router/src/main.tsx new file mode 100644 index 00000000..cbb3a8b3 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/react-router/src/main.tsx @@ -0,0 +1,24 @@ +import { createBrowserRouter, Link, useNavigate } from 'react-router-dom'; +import { Home } from './pages/Home'; +import { Layout } from './pages/Layout'; + +export const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'team', lazy: () => import('./pages/Team') }, + ], + }, +]); + +export function Nav() { + const navigate = useNavigate(); + return ( + + ); +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/react-router/src/pages/Home.tsx b/crates/codegraph-bench/fixtures/routers/apps/react-router/src/pages/Home.tsx new file mode 100644 index 00000000..028daa22 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/react-router/src/pages/Home.tsx @@ -0,0 +1,3 @@ +export function Home() { + return

    Home

    ; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/react-router/src/pages/Layout.tsx b/crates/codegraph-bench/fixtures/routers/apps/react-router/src/pages/Layout.tsx new file mode 100644 index 00000000..3bbb8f35 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/react-router/src/pages/Layout.tsx @@ -0,0 +1,5 @@ +import { Outlet } from 'react-router-dom'; + +export function Layout() { + return
    ; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/react-router/src/pages/Team.tsx b/crates/codegraph-bench/fixtures/routers/apps/react-router/src/pages/Team.tsx new file mode 100644 index 00000000..525cdebc --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/react-router/src/pages/Team.tsx @@ -0,0 +1,3 @@ +export function Component() { + return

    Team

    ; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/sveltekit/package.json b/crates/codegraph-bench/fixtures/routers/apps/sveltekit/package.json new file mode 100644 index 00000000..085a2d67 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/sveltekit/package.json @@ -0,0 +1 @@ +{ "name": "sveltekit-app", "private": true, "devDependencies": { "@sveltejs/kit": "2.5.0", "svelte": "4.2.0" } } diff --git a/crates/codegraph-bench/fixtures/routers/apps/sveltekit/src/routes/(marketing)/about/+page.svelte b/crates/codegraph-bench/fixtures/routers/apps/sveltekit/src/routes/(marketing)/about/+page.svelte new file mode 100644 index 00000000..ae068f61 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/sveltekit/src/routes/(marketing)/about/+page.svelte @@ -0,0 +1 @@ +

    About

    diff --git a/crates/codegraph-bench/fixtures/routers/apps/sveltekit/src/routes/+page.svelte b/crates/codegraph-bench/fixtures/routers/apps/sveltekit/src/routes/+page.svelte new file mode 100644 index 00000000..9d1354f7 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/sveltekit/src/routes/+page.svelte @@ -0,0 +1,7 @@ + + diff --git a/crates/codegraph-bench/fixtures/routers/apps/sveltekit/src/routes/blog/[slug=slug]/+page.ts b/crates/codegraph-bench/fixtures/routers/apps/sveltekit/src/routes/blog/[slug=slug]/+page.ts new file mode 100644 index 00000000..34a9bafe --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/sveltekit/src/routes/blog/[slug=slug]/+page.ts @@ -0,0 +1,8 @@ +import { redirect } from '@sveltejs/kit'; + +export function load({ params }) { + if (!params.slug) { + redirect(307, '/'); + } + return { slug: params.slug }; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/tanstack/package.json b/crates/codegraph-bench/fixtures/routers/apps/tanstack/package.json new file mode 100644 index 00000000..68484280 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/tanstack/package.json @@ -0,0 +1 @@ +{ "name": "tanstack-app", "private": true, "dependencies": { "@tanstack/react-router": "1.15.0", "react": "18.2.0" } } diff --git a/crates/codegraph-bench/fixtures/routers/apps/tanstack/src/routes/index.tsx b/crates/codegraph-bench/fixtures/routers/apps/tanstack/src/routes/index.tsx new file mode 100644 index 00000000..d2853772 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/tanstack/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute, Link } from '@tanstack/react-router'; + +export const Route = createFileRoute('/')({ + component: Index, +}); + +function Index() { + return Posts; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/tanstack/src/routes/posts.tsx b/crates/codegraph-bench/fixtures/routers/apps/tanstack/src/routes/posts.tsx new file mode 100644 index 00000000..dbd4e64e --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/tanstack/src/routes/posts.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/posts')({ + component: Posts, +}); + +function Posts() { + return
      ; +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/vue/package.json b/crates/codegraph-bench/fixtures/routers/apps/vue/package.json new file mode 100644 index 00000000..6cb3e445 --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/vue/package.json @@ -0,0 +1 @@ +{ "name": "vue-router-app", "private": true, "dependencies": { "vue": "3.4.0", "vue-router": "4.2.0" } } diff --git a/crates/codegraph-bench/fixtures/routers/apps/vue/src/router/index.ts b/crates/codegraph-bench/fixtures/routers/apps/vue/src/router/index.ts new file mode 100644 index 00000000..fc2915db --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/vue/src/router/index.ts @@ -0,0 +1,18 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import Home from '../views/Home.vue'; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'home', component: Home }, + { + path: '/about', + component: () => import('../views/About.vue'), + children: [{ path: 'team', component: () => import('../views/About.vue') }], + }, + ], +}); + +export function goAbout() { + return router.push('/about'); +} diff --git a/crates/codegraph-bench/fixtures/routers/apps/vue/src/views/About.vue b/crates/codegraph-bench/fixtures/routers/apps/vue/src/views/About.vue new file mode 100644 index 00000000..3b97c69b --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/vue/src/views/About.vue @@ -0,0 +1,3 @@ + + diff --git a/crates/codegraph-bench/fixtures/routers/apps/vue/src/views/Home.vue b/crates/codegraph-bench/fixtures/routers/apps/vue/src/views/Home.vue new file mode 100644 index 00000000..5fb4077c --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/apps/vue/src/views/Home.vue @@ -0,0 +1,4 @@ + + diff --git a/crates/codegraph-bench/fixtures/routers/package.json b/crates/codegraph-bench/fixtures/routers/package.json new file mode 100644 index 00000000..cfbc47eb --- /dev/null +++ b/crates/codegraph-bench/fixtures/routers/package.json @@ -0,0 +1,5 @@ +{ + "name": "routers-fixture", + "private": true, + "workspaces": ["apps/*"] +} diff --git a/crates/codegraph-bench/fixtures/servers/actix/Cargo.toml b/crates/codegraph-bench/fixtures/servers/actix/Cargo.toml new file mode 100644 index 00000000..1a635121 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/actix/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "actix-app" +version = "0.1.0" +edition = "2021" + +[dependencies] +actix-web = "4" diff --git a/crates/codegraph-bench/fixtures/servers/actix/src/main.rs b/crates/codegraph-bench/fixtures/servers/actix/src/main.rs new file mode 100644 index 00000000..35057ad9 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/actix/src/main.rs @@ -0,0 +1,15 @@ +use actix_web::{get, App, HttpResponse, HttpServer}; + +#[get("/ping")] +async fn ping() -> HttpResponse { + HttpResponse::Ok().body(pong()) +} + +fn pong() -> String { + "pong".to_string() +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + HttpServer::new(|| App::new().service(ping)).bind(("127.0.0.1", 8080))?.run().await +} diff --git a/crates/codegraph-bench/fixtures/servers/aspnet/App.csproj b/crates/codegraph-bench/fixtures/servers/aspnet/App.csproj new file mode 100644 index 00000000..f5775898 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/aspnet/App.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/crates/codegraph-bench/fixtures/servers/aspnet/Controllers/UsersController.cs b/crates/codegraph-bench/fixtures/servers/aspnet/Controllers/UsersController.cs new file mode 100644 index 00000000..ac04bbe6 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/aspnet/Controllers/UsersController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; + +namespace App.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class UsersController : ControllerBase + { + [HttpGet("{id}")] + public IActionResult Get(int id) + { + return Ok(Load(id)); + } + + private string Load(int id) => id.ToString(); + } +} diff --git a/crates/codegraph-bench/fixtures/servers/aspnet/Program.cs b/crates/codegraph-bench/fixtures/servers/aspnet/Program.cs new file mode 100644 index 00000000..f563ee94 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/aspnet/Program.cs @@ -0,0 +1,11 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +app.MapGet("hello", () => Greeter.Greet()); +app.MapControllers(); +app.Run(); + +static class Greeter +{ + public static string Greet() => "hello"; +} diff --git a/crates/codegraph-bench/fixtures/servers/axum/Cargo.toml b/crates/codegraph-bench/fixtures/servers/axum/Cargo.toml new file mode 100644 index 00000000..02134901 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/axum/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "axum-app" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.7" diff --git a/crates/codegraph-bench/fixtures/servers/axum/src/main.rs b/crates/codegraph-bench/fixtures/servers/axum/src/main.rs new file mode 100644 index 00000000..7c080c33 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/axum/src/main.rs @@ -0,0 +1,21 @@ +use axum::{routing::get, Router}; + +fn app() -> Router { + Router::new().route("/", get(root)).route("/users", get(list_users)) +} + +async fn root() -> &'static str { + "hello" +} + +async fn list_users() -> String { + format_users() +} + +fn format_users() -> String { + String::new() +} + +fn main() { + let _ = app(); +} diff --git a/crates/codegraph-bench/fixtures/servers/django/shop/urls.py b/crates/codegraph-bench/fixtures/servers/django/shop/urls.py new file mode 100644 index 00000000..cb8c98fd --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/django/shop/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("products/", views.product_list, name="product-list"), +] diff --git a/crates/codegraph-bench/fixtures/servers/django/shop/views.py b/crates/codegraph-bench/fixtures/servers/django/shop/views.py new file mode 100644 index 00000000..d2b065e4 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/django/shop/views.py @@ -0,0 +1,9 @@ +from django.http import JsonResponse + + +def product_list(request): + return JsonResponse({"items": load_items()}) + + +def load_items(): + return [] diff --git a/crates/codegraph-bench/fixtures/servers/drupal/modules/custom/greeting/greeting.routing.yml b/crates/codegraph-bench/fixtures/servers/drupal/modules/custom/greeting/greeting.routing.yml new file mode 100644 index 00000000..3d8b056f --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/drupal/modules/custom/greeting/greeting.routing.yml @@ -0,0 +1,6 @@ +greeting.hello: + path: '/hello' + defaults: + _controller: '\Drupal\greeting\Controller\GreetingController::hello' + requirements: + _permission: 'access content' diff --git a/crates/codegraph-bench/fixtures/servers/drupal/modules/custom/greeting/src/Controller/GreetingController.php b/crates/codegraph-bench/fixtures/servers/drupal/modules/custom/greeting/src/Controller/GreetingController.php new file mode 100644 index 00000000..a864373b --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/drupal/modules/custom/greeting/src/Controller/GreetingController.php @@ -0,0 +1,18 @@ + $this->message()]; + } + + private function message(): string + { + return 'Hello'; + } +} diff --git a/crates/codegraph-bench/fixtures/servers/express/app.js b/crates/codegraph-bench/fixtures/servers/express/app.js new file mode 100644 index 00000000..c832ceb5 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/express/app.js @@ -0,0 +1,20 @@ +const express = require('express'); +const users = require('./routes/users'); + +const app = express(); + +function requestLogger(req, res, next) { + next(); +} + +app.use(requestLogger); +app.use('/api', users); +app.get('/health', function health(req, res) { + res.json(status()); +}); + +function status() { + return { ok: true }; +} + +module.exports = app; diff --git a/crates/codegraph-bench/fixtures/servers/express/package.json b/crates/codegraph-bench/fixtures/servers/express/package.json new file mode 100644 index 00000000..6ca0076c --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/express/package.json @@ -0,0 +1 @@ +{ "name": "express-app", "private": true, "dependencies": { "express": "4.18.2" } } diff --git a/crates/codegraph-bench/fixtures/servers/express/routes/users.js b/crates/codegraph-bench/fixtures/servers/express/routes/users.js new file mode 100644 index 00000000..d552010c --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/express/routes/users.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const router = express.Router(); + +function loadUser(id) { + return { id }; +} + +router.get('/users/:id', (req, res) => { + res.json(loadUser(req.params.id)); +}); + +module.exports = router; diff --git a/crates/codegraph-bench/fixtures/servers/fastapi/main.py b/crates/codegraph-bench/fixtures/servers/fastapi/main.py new file mode 100644 index 00000000..5c2c1a59 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/fastapi/main.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, FastAPI + +app = FastAPI() +router = APIRouter(prefix="/items") + + +@router.get("/{item_id}") +def read_item(item_id: int): + return lookup(item_id) + + +def lookup(item_id: int): + return {"id": item_id} + + +app.include_router(router) diff --git a/crates/codegraph-bench/fixtures/servers/flask/app.py b/crates/codegraph-bench/fixtures/servers/flask/app.py new file mode 100644 index 00000000..7cd014e8 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/flask/app.py @@ -0,0 +1,19 @@ +from flask import Flask + +app = Flask(__name__) + + +@app.route("/hello") +def hello(): + return greeting() + + +def greeting(): + return "hello" + + +def ping(): + return "pong" + + +app.add_url_rule("/ping", view_func=ping) diff --git a/crates/codegraph-bench/fixtures/servers/gin/go.mod b/crates/codegraph-bench/fixtures/servers/gin/go.mod new file mode 100644 index 00000000..c6cf5289 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/gin/go.mod @@ -0,0 +1,5 @@ +module example.com/ginapp + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 diff --git a/crates/codegraph-bench/fixtures/servers/gin/main.go b/crates/codegraph-bench/fixtures/servers/gin/main.go new file mode 100644 index 00000000..d8739494 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/gin/main.go @@ -0,0 +1,17 @@ +package main + +import "github.com/gin-gonic/gin" + +func ping(c *gin.Context) { + c.JSON(200, gin.H{"message": pong()}) +} + +func pong() string { + return "pong" +} + +func main() { + r := gin.Default() + r.GET("/ping", ping) + _ = r.Run() +} diff --git a/crates/codegraph-bench/fixtures/servers/laravel/app/Http/Controllers/UserController.php b/crates/codegraph-bench/fixtures/servers/laravel/app/Http/Controllers/UserController.php new file mode 100644 index 00000000..a9a2d83b --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/laravel/app/Http/Controllers/UserController.php @@ -0,0 +1,21 @@ + $this->load()]); + } + + public function stats() + { + return redirect('/users'); + } + + private function load(): array + { + return []; + } +} diff --git a/crates/codegraph-bench/fixtures/servers/laravel/composer.json b/crates/codegraph-bench/fixtures/servers/laravel/composer.json new file mode 100644 index 00000000..d590280a --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/laravel/composer.json @@ -0,0 +1 @@ +{ "name": "example/laravel-app", "require": { "laravel/framework": "^10.0" } } diff --git a/crates/codegraph-bench/fixtures/servers/laravel/routes/api.php b/crates/codegraph-bench/fixtures/servers/laravel/routes/api.php new file mode 100644 index 00000000..55d33ae0 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/laravel/routes/api.php @@ -0,0 +1,7 @@ +json(['ok' => true]); +}); diff --git a/crates/codegraph-bench/fixtures/servers/laravel/routes/web.php b/crates/codegraph-bench/fixtures/servers/laravel/routes/web.php new file mode 100644 index 00000000..fa9c9c43 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/laravel/routes/web.php @@ -0,0 +1,9 @@ +group(function () { + Route::get('/stats', 'UserController@stats'); +}); diff --git a/crates/codegraph-bench/fixtures/servers/nest/package.json b/crates/codegraph-bench/fixtures/servers/nest/package.json new file mode 100644 index 00000000..a12b5a56 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/nest/package.json @@ -0,0 +1 @@ +{ "name": "nest-app", "private": true, "dependencies": { "@nestjs/common": "10.3.0", "@nestjs/core": "10.3.0" } } diff --git a/crates/codegraph-bench/fixtures/servers/nest/src/app.module.ts b/crates/codegraph-bench/fixtures/servers/nest/src/app.module.ts new file mode 100644 index 00000000..96835913 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/nest/src/app.module.ts @@ -0,0 +1,6 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users/users.controller'; +import { UsersService } from './users/users.service'; + +@Module({ controllers: [UsersController], providers: [UsersService] }) +export class AppModule {} diff --git a/crates/codegraph-bench/fixtures/servers/nest/src/main.ts b/crates/codegraph-bench/fixtures/servers/nest/src/main.ts new file mode 100644 index 00000000..b9a44027 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/nest/src/main.ts @@ -0,0 +1,11 @@ +import { NestFactory } from '@nestjs/core'; +import { VersioningType } from '@nestjs/common'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('api'); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); + await app.listen(3000); +} +bootstrap(); diff --git a/crates/codegraph-bench/fixtures/servers/nest/src/users/users.controller.ts b/crates/codegraph-bench/fixtures/servers/nest/src/users/users.controller.ts new file mode 100644 index 00000000..9912673d --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/nest/src/users/users.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { UsersService } from './users.service'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get(':id') + findOne(@Param('id') id: string) { + return this.usersService.findOne(id); + } +} diff --git a/crates/codegraph-bench/fixtures/servers/nest/src/users/users.service.ts b/crates/codegraph-bench/fixtures/servers/nest/src/users/users.service.ts new file mode 100644 index 00000000..86ddc0ec --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/nest/src/users/users.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class UsersService { + findOne(id: string) { + return { id }; + } +} diff --git a/crates/codegraph-bench/fixtures/servers/play/app/controllers/HomeController.scala b/crates/codegraph-bench/fixtures/servers/play/app/controllers/HomeController.scala new file mode 100644 index 00000000..3a98b70d --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/play/app/controllers/HomeController.scala @@ -0,0 +1,13 @@ +package controllers + +import javax.inject._ +import play.api.mvc._ + +@Singleton +class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController { + def index(): Action[AnyContent] = Action { Ok(render()) } + + def show(id: Long): Action[AnyContent] = Action { Ok(id.toString) } + + private def render(): String = "home" +} diff --git a/crates/codegraph-bench/fixtures/servers/play/build.sbt b/crates/codegraph-bench/fixtures/servers/play/build.sbt new file mode 100644 index 00000000..a941fcfb --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/play/build.sbt @@ -0,0 +1 @@ +lazy val root = (project in file(".")).enablePlugins(PlayScala) diff --git a/crates/codegraph-bench/fixtures/servers/play/conf/routes b/crates/codegraph-bench/fixtures/servers/play/conf/routes new file mode 100644 index 00000000..013473d6 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/play/conf/routes @@ -0,0 +1,2 @@ +GET / controllers.HomeController.index() +GET /items/:id controllers.HomeController.show(id: Long) diff --git a/crates/codegraph-bench/fixtures/servers/rails/Gemfile b/crates/codegraph-bench/fixtures/servers/rails/Gemfile new file mode 100644 index 00000000..52899631 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/rails/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rails", "~> 7.1" diff --git a/crates/codegraph-bench/fixtures/servers/rails/app/controllers/admin/reports_controller.rb b/crates/codegraph-bench/fixtures/servers/rails/app/controllers/admin/reports_controller.rb new file mode 100644 index 00000000..aad85437 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/rails/app/controllers/admin/reports_controller.rb @@ -0,0 +1,7 @@ +module Admin + class ReportsController < ApplicationController + def index + render json: [] + end + end +end diff --git a/crates/codegraph-bench/fixtures/servers/rails/app/controllers/users_controller.rb b/crates/codegraph-bench/fixtures/servers/rails/app/controllers/users_controller.rb new file mode 100644 index 00000000..ebe09cc7 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/rails/app/controllers/users_controller.rb @@ -0,0 +1,15 @@ +class UsersController < ApplicationController + def index + render json: load_users + end + + def show + render json: { id: params[:id] } + end + + private + + def load_users + [] + end +end diff --git a/crates/codegraph-bench/fixtures/servers/rails/config/routes.rb b/crates/codegraph-bench/fixtures/servers/rails/config/routes.rb new file mode 100644 index 00000000..56aab6f1 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/rails/config/routes.rb @@ -0,0 +1,7 @@ +Rails.application.routes.draw do + resources :users, only: %i[index show] + namespace :admin do + resources :reports, except: [:destroy] + end + get "health", to: "health#show" +end diff --git a/crates/codegraph-bench/fixtures/servers/spring/pom.xml b/crates/codegraph-bench/fixtures/servers/spring/pom.xml new file mode 100644 index 00000000..e5e412d3 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/spring/pom.xml @@ -0,0 +1,12 @@ + + 4.0.0 + com.example + users + 1.0.0 + + + org.springframework.boot + spring-boot-starter-web + + + diff --git a/crates/codegraph-bench/fixtures/servers/spring/src/main/java/com/example/users/UserController.java b/crates/codegraph-bench/fixtures/servers/spring/src/main/java/com/example/users/UserController.java new file mode 100644 index 00000000..29f45573 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/spring/src/main/java/com/example/users/UserController.java @@ -0,0 +1,19 @@ +package com.example.users; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/users") +public class UserController { + @Autowired + private UserService userService; + + @GetMapping("/{id}") + public String show(@PathVariable String id) { + return userService.find(id); + } +} diff --git a/crates/codegraph-bench/fixtures/servers/spring/src/main/java/com/example/users/UserService.java b/crates/codegraph-bench/fixtures/servers/spring/src/main/java/com/example/users/UserService.java new file mode 100644 index 00000000..e87f6265 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/spring/src/main/java/com/example/users/UserService.java @@ -0,0 +1,10 @@ +package com.example.users; + +import org.springframework.stereotype.Service; + +@Service +public class UserService { + public String find(String id) { + return id; + } +} diff --git a/crates/codegraph-bench/fixtures/servers/vapor/Package.swift b/crates/codegraph-bench/fixtures/servers/vapor/Package.swift new file mode 100644 index 00000000..734266ac --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/vapor/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "App", + dependencies: [ + .package(url: "https://github.com/vapor/vapor.git", from: "4.89.0"), + ], + targets: [ + .executableTarget(name: "App", dependencies: [.product(name: "Vapor", package: "vapor")]), + ] +) diff --git a/crates/codegraph-bench/fixtures/servers/vapor/Sources/App/Controllers/TodoController.swift b/crates/codegraph-bench/fixtures/servers/vapor/Sources/App/Controllers/TodoController.swift new file mode 100644 index 00000000..590424b7 --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/vapor/Sources/App/Controllers/TodoController.swift @@ -0,0 +1,12 @@ +import Vapor + +struct TodoController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + let todos = routes.grouped("todos") + todos.get(use: index) + } + + func index(req: Request) async throws -> [String] { + return [] + } +} diff --git a/crates/codegraph-bench/fixtures/servers/vapor/Sources/App/routes.swift b/crates/codegraph-bench/fixtures/servers/vapor/Sources/App/routes.swift new file mode 100644 index 00000000..85811fdf --- /dev/null +++ b/crates/codegraph-bench/fixtures/servers/vapor/Sources/App/routes.swift @@ -0,0 +1,12 @@ +import Vapor + +func routes(_ app: Application) throws { + app.get("hello") { req -> String in + return greeting() + } + try app.register(collection: TodoController()) +} + +func greeting() -> String { + return "hello" +} diff --git a/crates/codegraph-bench/fixtures/svelte/package.json b/crates/codegraph-bench/fixtures/svelte/package.json new file mode 100644 index 00000000..bd6c854e --- /dev/null +++ b/crates/codegraph-bench/fixtures/svelte/package.json @@ -0,0 +1,7 @@ +{ + "name": "svelte-fixture", + "private": true, + "devDependencies": { + "svelte": "4.2.0" + } +} diff --git a/crates/codegraph-bench/fixtures/svelte/src/lib/Widget.svelte b/crates/codegraph-bench/fixtures/svelte/src/lib/Widget.svelte new file mode 100644 index 00000000..e2a50690 --- /dev/null +++ b/crates/codegraph-bench/fixtures/svelte/src/lib/Widget.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/crates/codegraph-bench/fixtures/svelte/src/lib/util.ts b/crates/codegraph-bench/fixtures/svelte/src/lib/util.ts new file mode 100644 index 00000000..cbe338c0 --- /dev/null +++ b/crates/codegraph-bench/fixtures/svelte/src/lib/util.ts @@ -0,0 +1,7 @@ +export function doSetup(): number { + return 1; +} + +export function formatLabel(value: string): string { + return value.trim(); +} diff --git a/crates/codegraph-bench/fixtures/swift/Sources/App/Client.swift b/crates/codegraph-bench/fixtures/swift/Sources/App/Client.swift new file mode 100644 index 00000000..ede4b2a2 --- /dev/null +++ b/crates/codegraph-bench/fixtures/swift/Sources/App/Client.swift @@ -0,0 +1,18 @@ +import Foundation + +final class Client { + let cache = Cache() + + func run() -> String { + let view = HomeView(title: "home") + cache.store(key: "a") + cache.store(key: "b", value: 2) + let smallest = min(1, 2) + _ = smallest + return API.DependencyController.GetRoute.query() + view.subtitle() + } +} + +#Preview { + HomeView(title: "preview") +} diff --git a/crates/codegraph-bench/fixtures/swift/Sources/App/Models.swift b/crates/codegraph-bench/fixtures/swift/Sources/App/Models.swift new file mode 100644 index 00000000..2e2f7dcd --- /dev/null +++ b/crates/codegraph-bench/fixtures/swift/Sources/App/Models.swift @@ -0,0 +1,38 @@ +import Foundation + +protocol Renderable { + func render() -> String +} + +struct HomeView: Renderable { + let title: String + + func render() -> String { return format(title) } + + private func format(_ value: String) -> String { return value.uppercased() } +} + +extension HomeView { + func subtitle() -> String { return render() + "!" } +} + +enum API { + enum DependencyController { + struct GetRoute { + static func query() -> String { return "deps" } + } + } +} + +enum PackageController { + struct GetRoute { + static func query() -> String { return "packages" } + } +} + +final class Cache { + var imageCachedType: Int = 0 + + func store(key: String, value: Int) { imageCachedType = value } + func store(key: String) { store(key: key, value: 0) } +} diff --git a/crates/codegraph-bench/fixtures/synthesis/c/src/ops.c b/crates/codegraph-bench/fixtures/synthesis/c/src/ops.c new file mode 100644 index 00000000..d66b47cc --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/c/src/ops.c @@ -0,0 +1,17 @@ +struct file_ops { + int (*open)(const char *path); + int (*close)(int fd); +}; + +static int my_open(const char *path) { return path != 0; } +static int my_close(int fd) { return fd; } + +static const struct file_ops ops = { + .open = my_open, + .close = my_close, +}; + +int run(const char *path) { + int fd = ops.open(path); + return ops.close(fd); +} diff --git a/crates/codegraph-bench/fixtures/synthesis/dispatch/src/Events.java b/crates/codegraph-bench/fixtures/synthesis/dispatch/src/Events.java new file mode 100644 index 00000000..502a79df --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/dispatch/src/Events.java @@ -0,0 +1,32 @@ +package app; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; + +public class Events { + private final ApplicationEventPublisher publisher; + + public Events(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + public void register(String user) { + publisher.publishEvent(new UserRegistered(user)); + } + + @EventListener + public void onRegistered(UserRegistered event) { + welcome(event.user); + } + + private void welcome(String user) { + } + + static class UserRegistered { + final String user; + + UserRegistered(String user) { + this.user = user; + } + } +} diff --git a/crates/codegraph-bench/fixtures/synthesis/go/go.mod b/crates/codegraph-bench/fixtures/synthesis/go/go.mod new file mode 100644 index 00000000..a240f36d --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/go/go.mod @@ -0,0 +1,3 @@ +module example.com/synth + +go 1.21 diff --git a/crates/codegraph-bench/fixtures/synthesis/go/store/store.go b/crates/codegraph-bench/fixtures/synthesis/go/store/store.go new file mode 100644 index 00000000..c98349e9 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/go/store/store.go @@ -0,0 +1,30 @@ +package store + +type Getter interface { + Get(key string) string +} + +type Store interface { + Getter + Set(key, value string) +} + +type base struct{} + +func (base) Get(key string) string { return key } + +type MemStore struct { + base + data map[string]string +} + +func (m *MemStore) Set(key, value string) { m.data[key] = value } + +type Alias = MemStore + +type Defined MemStore + +func Use(s Store) string { + s.Set("a", "b") + return s.Get("a") +} diff --git a/crates/codegraph-bench/fixtures/synthesis/js/package.json b/crates/codegraph-bench/fixtures/synthesis/js/package.json new file mode 100644 index 00000000..51452c02 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/package.json @@ -0,0 +1 @@ +{ "name": "synthesis-js", "private": true, "dependencies": { "react": "18.2.0", "@reduxjs/toolkit": "2.2.0", "pinia": "2.1.7" } } diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/Counter.tsx b/crates/codegraph-bench/fixtures/synthesis/js/src/Counter.tsx new file mode 100644 index 00000000..8eea5766 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/Counter.tsx @@ -0,0 +1,14 @@ +import { useState } from 'react'; + +function Label({ value }: { value: number }) { + return {value}; +} + +export function Counter() { + const [count, setCount] = useState(0); + return ( + + ); +} diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/events.js b/crates/codegraph-bench/fixtures/synthesis/js/src/events.js new file mode 100644 index 00000000..10281299 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/events.js @@ -0,0 +1,24 @@ +const { EventEmitter } = require('events'); + +const bus = new EventEmitter(); + +function onSaved(record) { + return record.id; +} + +bus.on('saved', onSaved); + +function save(record) { + bus.emit('saved', record); +} + +const handlers = { + create: (payload) => payload, + remove: (payload) => payload.id, +}; + +function dispatch(kind, payload) { + return handlers[kind](payload); +} + +module.exports = { save, dispatch }; diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/store.ts b/crates/codegraph-bench/fixtures/synthesis/js/src/store.ts new file mode 100644 index 00000000..f52350ea --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/store.ts @@ -0,0 +1,29 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { defineStore } from 'pinia'; + +export const fetchUser = createAsyncThunk('user/fetch', async (id: string) => { + return loadUser(id); +}); + +async function loadUser(id: string) { + return { id }; +} + +export const userSlice = createSlice({ + name: 'user', + initialState: { id: '' }, + reducers: { + reset(state) { + state.id = ''; + }, + }, +}); + +export const useCartStore = defineStore('cart', { + state: () => ({ items: [] as string[] }), + actions: { + add(item: string) { + this.items.push(item); + }, + }, +}); diff --git a/crates/codegraph-bench/fixtures/synthesis/kotlin/common/src/Platform.kt b/crates/codegraph-bench/fixtures/synthesis/kotlin/common/src/Platform.kt new file mode 100644 index 00000000..8e1988b9 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/kotlin/common/src/Platform.kt @@ -0,0 +1,5 @@ +package app + +expect fun platformName(): String + +fun greeting(): String = "Hello " + platformName() diff --git a/crates/codegraph-bench/fixtures/synthesis/kotlin/jvm/src/Platform.kt b/crates/codegraph-bench/fixtures/synthesis/kotlin/jvm/src/Platform.kt new file mode 100644 index 00000000..9f646b30 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/kotlin/jvm/src/Platform.kt @@ -0,0 +1,3 @@ +package app + +actual fun platformName(): String = "JVM" diff --git a/crates/codegraph-bench/fixtures/synthesis/mybatis/src/main/java/com/example/mapper/UserMapper.java b/crates/codegraph-bench/fixtures/synthesis/mybatis/src/main/java/com/example/mapper/UserMapper.java new file mode 100644 index 00000000..94e02406 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/mybatis/src/main/java/com/example/mapper/UserMapper.java @@ -0,0 +1,5 @@ +package com.example.mapper; + +public interface UserMapper { + String findName(long id); +} diff --git a/crates/codegraph-bench/fixtures/synthesis/mybatis/src/main/resources/mapper/UserMapper.xml b/crates/codegraph-bench/fixtures/synthesis/mybatis/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 00000000..86cae6c7 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/mybatis/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/crates/codegraph-bench/fixtures/synthesis/python/tasks.py b/crates/codegraph-bench/fixtures/synthesis/python/tasks.py new file mode 100644 index 00000000..7ec04251 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/python/tasks.py @@ -0,0 +1,16 @@ +from celery import Celery + +app = Celery("tasks") + + +@app.task +def send_email(address): + return render(address) + + +def render(address): + return address + + +def signup(address): + send_email.delay(address) diff --git a/crates/codegraph-bench/fixtures/vue/package.json b/crates/codegraph-bench/fixtures/vue/package.json new file mode 100644 index 00000000..1ed57b43 --- /dev/null +++ b/crates/codegraph-bench/fixtures/vue/package.json @@ -0,0 +1,7 @@ +{ + "name": "vue-fixture", + "private": true, + "dependencies": { + "vue": "3.4.0" + } +} diff --git a/crates/codegraph-bench/fixtures/vue/src/components/App.vue b/crates/codegraph-bench/fixtures/vue/src/components/App.vue new file mode 100644 index 00000000..ab5ef98d --- /dev/null +++ b/crates/codegraph-bench/fixtures/vue/src/components/App.vue @@ -0,0 +1,18 @@ + + + diff --git a/crates/codegraph-bench/fixtures/vue/src/components/Counter.vue b/crates/codegraph-bench/fixtures/vue/src/components/Counter.vue new file mode 100644 index 00000000..ab747775 --- /dev/null +++ b/crates/codegraph-bench/fixtures/vue/src/components/Counter.vue @@ -0,0 +1,27 @@ + + + diff --git a/crates/codegraph-bench/fixtures/vue/src/composables/useCounter.ts b/crates/codegraph-bench/fixtures/vue/src/composables/useCounter.ts new file mode 100644 index 00000000..01eb464a --- /dev/null +++ b/crates/codegraph-bench/fixtures/vue/src/composables/useCounter.ts @@ -0,0 +1,13 @@ +import { ref } from 'vue'; + +export function useCounter(start: number) { + const count = ref(start); + function increment() { + count.value += 1; + } + return { count, increment }; +} + +export function helper(): number { + return 1; +} diff --git a/crates/codegraph-bench/src/ab.rs b/crates/codegraph-bench/src/ab.rs new file mode 100644 index 00000000..f012e2cf --- /dev/null +++ b/crates/codegraph-bench/src/ab.rs @@ -0,0 +1,1289 @@ +//! `bench --ab`: cold-`init` A/B timing of two CodeGraph binaries. +//! +//! For every corpus, thread count and run, the harness copies the corpus +//! subtree into a fresh temporary project (without `.git` or an index +//! directory) and runs ` init ` with +//! `RAYON_NUM_THREADS=`, `CODEGRAPH_NO_DAEMON=1` and +//! `CODEGRAPH_NO_WATCH=1`, and with `CODEGRAPH_DIR` removed. The CLI has no +//! thread flag; parsing and resolution run on rayon's global pool, which reads +//! `RAYON_NUM_THREADS`. Each run records its wall time, its peak RSS and the +//! canonical fingerprint of the database it wrote. On Linux the peak RSS is the +//! child's own `VmHWM`, polled from `/proc//status` while it runs: +//! `wait4`'s `ru_maxrss` also counts the address space the child was forked +//! from, the harness's own, which grows as it canonicalizes each database, so +//! every run after a large corpus read the harness's size. Other Unix hosts +//! report `ru_maxrss`. +//! +//! Baseline and candidate runs alternate (even runs baseline first, odd runs +//! candidate first). The first run of every (corpus, binary, threads) cell is a +//! warm-up and is left out of the statistics. +//! +//! The harness fails closed and writes no report when a run exits non-zero, is +//! killed or times out; when a run exits 0 without a database, or with one that +//! fails `PRAGMA quick_check` or canonicalization; and when two runs of the +//! same binary on one corpus, at any thread counts, produce different canonical +//! graphs. A baseline/candidate graph difference is not an error: it is +//! reported per corpus, so a candidate that changes the graph can still be +//! timed against its row growth. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsStr; +use std::fmt; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use codegraph_core::index_paths::{DEFAULT_CURRENT_DIR, WSL_CURRENT_DIR}; +use serde::{Deserialize, Serialize}; + +use crate::corpus::{Corpus, benchmark_path_in, checkout_path_in}; +use crate::graph_diff::{ + GraphFingerprint, ScratchDir, canonicalize_checked, differing_surfaces, fingerprint, + sha256_file, +}; +#[cfg(unix)] +use crate::metrics::read_proc_status_kb; +use crate::metrics::{mad, median}; +use crate::report::{command_stdout, read_cpu_model, read_mem_total_kb, read_os_pretty_name}; + +/// Environment variable that sets the thread count of each timed run. +pub const THREAD_ENV: &str = "RAYON_NUM_THREADS"; +/// Default wall-clock cap for one timed run; a run past it is killed and fails. +pub const DEFAULT_RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// Regression budget (`docs/benchmark.md`): candidate/baseline cold-`init` +/// median wall time. +pub const INIT_WALL_RATIO_MAX: f64 = 1.10; +/// A slower `init` is still within budget when the candidate graph has more +/// canonical rows and its rows/s stay at or above this ratio. +pub const ROWS_PER_SECOND_RATIO_MIN: f64 = 0.90; +/// Regression budget: candidate/baseline median peak RSS. +pub const PEAK_RSS_RATIO_MAX: f64 = 1.15; + +const REPORT_KIND: &str = "codegraph-ab"; +const VERSION_TIMEOUT: Duration = Duration::from_secs(30); +const POLL_INTERVAL: Duration = Duration::from_millis(1); +const STDERR_TAIL_LINES: usize = 40; +const DATABASE_FILE: &str = "codegraph.db"; + +/// What one `bench --ab` invocation compares. +#[derive(Clone, Debug)] +pub struct AbConfig { + pub baseline: PathBuf, + pub candidate: PathBuf, + pub corpora: Vec, + pub threads: Vec, + /// Runs per (corpus, binary, threads) cell, including the warm-up. + pub runs: usize, + pub run_timeout: Duration, + /// Workspace whose commit is recorded as the harness commit. + pub workspace_root: Option, + /// The command line, recorded verbatim in the report. + pub argv: Vec, +} + +/// One corpus to time: the subtree copied into every run's project. +#[derive(Clone, Debug)] +pub struct AbCorpus { + pub name: String, + pub commit: Option, + pub tag: Option, + pub source: PathBuf, +} + +impl AbCorpus { + /// Resolve a registry corpus under `corpora_root`. A checkout that is + /// missing, at another commit than the pin, or modified is refused. + pub fn from_registry(corpus: Corpus, corpora_root: &Path) -> Result { + let checkout = checkout_path_in(corpora_root, corpus); + if !checkout.is_dir() { + bail!( + "corpus {} is not fetched at {} (run `bench --fetch-corpora --corpora-root {}`)", + corpus.name, + checkout.display(), + corpora_root.display() + ); + } + let head = git_stdout(&checkout, &["rev-parse", "HEAD"]) + .with_context(|| format!("reading the commit of {}", checkout.display()))?; + if head != corpus.commit { + bail!( + "corpus {} at {} is at {head}, but the registry pins {}", + corpus.name, + checkout.display(), + corpus.commit + ); + } + let status = git_stdout( + &checkout, + &["status", "--porcelain", "--untracked-files=normal"], + ) + .with_context(|| format!("reading the status of {}", checkout.display()))?; + if !status.is_empty() { + bail!( + "corpus {} at {} has local changes; restore the pinned checkout first:\n{status}", + corpus.name, + checkout.display() + ); + } + let source = benchmark_path_in(corpora_root, corpus); + if !source.is_dir() { + bail!( + "corpus {} has no benchmark directory {}", + corpus.name, + source.display() + ); + } + Ok(Self { + name: corpus.name.to_string(), + commit: Some(corpus.commit.to_string()), + tag: corpus.tag.map(str::to_string), + source, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AbReport { + pub schema_version: u32, + pub kind: String, + pub argv: Vec, + pub environment: AbEnvironment, + pub settings: AbSettings, + pub baseline: BinaryInfo, + pub candidate: BinaryInfo, + pub corpora: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AbEnvironment { + pub cpu_model: Option, + pub logical_cpus: Option, + pub memory_total_kb: Option, + pub os: Option, + pub kernel: Option, + pub rustc_version: Option, + pub harness_commit: Option, + pub harness_dirty: Option, + pub rss_collector: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AbSettings { + pub invocation: String, + pub env_set: BTreeMap, + pub env_removed: Vec, + pub thread_env: String, + pub threads: Vec, + pub runs: usize, + pub discarded_warmup_runs: usize, + pub order: String, + pub run_timeout_secs: u64, + pub budget: BudgetLimits, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub struct BudgetLimits { + pub init_wall_ratio_max: f64, + pub rows_per_second_ratio_min: f64, + pub peak_rss_ratio_max: f64, +} + +impl BudgetLimits { + pub const PROJECT: Self = Self { + init_wall_ratio_max: INIT_WALL_RATIO_MAX, + rows_per_second_ratio_min: ROWS_PER_SECOND_RATIO_MIN, + peak_rss_ratio_max: PEAK_RSS_RATIO_MAX, + }; +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct BinaryInfo { + pub path: String, + pub sha256: String, + pub size_bytes: u64, + /// First line of ` --version` when it exits 0 within 30 s. + pub version: Option, + /// The resolved path that is executed; `path` is its display form. + #[serde(skip)] + executable: PathBuf, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CorpusAb { + pub name: String, + pub commit: Option, + pub tag: Option, + pub source: String, + pub copied_files: u64, + pub skipped_symlinks: u64, + pub graph: GraphComparison, + pub cells: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct GraphComparison { + pub identical: bool, + pub differing_surfaces: Vec, + pub baseline: GraphFingerprint, + pub candidate: GraphFingerprint, +} + +/// One corpus at one thread count. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Cell { + pub threads: usize, + pub baseline: ArmSeries, + pub candidate: ArmSeries, + pub ratio: Ratios, + pub budget: BudgetVerdict, +} + +/// Every run of one binary in a cell, and the statistics of the measured ones. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ArmSeries { + pub runs: Vec, + pub wall_ms: Summary, + pub peak_rss_kb: Option, + pub rows_per_second: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RunSample { + /// 1-based run number within the cell. + pub run: usize, + pub warmup: bool, + pub wall_ms: f64, + pub peak_rss_kb: Option, + pub canonical_hash: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] +pub struct Summary { + pub median: f64, + pub mad: f64, + pub samples: usize, +} + +impl Summary { + fn of(samples: &[f64]) -> Option { + Some(Self { + median: median(samples)?, + mad: mad(samples)?, + samples: samples.len(), + }) + } +} + +/// Candidate divided by baseline; `None` when either side is missing or zero. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] +pub struct Ratios { + pub wall_ms: Option, + pub peak_rss_kb: Option, + pub canonical_rows: Option, + pub rows_per_second: Option, +} + +/// The regression budget applied to one cell; `None` when not measurable. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] +pub struct BudgetVerdict { + pub init_wall_ok: Option, + pub peak_rss_ok: Option, +} + +impl BudgetVerdict { + pub fn evaluate(ratio: &Ratios, limits: &BudgetLimits) -> Self { + let init_wall_ok = ratio.wall_ms.map(|wall| { + let rows_grew = ratio.canonical_rows.is_some_and(|rows| rows > 1.0); + let throughput_held = ratio + .rows_per_second + .is_some_and(|rate| rate >= limits.rows_per_second_ratio_min); + wall <= limits.init_wall_ratio_max || (rows_grew && throughput_held) + }); + let peak_rss_ok = ratio + .peak_rss_kb + .map(|rss| rss <= limits.peak_rss_ratio_max); + Self { + init_wall_ok, + peak_rss_ok, + } + } +} + +/// Time both binaries on every configured corpus. +pub fn run_ab(config: &AbConfig) -> Result { + validate(config)?; + let scratch = ScratchDir::new("ab")?; + let baseline = BinaryInfo::inspect(&config.baseline, &scratch.path().join("baseline.version")) + .context("inspecting the --baseline binary")?; + let candidate = + BinaryInfo::inspect(&config.candidate, &scratch.path().join("candidate.version")) + .context("inspecting the --candidate binary")?; + let binaries = [&baseline, &candidate]; + let mut corpora = Vec::with_capacity(config.corpora.len()); + for (index, corpus) in config.corpora.iter().enumerate() { + eprintln!("==> {} ({})", corpus.name, corpus.source.display()); + corpora.push(measure_corpus( + config, + index, + corpus, + binaries, + scratch.path(), + )?); + } + Ok(AbReport { + schema_version: 1, + kind: REPORT_KIND.to_string(), + argv: config.argv.clone(), + environment: AbEnvironment::detect(config.workspace_root.as_deref()), + settings: AbSettings::of(config), + baseline, + candidate, + corpora, + }) +} + +fn validate(config: &AbConfig) -> Result<()> { + if config.runs < 2 { + bail!( + "--runs must be at least 2: the first run of every cell is a discarded warm-up (got {})", + config.runs + ); + } + if config.threads.is_empty() { + bail!("--ab needs at least one thread count (--threads )"); + } + if config.threads.contains(&0) { + bail!("thread counts must be at least 1"); + } + let distinct: BTreeSet<_> = config.threads.iter().collect(); + if distinct.len() != config.threads.len() { + bail!("thread counts must be distinct: {:?}", config.threads); + } + if config.corpora.is_empty() { + bail!("--ab needs at least one corpus (--corpora )"); + } + let names: BTreeSet<_> = config.corpora.iter().map(|corpus| &corpus.name).collect(); + if names.len() != config.corpora.len() { + bail!("each corpus may be listed only once"); + } + if config.run_timeout.is_zero() { + bail!("the run timeout must be positive"); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Arm { + Baseline, + Candidate, +} + +impl Arm { + fn index(self) -> usize { + match self { + Self::Baseline => 0, + Self::Candidate => 1, + } + } + + fn name(self) -> &'static str { + match self { + Self::Baseline => "baseline", + Self::Candidate => "candidate", + } + } + + /// Even runs time the baseline first, odd runs the candidate first. + fn order(run: usize) -> [Self; 2] { + if run.is_multiple_of(2) { + [Self::Baseline, Self::Candidate] + } else { + [Self::Candidate, Self::Baseline] + } + } +} + +/// Names one timed run in progress lines and errors. +struct RunLabel<'a> { + arm: Arm, + corpus: &'a str, + threads: usize, + run: usize, + runs: usize, +} + +impl fmt::Display for RunLabel<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} run {}/{} on {} ({}={})", + self.arm.name(), + self.run + 1, + self.runs, + self.corpus, + THREAD_ENV, + self.threads + ) + } +} + +fn measure_corpus( + config: &AbConfig, + index: usize, + corpus: &AbCorpus, + binaries: [&BinaryInfo; 2], + scratch: &Path, +) -> Result { + let mut graphs: [Option; 2] = [None, None]; + let mut copied = CopyStats::default(); + let mut cells = Vec::with_capacity(config.threads.len()); + for &threads in &config.threads { + let mut series: [Vec; 2] = [Vec::new(), Vec::new()]; + for run in 0..config.runs { + for arm in Arm::order(run) { + let label = RunLabel { + arm, + corpus: &corpus.name, + threads, + run, + runs: config.runs, + }; + let run_dir = scratch.join(format!("c{index}-{}-t{threads}-r{run}", arm.name())); + let outcome = measure_run( + binaries[arm.index()], + corpus, + threads, + &run_dir, + config.run_timeout, + ) + .with_context(|| format!("{label} failed"))?; + fs::remove_dir_all(&run_dir) + .with_context(|| format!("removing {}", run_dir.display()))?; + check_same_graph(&mut graphs[arm.index()], &outcome.graph, &label)?; + copied = outcome.copied; + let wall_ms = outcome.wall.as_secs_f64() * 1000.0; + eprintln!( + " {label}: {wall_ms:.1} ms, peak RSS {}{}", + outcome + .peak_rss_kb + .map_or_else(|| "n/a".to_string(), |kb| format!("{kb} KiB")), + if run == 0 { " (warm-up)" } else { "" } + ); + series[arm.index()].push(RunSample { + run: run + 1, + warmup: run == 0, + wall_ms, + peak_rss_kb: outcome.peak_rss_kb, + canonical_hash: outcome.graph.hash.clone(), + }); + } + } + let [baseline_runs, candidate_runs] = series; + let rows = |arm: Arm| graphs[arm.index()].as_ref().map(|graph| graph.rows.total); + let baseline = ArmSeries::new(baseline_runs, rows(Arm::Baseline))?; + let candidate = ArmSeries::new(candidate_runs, rows(Arm::Candidate))?; + let ratio = Ratios::between( + &baseline, + &candidate, + rows(Arm::Baseline), + rows(Arm::Candidate), + ); + cells.push(Cell { + threads, + budget: BudgetVerdict::evaluate(&ratio, &BudgetLimits::PROJECT), + baseline, + candidate, + ratio, + }); + } + let [Some(baseline), Some(candidate)] = graphs else { + bail!("corpus {} produced no measured runs", corpus.name); + }; + let differing = differing_surfaces(&baseline, &candidate); + eprintln!( + " graph: {}", + if differing.is_empty() { + "identical".to_string() + } else { + format!("baseline and candidate differ in {}", differing.join(", ")) + } + ); + Ok(CorpusAb { + name: corpus.name.clone(), + commit: corpus.commit.clone(), + tag: corpus.tag.clone(), + source: corpus.source.display().to_string(), + copied_files: copied.files, + skipped_symlinks: copied.skipped_symlinks, + graph: GraphComparison { + identical: differing.is_empty(), + differing_surfaces: differing, + baseline, + candidate, + }, + cells, + }) +} + +fn check_same_graph( + first: &mut Option, + graph: &GraphFingerprint, + label: &RunLabel<'_>, +) -> Result<()> { + match first { + None => { + *first = Some(graph.clone()); + Ok(()) + } + Some(expected) if expected.hash == graph.hash => Ok(()), + Some(expected) => bail!( + "non-deterministic graph: {label} produced canonical hash {}, but the first {} run on \ + {} produced {} (differing surfaces: {}); every run of one binary must produce the \ + same canonical graph", + graph.hash, + label.arm.name(), + label.corpus, + expected.hash, + differing_surfaces(expected, graph).join(", ") + ), + } +} + +struct RunOutcome { + wall: Duration, + peak_rss_kb: Option, + graph: GraphFingerprint, + copied: CopyStats, +} + +fn measure_run( + binary: &BinaryInfo, + corpus: &AbCorpus, + threads: usize, + run_dir: &Path, + timeout: Duration, +) -> Result { + let project = run_dir.join("project"); + fs::create_dir_all(run_dir).with_context(|| format!("creating {}", run_dir.display()))?; + let copied = copy_corpus(&corpus.source, &project)?; + let stdout_path = run_dir.join("init.stdout"); + let stderr_path = run_dir.join("init.stderr"); + let stdout = File::create(&stdout_path) + .with_context(|| format!("creating {}", stdout_path.display()))?; + let stderr = File::create(&stderr_path) + .with_context(|| format!("creating {}", stderr_path.display()))?; + let mut command = Command::new(&binary.executable); + command + .arg("init") + .arg(&project) + .current_dir(run_dir) + .env(THREAD_ENV, threads.to_string()) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env_remove("CODEGRAPH_DIR") + .stdin(Stdio::null()) + .stdout(stdout) + .stderr(stderr); + let measured = spawn_measured(&mut command, timeout) + .with_context(|| format!("running {} init", binary.path))?; + match measured.exit { + Exit::Code(0) => {} + Exit::Code(code) => bail!( + "`{} init` exited with status {code}{}", + binary.path, + stderr_tail(&stderr_path) + ), + #[cfg(unix)] + Exit::Signal(signal) => bail!( + "`{} init` was killed by signal {signal}{}", + binary.path, + stderr_tail(&stderr_path) + ), + Exit::TimedOut => bail!( + "`{} init` exceeded the {} s run timeout and was killed{}", + binary.path, + timeout.as_secs(), + stderr_tail(&stderr_path) + ), + Exit::Unknown => bail!("`{} init` ended without an exit status", binary.path), + } + let db = project.join(DEFAULT_CURRENT_DIR).join(DATABASE_FILE); + if !db.is_file() { + bail!( + "`{} init` exited 0 but produced no database at {}", + binary.path, + db.display() + ); + } + let canonical = canonicalize_checked(&db) + .with_context(|| format!("the database {} cannot be canonicalized", db.display()))?; + Ok(RunOutcome { + wall: measured.wall, + peak_rss_kb: measured.peak_rss_kb, + graph: fingerprint(&canonical), + copied, + }) +} + +fn stderr_tail(path: &Path) -> String { + let Ok(text) = fs::read_to_string(path) else { + return String::new(); + }; + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return String::new(); + } + let start = lines.len().saturating_sub(STDERR_TAIL_LINES); + format!( + "\nstderr (last {} lines):\n{}", + lines.len() - start, + lines[start..].join("\n") + ) +} + +impl ArmSeries { + fn new(runs: Vec, rows_total: Option) -> Result { + let measured: Vec<&RunSample> = runs.iter().filter(|run| !run.warmup).collect(); + let walls: Vec = measured.iter().map(|run| run.wall_ms).collect(); + let wall_ms = Summary::of(&walls).context("a cell has no measured runs")?; + let rss: Vec = measured + .iter() + .filter_map(|run| run.peak_rss_kb) + .map(|kb| kb as f64) + .collect(); + let peak_rss_kb = (rss.len() == measured.len()) + .then(|| Summary::of(&rss)) + .flatten(); + let rows_per_second = rows_total + .filter(|_| wall_ms.median > 0.0) + .map(|rows| rows as f64 / (wall_ms.median / 1000.0)); + Ok(Self { + runs, + wall_ms, + peak_rss_kb, + rows_per_second, + }) + } +} + +impl Ratios { + fn between( + baseline: &ArmSeries, + candidate: &ArmSeries, + baseline_rows: Option, + candidate_rows: Option, + ) -> Self { + Self { + wall_ms: ratio( + Some(candidate.wall_ms.median), + Some(baseline.wall_ms.median), + ), + peak_rss_kb: ratio( + candidate.peak_rss_kb.map(|rss| rss.median), + baseline.peak_rss_kb.map(|rss| rss.median), + ), + canonical_rows: ratio( + candidate_rows.map(|rows| rows as f64), + baseline_rows.map(|rows| rows as f64), + ), + rows_per_second: ratio(candidate.rows_per_second, baseline.rows_per_second), + } + } +} + +fn ratio(numerator: Option, denominator: Option) -> Option { + let (numerator, denominator) = (numerator?, denominator?); + (denominator > 0.0 && numerator.is_finite() && denominator.is_finite()) + .then(|| numerator / denominator) +} + +impl BinaryInfo { + /// Hash and describe a binary; `version_out` receives its `--version` output. + fn inspect(path: &Path, version_out: &Path) -> Result { + let resolved = + fs::canonicalize(path).with_context(|| format!("cannot find {}", path.display()))?; + let metadata = + fs::metadata(&resolved).with_context(|| format!("reading {}", resolved.display()))?; + if !metadata.is_file() { + bail!("{} is not a file", resolved.display()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 == 0 { + bail!("{} is not executable", resolved.display()); + } + } + Ok(Self { + path: resolved.display().to_string(), + sha256: sha256_file(&resolved)?, + size_bytes: metadata.len(), + version: probe_version(&resolved, version_out), + executable: resolved, + }) + } +} + +/// First line of ` --version`, or `None` when it fails or hangs. +fn probe_version(binary: &Path, out_path: &Path) -> Option { + let out = File::create(out_path).ok()?; + let mut command = Command::new(binary); + command + .arg("--version") + .stdin(Stdio::null()) + .stdout(out) + .stderr(Stdio::null()); + let measured = spawn_measured(&mut command, VERSION_TIMEOUT).ok()?; + let text = fs::read_to_string(out_path).ok()?; + if measured.exit != Exit::Code(0) { + return None; + } + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Exit { + Code(i32), + #[cfg(unix)] + Signal(i32), + TimedOut, + Unknown, +} + +struct Measured { + exit: Exit, + wall: Duration, + peak_rss_kb: Option, +} + +/// Spawn `command`, wait for it under `timeout`, and measure it. The wall time +/// runs from just before the spawn until the poll that observes the exit. +fn spawn_measured(command: &mut Command, timeout: Duration) -> Result { + let started = Instant::now(); + let child = command.spawn().context("spawning the process")?; + wait_measured(child, started, timeout) +} + +#[cfg(unix)] +fn wait_measured(child: Child, started: Instant, timeout: Duration) -> Result { + let pid = libc::pid_t::try_from(child.id()).context("the child pid does not fit pid_t")?; + let mut status: libc::c_int = 0; + // SAFETY: `rusage` is a plain C struct of integers; all-zero is a valid value + // and `wait4` overwrites it when it reaps the child. + let mut usage: libc::rusage = unsafe { std::mem::zeroed() }; + let mut killed = false; + // The child's own high-water mark (see the module comment): read while + // it runs, as a reaped process has no memory figures left. + let mut polled_peak_kb: Option = None; + loop { + // SAFETY: `pid` is this process's own child, which only this loop ever + // reaps (the std `Child` handle is dropped without waiting), and both + // out-pointers are valid for writes for the duration of the call. + let reaped = unsafe { libc::wait4(pid, &mut status, libc::WNOHANG, &mut usage) }; + if reaped == pid { + break; + } + if reaped == -1 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error).context("waiting for the child process"); + } + if cfg!(target_os = "linux") + && let Some(kb) = read_proc_status_kb(child.id(), "VmHWM:") + { + polled_peak_kb = polled_peak_kb.max(Some(kb)); + } + if !killed && started.elapsed() > timeout { + // SAFETY: the child has not been reaped yet, so its pid still names + // it and cannot have been reused by another process. + unsafe { + libc::kill(pid, libc::SIGKILL); + } + killed = true; + } + thread::sleep(POLL_INTERVAL); + } + let wall = started.elapsed(); + drop(child); + let exit = if killed { + Exit::TimedOut + } else if libc::WIFEXITED(status) { + Exit::Code(libc::WEXITSTATUS(status)) + } else if libc::WIFSIGNALED(status) { + Exit::Signal(libc::WTERMSIG(status)) + } else { + Exit::Unknown + }; + let peak_rss_kb = if cfg!(target_os = "linux") { + polled_peak_kb + } else { + max_rss_kb(&usage) + }; + Ok(Measured { + exit, + wall, + peak_rss_kb, + }) +} + +/// `ru_maxrss` is KiB on Linux and the BSDs, bytes on macOS. +#[cfg(unix)] +fn max_rss_kb(usage: &libc::rusage) -> Option { + let raw = u64::try_from(usage.ru_maxrss).ok()?; + let kb = if cfg!(target_os = "macos") { + raw / 1024 + } else { + raw + }; + (kb > 0).then_some(kb) +} + +#[cfg(not(unix))] +fn wait_measured(mut child: Child, started: Instant, timeout: Duration) -> Result { + let mut killed = false; + let status = loop { + if let Some(status) = child.try_wait().context("waiting for the child process")? { + break status; + } + if !killed && started.elapsed() > timeout { + let _ = child.kill(); + killed = true; + } + thread::sleep(POLL_INTERVAL); + }; + let wall = started.elapsed(); + let exit = if killed { + Exit::TimedOut + } else { + status.code().map_or(Exit::Unknown, Exit::Code) + }; + Ok(Measured { + exit, + wall, + peak_rss_kb: None, + }) +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct CopyStats { + files: u64, + skipped_symlinks: u64, +} + +/// Copy a corpus subtree, leaving out VCS metadata and index directories and +/// skipping symlinks, so every run starts from the same plain source tree. +fn copy_corpus(source: &Path, destination: &Path) -> Result { + let mut stats = CopyStats::default(); + copy_dir(source, destination, &mut stats)?; + Ok(stats) +} + +fn copy_dir(source: &Path, destination: &Path, stats: &mut CopyStats) -> Result<()> { + fs::create_dir_all(destination) + .with_context(|| format!("creating {}", destination.display()))?; + for entry in fs::read_dir(source).with_context(|| format!("reading {}", source.display()))? { + let entry = entry.with_context(|| format!("reading {}", source.display()))?; + let name = entry.file_name(); + if is_excluded(&name) { + continue; + } + let file_type = entry.file_type()?; + let from = entry.path(); + let to = destination.join(&name); + if file_type.is_symlink() { + stats.skipped_symlinks += 1; + } else if file_type.is_dir() { + copy_dir(&from, &to, stats)?; + } else if file_type.is_file() { + fs::copy(&from, &to) + .with_context(|| format!("copying {} to {}", from.display(), to.display()))?; + stats.files += 1; + } + } + Ok(()) +} + +fn is_excluded(name: &OsStr) -> bool { + name == ".git" || name == DEFAULT_CURRENT_DIR || name == WSL_CURRENT_DIR +} + +fn git_stdout(dir: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("--no-optional-locks") + .arg("-C") + .arg(dir) + .args(args) + .stdin(Stdio::null()) + .output() + .context("running git")?; + if !output.status.success() { + bail!( + "git {} failed in {}: {}", + args.join(" "), + dir.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +impl AbEnvironment { + fn detect(workspace_root: Option<&Path>) -> Self { + let harness_commit = + workspace_root.and_then(|root| git_stdout(root, &["rev-parse", "HEAD"]).ok()); + let harness_dirty = workspace_root.and_then(|root| { + git_stdout(root, &["status", "--porcelain"]) + .ok() + .map(|status| !status.is_empty()) + }); + Self { + cpu_model: read_cpu_model(), + logical_cpus: thread::available_parallelism().ok().map(usize::from), + memory_total_kb: read_mem_total_kb(), + os: read_os_pretty_name(), + kernel: command_stdout("uname", &["-sr"]), + rustc_version: command_stdout("rustc", &["--version"]), + harness_commit, + harness_dirty, + rss_collector: rss_collector().to_string(), + } + } +} + +fn rss_collector() -> &'static str { + if cfg!(target_os = "linux") { + "VmHWM of /proc//status, polled every millisecond (KiB)" + } else if cfg!(target_os = "macos") { + "wait4 rusage ru_maxrss (bytes, reported in KiB)" + } else if cfg!(unix) { + "wait4 rusage ru_maxrss (KiB)" + } else { + "unavailable" + } +} + +impl AbSettings { + fn of(config: &AbConfig) -> Self { + let env_set = [ + ("CODEGRAPH_NO_DAEMON", "1"), + ("CODEGRAPH_NO_WATCH", "1"), + (THREAD_ENV, ""), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + Self { + invocation: " init ".to_string(), + env_set, + env_removed: vec!["CODEGRAPH_DIR".to_string()], + thread_env: THREAD_ENV.to_string(), + threads: config.threads.clone(), + runs: config.runs, + discarded_warmup_runs: 1, + order: "interleaved: even runs baseline first, odd runs candidate first".to_string(), + run_timeout_secs: config.run_timeout.as_secs(), + budget: BudgetLimits::PROJECT, + } + } +} + +/// Write the JSON report, creating parent directories. +pub fn write_ab_report(path: &Path, report: &AbReport) -> Result<()> { + write_text( + path, + &serde_json::to_string_pretty(report).context("serializing the A/B report")?, + ) +} + +/// Write the Markdown table rendered by [`render_ab_markdown`]. +pub fn write_ab_markdown(path: &Path, report: &AbReport) -> Result<()> { + write_text(path, &render_ab_markdown(report)) +} + +fn write_text(path: &Path, text: &str) -> Result<()> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + fs::write(path, format!("{}\n", text.trim_end())) + .with_context(|| format!("writing {}", path.display())) +} + +/// Render the report as Markdown: a provenance table, then one row per corpus +/// and thread count. +pub fn render_ab_markdown(report: &AbReport) -> String { + let env = &report.environment; + let settings = &report.settings; + let threads: Vec = settings.threads.iter().map(usize::to_string).collect(); + let budget = settings.budget; + let mut out = String::from("# CodeGraph A/B comparison\n\n| Field | Value |\n|---|---|\n"); + let mut field = |name: &str, value: String| { + out.push_str(&format!("| {name} | {} |\n", value.replace('|', "\\|"))); + }; + for (name, binary) in [ + ("Baseline", &report.baseline), + ("Candidate", &report.candidate), + ] { + field( + name, + format!( + "`{}` ({})", + binary.path, + binary.version.as_deref().unwrap_or("version unknown") + ), + ); + field(&format!("{name} sha256"), format!("`{}`", binary.sha256)); + } + field( + "Runs per cell", + format!( + "{} ({} warm-up discarded; {})", + settings.runs, settings.discarded_warmup_runs, settings.order + ), + ); + field( + &format!("Thread counts (`{}`)", settings.thread_env), + threads.join(", "), + ); + field( + "CPU", + format!( + "{} ({} logical)", + env.cpu_model.as_deref().unwrap_or("unknown"), + env.logical_cpus + .map_or_else(|| "?".to_string(), |cpus| cpus.to_string()) + ), + ); + field( + "Kernel", + env.kernel.clone().unwrap_or_else(|| "unknown".into()), + ); + field( + "rustc", + env.rustc_version + .clone() + .unwrap_or_else(|| "unknown".into()), + ); + field( + "Harness commit", + match (&env.harness_commit, env.harness_dirty) { + (Some(commit), Some(true)) => format!("`{commit}` (dirty)"), + (Some(commit), _) => format!("`{commit}`"), + (None, _) => "unknown".to_string(), + }, + ); + field( + "Budget", + format!( + "init wall ≤ {:.2}× (or rows grew and rows/s ≥ {:.2}×); peak RSS ≤ {:.2}×", + budget.init_wall_ratio_max, budget.rows_per_second_ratio_min, budget.peak_rss_ratio_max + ), + ); + + out.push_str( + "\n| Corpus | Threads | Baseline ms (MAD) | Candidate ms (MAD) | Wall × | \ + Baseline RSS MiB | Candidate RSS MiB | RSS × | Rows × | Rows/s × | Graph | Budget |\n\ + |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---|\n", + ); + for corpus in &report.corpora { + let graph = if corpus.graph.identical { + "identical".to_string() + } else { + format!("differs: {}", corpus.graph.differing_surfaces.join(", ")) + }; + for cell in &corpus.cells { + out.push_str(&format!( + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {graph} | {} |\n", + corpus.name, + cell.threads, + summary_ms(&cell.baseline.wall_ms), + summary_ms(&cell.candidate.wall_ms), + ratio_text(cell.ratio.wall_ms), + rss_mib(cell.baseline.peak_rss_kb), + rss_mib(cell.candidate.peak_rss_kb), + ratio_text(cell.ratio.peak_rss_kb), + ratio_text(cell.ratio.canonical_rows), + ratio_text(cell.ratio.rows_per_second), + budget_text(&cell.budget), + )); + } + } + out +} + +fn summary_ms(summary: &Summary) -> String { + format!("{:.1} ({:.1})", summary.median, summary.mad) +} + +fn rss_mib(summary: Option) -> String { + summary.map_or_else( + || "n/a".to_string(), + |rss| format!("{:.1}", rss.median / 1024.0), + ) +} + +fn ratio_text(ratio: Option) -> String { + ratio.map_or_else(|| "n/a".to_string(), |value| format!("{value:.3}")) +} + +fn budget_text(verdict: &BudgetVerdict) -> String { + let mut over = Vec::new(); + if verdict.init_wall_ok == Some(false) { + over.push("init wall over"); + } + if verdict.peak_rss_ok == Some(false) { + over.push("peak RSS over"); + } + if !over.is_empty() { + return over.join(", "); + } + match (verdict.init_wall_ok, verdict.peak_rss_ok) { + (Some(true), Some(true)) => "ok".to_string(), + (Some(true), None) => "ok (no RSS)".to_string(), + _ => "n/a".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A run's peak RSS is the child's own, not the address space it was + /// forked from: with the harness holding 256 MiB, a `sleep` still + /// reports a few MiB (W4d end: `ru_maxrss` read every run after the + /// typescript-large corpus as the harness's 414 MiB). + #[cfg(target_os = "linux")] + #[test] + fn a_runs_peak_rss_is_the_childs_own_not_the_harnesss() { + let held = vec![1u8; 256 << 20]; + let touched: u64 = held.iter().step_by(4096).map(|&b| u64::from(b)).sum(); + assert!(touched > 0); + let mut command = Command::new("sleep"); + command.arg("0.2"); + let measured = spawn_measured(&mut command, Duration::from_secs(30)).expect("sleep runs"); + assert!(matches!(measured.exit, Exit::Code(0))); + let peak = measured.peak_rss_kb.expect("the child's VmHWM is read"); + assert!(peak < 64 << 10, "a sleep's peak RSS is {peak} KiB"); + drop(held); + } + + fn ratios(wall: f64, rows: f64, rate: f64, rss: Option) -> Ratios { + Ratios { + wall_ms: Some(wall), + peak_rss_kb: rss, + canonical_rows: Some(rows), + rows_per_second: Some(rate), + } + } + + #[test] + fn budget_allows_slower_init_only_when_rows_grew_and_throughput_held() { + let limits = BudgetLimits::PROJECT; + let verdict = |r: Ratios| BudgetVerdict::evaluate(&r, &limits); + assert_eq!( + verdict(ratios(1.10, 1.0, 0.91, Some(1.15))).init_wall_ok, + Some(true) + ); + assert_eq!( + verdict(ratios(1.10, 1.0, 0.91, Some(1.15))).peak_rss_ok, + Some(true) + ); + assert_eq!( + verdict(ratios(1.11, 1.0, 0.95, None)).init_wall_ok, + Some(false) + ); + assert_eq!( + verdict(ratios(1.30, 1.25, 0.96, None)).init_wall_ok, + Some(true) + ); + assert_eq!( + verdict(ratios(1.30, 1.10, 0.85, None)).init_wall_ok, + Some(false) + ); + assert_eq!( + verdict(ratios(1.0, 1.0, 1.0, Some(1.16))).peak_rss_ok, + Some(false) + ); + assert_eq!(verdict(ratios(1.0, 1.0, 1.0, None)).peak_rss_ok, None); + } + + #[test] + fn warm_up_runs_are_left_out_of_the_statistics() { + let sample = |run: usize, wall_ms: f64, rss: u64| RunSample { + run, + warmup: run == 1, + wall_ms, + peak_rss_kb: Some(rss), + canonical_hash: "h".to_string(), + }; + let series = ArmSeries::new( + vec![ + sample(1, 1000.0, 9000), + sample(2, 10.0, 100), + sample(3, 14.0, 300), + sample(4, 12.0, 200), + ], + Some(120), + ) + .unwrap(); + assert_eq!( + series.wall_ms, + Summary { + median: 12.0, + mad: 2.0, + samples: 3 + } + ); + assert_eq!(series.peak_rss_kb.map(|rss| rss.median), Some(200.0)); + assert_eq!(series.rows_per_second, Some(10_000.0)); + } + + #[test] + fn ratios_are_absent_rather_than_infinite() { + assert_eq!(ratio(Some(2.0), Some(0.0)), None); + assert_eq!(ratio(None, Some(1.0)), None); + assert_eq!(ratio(Some(3.0), Some(2.0)), Some(1.5)); + } + + #[test] + fn runs_alternate_which_binary_goes_first() { + assert_eq!(Arm::order(0), [Arm::Baseline, Arm::Candidate]); + assert_eq!(Arm::order(1), [Arm::Candidate, Arm::Baseline]); + assert_eq!(Arm::order(4), [Arm::Baseline, Arm::Candidate]); + } + + #[test] + fn copies_leave_out_vcs_and_index_directories() { + let scratch = ScratchDir::new("copy-test").unwrap(); + let source = scratch.path().join("source"); + for dir in [ + ".git", + ".codegraph", + ".codegraph-wsl", + "src/.git", + "src/nested", + ] { + fs::create_dir_all(source.join(dir)).unwrap(); + fs::write(source.join(dir).join("marker"), b"x").unwrap(); + } + fs::write(source.join("src/lib.rs"), b"fn a() {}\n").unwrap(); + let destination = scratch.path().join("copy"); + let stats = copy_corpus(&source, &destination).unwrap(); + assert_eq!(stats.files, 2); + assert!(destination.join("src/lib.rs").is_file()); + assert!(destination.join("src/nested/marker").is_file()); + for dir in [".git", ".codegraph", ".codegraph-wsl", "src/.git"] { + assert!(!destination.join(dir).exists(), "{dir} must not be copied"); + } + } +} diff --git a/crates/codegraph-bench/src/corpus.rs b/crates/codegraph-bench/src/corpus.rs index c518803b..88c5b4f8 100644 --- a/crates/codegraph-bench/src/corpus.rs +++ b/crates/codegraph-bench/src/corpus.rs @@ -12,6 +12,9 @@ const CORPORA_DIR: &str = "bench/corpora"; pub struct Corpus { pub name: &'static str, pub url: &'static str, + /// Release tag the commit was taken from; `None` for a commit pinned + /// without one. + pub tag: Option<&'static str>, pub commit: &'static str, pub subdir: Option<&'static str>, pub expected_loc: u64, @@ -22,6 +25,7 @@ pub struct Corpus { pub struct CorpusStatus { pub name: String, pub url: String, + pub tag: Option, pub commit: String, pub subdir: Option, pub expected_loc: u64, @@ -43,6 +47,7 @@ pub const CORPORA: &[Corpus] = &[ Corpus { name: "fd-small", url: "https://github.com/sharkdp/fd.git", + tag: None, commit: "25461e5ce13dc12ff2a75993285a87e99b33db2d", subdir: Some("src"), expected_loc: 4254, @@ -51,6 +56,7 @@ pub const CORPORA: &[Corpus] = &[ Corpus { name: "tokio-medium", url: "https://github.com/tokio-rs/tokio.git", + tag: None, commit: "ecb5125a6787b9d8eb818b1b00973bcd55ae77c0", subdir: Some("tokio/src"), expected_loc: 93339, @@ -59,46 +65,146 @@ pub const CORPORA: &[Corpus] = &[ Corpus { name: "typescript-large", url: "https://github.com/microsoft/TypeScript.git", + tag: None, commit: "7964e22f2b85f16e520f0e902c7fd7b6f0c15416", subdir: Some("src"), expected_loc: 424376, expected_files: 730, }, + // A/B timing corpus (JavaScript). + Corpus { + name: "express-medium", + url: "https://github.com/expressjs/express.git", + tag: None, + commit: "dae209ae6559c29cfca2a1f4414c51d89ea643d5", + subdir: None, + expected_loc: 21463, + expected_files: 164, + }, + // Precision corpora (C++, Java, C#, Swift): resolution changes are sampled + // on these. + Corpus { + name: "leveldb-small", + url: "https://github.com/google/leveldb.git", + tag: Some("1.23"), + commit: "99b3c03b3284f5886f9ef9a4ef703d57373e61be", + subdir: None, + expected_loc: 25952, + expected_files: 142, + }, + Corpus { + name: "gson-medium", + url: "https://github.com/google/gson.git", + tag: Some("gson-parent-2.14.0"), + commit: "3ff35d6269894901ab8006258395aafc4b9765cd", + subdir: Some("gson"), + expected_loc: 43162, + expected_files: 211, + }, + Corpus { + name: "newtonsoft-large", + url: "https://github.com/JamesNK/Newtonsoft.Json.git", + tag: Some("13.0.4"), + commit: "4e13299d4b0ec96bd4df9954ef646bd2d1b5bf2a", + subdir: Some("Src"), + expected_loc: 393357, + expected_files: 968, + }, + Corpus { + name: "alamofire-small", + url: "https://github.com/Alamofire/Alamofire.git", + tag: Some("5.12.2"), + commit: "bda9ed57d72988a3a2ada33d824583541f86eac6", + subdir: Some("Source"), + expected_loc: 14833, + expected_files: 44, + }, + // Performance corpora: C with dense function-pointer dispatch, and Lua. + Corpus { + name: "redis-medium", + url: "https://github.com/redis/redis.git", + tag: Some("8.10.2"), + commit: "498ecd0d6d007db11ddb3aea9428552598a78622", + subdir: Some("src"), + expected_loc: 215885, + expected_files: 679, + }, + Corpus { + name: "kong-medium", + url: "https://github.com/Kong/kong.git", + tag: Some("3.9.3"), + commit: "a643428bc4d5397152164a63bcc0f8bc65fce69d", + subdir: Some("kong"), + expected_loc: 93929, + expected_files: 592, + }, ]; +/// Find a registry corpus by name. +pub fn find_corpus(name: &str) -> Option { + CORPORA.iter().copied().find(|corpus| corpus.name == name) +} + pub fn corpora_root(workspace_root: &Path) -> PathBuf { workspace_root.join(CORPORA_DIR) } -pub fn corpus_checkout_path(workspace_root: &Path, corpus: Corpus) -> PathBuf { - corpora_root(workspace_root).join(corpus.name) +/// The checkout of `corpus` under a corpora root. +pub fn checkout_path_in(corpora_root: &Path, corpus: Corpus) -> PathBuf { + corpora_root.join(corpus.name) } -pub fn corpus_benchmark_path(workspace_root: &Path, corpus: Corpus) -> PathBuf { +/// The subtree of `corpus` that is benchmarked, under a corpora root. +pub fn benchmark_path_in(corpora_root: &Path, corpus: Corpus) -> PathBuf { + let checkout = checkout_path_in(corpora_root, corpus); match corpus.subdir { - Some(subdir) => corpus_checkout_path(workspace_root, corpus).join(subdir), - None => corpus_checkout_path(workspace_root, corpus), + Some(subdir) => checkout.join(subdir), + None => checkout, } } +pub fn corpus_checkout_path(workspace_root: &Path, corpus: Corpus) -> PathBuf { + checkout_path_in(&corpora_root(workspace_root), corpus) +} + +pub fn corpus_benchmark_path(workspace_root: &Path, corpus: Corpus) -> PathBuf { + benchmark_path_in(&corpora_root(workspace_root), corpus) +} + pub fn list_statuses(workspace_root: &Path) -> Vec { + list_statuses_in(&corpora_root(workspace_root)) +} + +/// Registry rows with the live state of each checkout under `corpora_root`. +pub fn list_statuses_in(corpora_root: &Path) -> Vec { CORPORA .iter() .copied() - .map(|corpus| status(workspace_root, corpus)) + .map(|corpus| status(corpora_root, corpus)) .collect() } pub fn fetch_all(workspace_root: &Path) -> Result> { - fs::create_dir_all(corpora_root(workspace_root)).with_context(|| "creating bench/corpora")?; + fetch_all_in(&corpora_root(workspace_root)) +} + +/// Fetch every registry corpus missing under `corpora_root`; an existing +/// checkout is only verified. +pub fn fetch_all_in(corpora_root: &Path) -> Result> { + fs::create_dir_all(corpora_root) + .with_context(|| format!("creating {}", corpora_root.display()))?; for corpus in CORPORA.iter().copied() { - ensure_corpus(workspace_root, corpus)?; + ensure_corpus_in(corpora_root, corpus)?; } - Ok(list_statuses(workspace_root)) + Ok(list_statuses_in(corpora_root)) } pub fn ensure_corpus(workspace_root: &Path, corpus: Corpus) -> Result<()> { - let path = corpus_checkout_path(workspace_root, corpus); + ensure_corpus_in(&corpora_root(workspace_root), corpus) +} + +pub fn ensure_corpus_in(corpora_root: &Path, corpus: Corpus) -> Result<()> { + let path = checkout_path_in(corpora_root, corpus); if path.exists() { let actual = git_rev_parse(&path).ok(); if actual.as_deref() == Some(corpus.commit) { @@ -118,20 +224,21 @@ pub fn ensure_corpus(workspace_root: &Path, corpus: Corpus) -> Result<()> { .with_context(|| format!("removing stale {}", tmp_path.display()))?; } - fs::create_dir_all(corpora_root(workspace_root)).with_context(|| "creating corpora root")?; + fs::create_dir_all(corpora_root) + .with_context(|| format!("creating {}", corpora_root.display()))?; run_git( Command::new("git") .arg("init") .arg("--quiet") .arg(&tmp_path), - workspace_root, + corpora_root, )?; run_git( Command::new("git") .arg("-C") .arg(&tmp_path) .args(["remote", "add", "origin", corpus.url]), - workspace_root, + corpora_root, )?; run_git( Command::new("git").arg("-C").arg(&tmp_path).args([ @@ -141,14 +248,14 @@ pub fn ensure_corpus(workspace_root: &Path, corpus: Corpus) -> Result<()> { "origin", corpus.commit, ]), - workspace_root, + corpora_root, )?; run_git( Command::new("git") .arg("-C") .arg(&tmp_path) .args(["checkout", "--quiet", "FETCH_HEAD"]), - workspace_root, + corpora_root, )?; let actual = git_rev_parse(&tmp_path)?; @@ -176,19 +283,19 @@ pub fn count_tree(root: &Path) -> Result { Ok(counts) } -fn status(workspace_root: &Path, corpus: Corpus) -> CorpusStatus { - let checkout_path = corpus_checkout_path(workspace_root, corpus); +fn status(corpora_root: &Path, corpus: Corpus) -> CorpusStatus { + let checkout_path = checkout_path_in(corpora_root, corpus); + let benchmark_path = benchmark_path_in(corpora_root, corpus); let fetched = checkout_path.exists(); let actual_commit = fetched .then(|| git_rev_parse(&checkout_path).ok()) .flatten(); - let counts = fetched - .then(|| count_corpus(workspace_root, corpus).ok()) - .flatten(); + let counts = fetched.then(|| count_tree(&benchmark_path).ok()).flatten(); CorpusStatus { name: corpus.name.to_string(), url: corpus.url.to_string(), + tag: corpus.tag.map(str::to_string), commit: corpus.commit.to_string(), subdir: corpus.subdir.map(str::to_string), expected_loc: corpus.expected_loc, @@ -197,9 +304,7 @@ fn status(workspace_root: &Path, corpus: Corpus) -> CorpusStatus { actual_commit, actual_loc: counts.map(|c| c.loc), actual_files: counts.map(|c| c.files), - path: corpus_benchmark_path(workspace_root, corpus) - .display() - .to_string(), + path: benchmark_path.display().to_string(), } } @@ -300,3 +405,98 @@ fn run_git(command: &mut Command, cwd: &Path) -> Result<()> { String::from_utf8_lossy(&output.stderr) )) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + #[test] + fn registry_names_are_unique_and_pins_are_full_shas() { + let names: BTreeSet<_> = CORPORA.iter().map(|corpus| corpus.name).collect(); + assert_eq!(names.len(), CORPORA.len(), "corpus names must be unique"); + for corpus in CORPORA { + assert!( + corpus.commit.len() == 40 + && corpus + .commit + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "{} must pin a full lowercase commit SHA, got {}", + corpus.name, + corpus.commit + ); + assert!( + corpus.url.starts_with("https://github.com/") && corpus.url.ends_with(".git"), + "{} has an unexpected URL {}", + corpus.name, + corpus.url + ); + } + } + + #[test] + fn ab_and_precision_corpora_are_registered() { + for name in [ + "express-medium", + "leveldb-small", + "gson-medium", + "newtonsoft-large", + "alamofire-small", + "redis-medium", + "kong-medium", + ] { + assert!( + find_corpus(name).is_some(), + "{name} is missing from CORPORA" + ); + } + assert!(find_corpus("does-not-exist").is_none()); + } + + #[test] + fn every_registry_pin_is_listed_in_the_benchmark_doc() { + let doc_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/benchmark.md"); + let doc = fs::read_to_string(&doc_path).expect("docs/benchmark.md is readable"); + for corpus in CORPORA { + let row = doc + .lines() + .find(|line| line.starts_with(&format!("| `{}` ", corpus.name))) + .unwrap_or_else(|| panic!("docs/benchmark.md has no row for {}", corpus.name)); + assert!( + row.contains(&format!("`{}`", corpus.commit)), + "the {} row must carry its pinned commit: {row}", + corpus.name + ); + assert!( + row.contains(&format!("`{}`", corpus.subdir.unwrap_or("."))), + "the {} row must carry its benchmark directory: {row}", + corpus.name + ); + if let Some(tag) = corpus.tag { + assert!( + row.contains(&format!("`{tag}`")), + "the {} row must carry its tag: {row}", + corpus.name + ); + } + } + } + + #[test] + fn benchmark_paths_join_the_subdirectory() { + let root = Path::new("/corpora"); + let fd = find_corpus("fd-small").unwrap(); + assert_eq!(checkout_path_in(root, fd), root.join("fd-small")); + assert_eq!( + benchmark_path_in(root, fd), + root.join("fd-small").join("src") + ); + let express = find_corpus("express-medium").unwrap(); + assert_eq!( + benchmark_path_in(root, express), + root.join("express-medium") + ); + } +} diff --git a/crates/codegraph-bench/src/graph_diff.rs b/crates/codegraph-bench/src/graph_diff.rs new file mode 100644 index 00000000..4507dc6b --- /dev/null +++ b/crates/codegraph-bench/src/graph_diff.rs @@ -0,0 +1,778 @@ +//! `bench --graph-diff`: compare two canonical graphs and print what changed. +//! +//! Either input is a CodeGraph SQLite database or a golden directory +//! (`nodes.json`, `edges.json`, `refs.json`, `files.json`, `schema.sql`). Both +//! go through the oracle canonicalization, so a database and the golden written +//! from it compare identical. +//! +//! A database is copied, together with its `-wal` when one exists, into a +//! private temporary directory and opened only there. SQLite therefore never +//! creates `-wal`/`-shm` sidecars beside the input, which would make the +//! CodeGraph CLI refuse a published index. The copy must pass +//! `PRAGMA quick_check` before it is read, so a corrupted file stops the +//! comparison instead of yielding a partial graph. +//! +//! Rows are compared as multisets of canonical JSON. A row only on the left is +//! removed and a row only on the right is added, so a node or file whose +//! attributes changed shows as one removed and one added row with the same key. +//! Changes are grouped by (surface, language, kind, resolvedBy) and every list +//! is sorted, so identical inputs always produce byte-identical output. + +use std::collections::{BTreeMap, HashMap}; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::oracle::{CanonicalDb, CanonicalRow, canonicalize_db, load_golden}; + +/// Placeholder for a group column a surface does not have. +const NONE: &str = "-"; +/// Language of an edge whose source node is in neither graph. +const UNKNOWN_LANGUAGE: &str = "?"; + +/// The surfaces a [`GraphFingerprint`] hashes, in the order the combined hash +/// reads them. +pub const FINGERPRINT_SURFACES: [&str; 5] = + ["nodes", "edges", "unresolved_refs", "files", "schema"]; + +/// The row surfaces of a canonical graph, in report order. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Surface { + Nodes, + Edges, + UnresolvedRefs, + Files, +} + +impl Surface { + pub const ALL: [Self; 4] = [Self::Nodes, Self::Edges, Self::UnresolvedRefs, Self::Files]; + + pub fn name(self) -> &'static str { + match self { + Self::Nodes => "nodes", + Self::Edges => "edges", + Self::UnresolvedRefs => "unresolved_refs", + Self::Files => "files", + } + } + + fn rows(self, graph: &CanonicalDb) -> &[CanonicalRow] { + match self { + Self::Nodes => &graph.nodes, + Self::Edges => &graph.edges, + Self::UnresolvedRefs => &graph.unresolved_refs, + Self::Files => &graph.files, + } + } +} + +/// What kind of input a graph was loaded from. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GraphSource { + Database, + Golden, +} + +impl GraphSource { + pub fn name(self) -> &'static str { + match self { + Self::Database => "database", + Self::Golden => "golden", + } + } +} + +/// Load a canonical graph from a golden directory or a SQLite database file. +pub fn load_graph(path: &Path) -> Result<(GraphSource, CanonicalDb)> { + let metadata = fs::metadata(path).with_context(|| format!("cannot read {}", path.display()))?; + if metadata.is_dir() { + let graph = load_golden(path) + .with_context(|| format!("cannot load the golden directory {}", path.display()))?; + return Ok((GraphSource::Golden, graph)); + } + if !metadata.is_file() { + bail!( + "{} is neither a database file nor a golden directory", + path.display() + ); + } + let scratch = ScratchDir::new("graph-diff")?; + let copy = scratch.path().join("graph.db"); + fs::copy(path, ©) + .with_context(|| format!("copying {} to {}", path.display(), copy.display()))?; + let wal = with_suffix(path, "-wal"); + if wal.is_file() { + let wal_copy = with_suffix(©, "-wal"); + fs::copy(&wal, &wal_copy) + .with_context(|| format!("copying {} to {}", wal.display(), wal_copy.display()))?; + } + let graph = canonicalize_checked(©) + .with_context(|| format!("cannot canonicalize the database {}", path.display()))?; + Ok((GraphSource::Database, graph)) +} + +/// Canonicalize the database at `db` after it passes `PRAGMA quick_check`. +/// +/// SQLite may write `-wal`/`-shm` sidecars next to `db`, so callers pass a file +/// they own: a scratch copy, or the index of a scratch project. +pub fn canonicalize_checked(db: &Path) -> Result { + if !db.is_file() { + bail!("{} is not a database file", db.display()); + } + { + let conn = Connection::open(db).with_context(|| format!("opening {}", db.display()))?; + let mut statement = conn + .prepare("PRAGMA quick_check") + .with_context(|| format!("running PRAGMA quick_check on {}", db.display()))?; + let verdict = statement + .query_map([], |row| row.get::<_, String>(0)) + .and_then(|rows| rows.collect::>>()) + .with_context(|| format!("running PRAGMA quick_check on {}", db.display()))?; + if verdict != ["ok"] { + bail!( + "PRAGMA quick_check failed for {}: {}", + db.display(), + verdict.join("; ") + ); + } + } + canonicalize_db(db) +} + +/// Rows per surface of a canonical graph. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct RowCounts { + pub nodes: u64, + pub edges: u64, + pub unresolved_refs: u64, + pub files: u64, + pub total: u64, +} + +impl RowCounts { + pub fn of(graph: &CanonicalDb) -> Self { + let count = |rows: &[CanonicalRow]| rows.len() as u64; + let nodes = count(&graph.nodes); + let edges = count(&graph.edges); + let unresolved_refs = count(&graph.unresolved_refs); + let files = count(&graph.files); + Self { + nodes, + edges, + unresolved_refs, + files, + total: nodes + edges + unresolved_refs + files, + } + } +} + +/// SHA-256 identity of a canonical graph. +/// +/// Each row surface hashes the compact JSON array of its canonical rows; +/// `schema` hashes the normalized schema text. `hash` is the SHA-256 of the +/// lines `" \n"` in [`FINGERPRINT_SURFACES`] order. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GraphFingerprint { + pub hash: String, + pub surfaces: BTreeMap, + pub rows: RowCounts, +} + +pub fn fingerprint(graph: &CanonicalDb) -> GraphFingerprint { + let mut surfaces = BTreeMap::new(); + for surface in Surface::ALL { + surfaces.insert(surface.name().to_string(), hash_rows(surface.rows(graph))); + } + surfaces.insert( + "schema".to_string(), + hex(&Sha256::digest(graph.schema.as_bytes())), + ); + let mut combined = Sha256::new(); + for name in FINGERPRINT_SURFACES { + combined.update(format!("{name} {}\n", surfaces[name]).as_bytes()); + } + GraphFingerprint { + hash: hex(&combined.finalize()), + surfaces, + rows: RowCounts::of(graph), + } +} + +/// The [`FINGERPRINT_SURFACES`] whose hashes differ between two fingerprints. +pub fn differing_surfaces(left: &GraphFingerprint, right: &GraphFingerprint) -> Vec { + FINGERPRINT_SURFACES + .iter() + .filter(|name| left.surfaces.get(**name) != right.surfaces.get(**name)) + .map(|name| (*name).to_string()) + .collect() +} + +/// Lowercase hex SHA-256 of a file's bytes. +pub fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; 1 << 20]; + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("reading {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex(&hasher.finalize())) +} + +/// One (surface, language, kind, resolvedBy) group of changed rows. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct GroupKey { + pub surface: Surface, + pub language: String, + pub kind: String, + pub resolved_by: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GroupDiff { + pub key: GroupKey, + /// Canonical JSON of rows only in the left graph, sorted. + pub removed: Vec, + /// Canonical JSON of rows only in the right graph, sorted. + pub added: Vec, +} + +/// Schema lines that are only on one side. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SchemaDiff { + pub removed: Vec, + pub added: Vec, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GraphDiff { + /// Changed groups in (surface, language, kind, resolvedBy) order. + pub groups: Vec, + /// `Some` when the normalized schemas differ. + pub schema: Option, +} + +impl GraphDiff { + pub fn is_identical(&self) -> bool { + self.groups.is_empty() && self.schema.is_none() + } + + /// (removed, added) row counts of one surface. + pub fn totals(&self, surface: Surface) -> (usize, usize) { + self.groups + .iter() + .filter(|group| group.key.surface == surface) + .fold((0, 0), |(removed, added), group| { + (removed + group.removed.len(), added + group.added.len()) + }) + } +} + +/// Compare two canonical graphs row by row. +/// +/// The group of a node is its own language and kind, of a reference its +/// language and `reference_kind`, and of a file its language. An edge takes the +/// language of its source node (looked up on its own side first, then on the +/// other) and its `metadata.resolvedBy`; a synthesized edge without one shows +/// `synthesizedBy:`. +pub fn diff_graphs(left: &CanonicalDb, right: &CanonicalDb) -> GraphDiff { + let left_languages = node_languages(left); + let right_languages = node_languages(right); + let mut groups: BTreeMap, Vec)> = BTreeMap::new(); + for surface in Surface::ALL { + let left_rows = counted_rows(surface.rows(left)); + let right_rows = counted_rows(surface.rows(right)); + for (json, row) in surplus_rows(&left_rows, &right_rows) { + let key = group_key(surface, row, &left_languages, &right_languages); + groups.entry(key).or_default().0.push(json); + } + for (json, row) in surplus_rows(&right_rows, &left_rows) { + let key = group_key(surface, row, &right_languages, &left_languages); + groups.entry(key).or_default().1.push(json); + } + } + let groups = groups + .into_iter() + .map(|(key, (mut removed, mut added))| { + removed.sort(); + added.sort(); + GroupDiff { + key, + removed, + added, + } + }) + .collect(); + let schema = (left.schema != right.schema).then(|| SchemaDiff { + removed: unmatched_lines(&left.schema, &right.schema), + added: unmatched_lines(&right.schema, &left.schema), + }); + GraphDiff { groups, schema } +} + +/// Load both inputs, write the report to `out`, and return whether the graphs +/// are identical. Nothing is written when an input cannot be loaded. +pub fn run_graph_diff(left: &Path, right: &Path, out: &mut dyn Write) -> Result { + let (left_source, left_graph) = load_graph(left)?; + let (right_source, right_graph) = load_graph(right)?; + let diff = diff_graphs(&left_graph, &right_graph); + let report = render_diff( + &format!("{} ({})", left.display(), left_source.name()), + &format!("{} ({})", right.display(), right_source.name()), + &diff, + ); + out.write_all(report.as_bytes()) + .context("writing the graph-diff report")?; + out.flush().context("writing the graph-diff report")?; + Ok(diff.is_identical()) +} + +/// Render a [`GraphDiff`] as the plain-text `bench --graph-diff` report. +pub fn render_diff(left: &str, right: &str, diff: &GraphDiff) -> String { + let mut out = format!("left: {left}\nright: {right}\n\n"); + out.push_str("surface removed added\n"); + let mut removed_total = 0; + let mut added_total = 0; + for surface in Surface::ALL { + let (removed, added) = diff.totals(surface); + removed_total += removed; + added_total += added; + out.push_str(&format!("{:<15} {removed:>8} {added:>8}\n", surface.name())); + } + let schema_state = if diff.schema.is_some() { + "differs" + } else { + "identical" + }; + out.push_str(&format!("schema {schema_state}\n")); + + if !diff.groups.is_empty() { + out.push_str("\ngroups:\n"); + out.push_str(&group_table(&diff.groups)); + out.push_str("\nrows:\n"); + for group in &diff.groups { + let key = &group.key; + out.push_str(&format!( + "@@ {} {} {} {} (-{} +{})\n", + key.surface.name(), + key.language, + key.kind, + key.resolved_by, + group.removed.len(), + group.added.len() + )); + for row in &group.removed { + out.push_str(&format!("- {row}\n")); + } + for row in &group.added { + out.push_str(&format!("+ {row}\n")); + } + } + } + if let Some(schema) = &diff.schema { + out.push_str("\n@@ schema\n"); + if schema.removed.is_empty() && schema.added.is_empty() { + out.push_str("(the same lines in a different order)\n"); + } + for line in &schema.removed { + out.push_str(&format!("- {line}\n")); + } + for line in &schema.added { + out.push_str(&format!("+ {line}\n")); + } + } + + if diff.is_identical() { + out.push_str("\nresult: identical\n"); + } else { + out.push_str(&format!( + "\nresult: different ({removed_total} removed, {added_total} added rows; schema {schema_state})\n" + )); + } + out +} + +fn group_table(groups: &[GroupDiff]) -> String { + let header = ["surface", "language", "kind", "resolvedBy"]; + let cells = |key: &GroupKey| -> [String; 4] { + [ + key.surface.name().to_string(), + key.language.clone(), + key.kind.clone(), + key.resolved_by.clone(), + ] + }; + let mut widths = header.map(str::len); + for group in groups { + for (width, value) in widths.iter_mut().zip(cells(&group.key)) { + *width = (*width).max(value.len()); + } + } + let line = |cells: [&str; 4], removed: &str, added: &str| { + format!( + "{:8} {added:>8}\n", + cells[0], + cells[1], + cells[2], + cells[3], + w0 = widths[0], + w1 = widths[1], + w2 = widths[2], + w3 = widths[3], + ) + }; + let mut out = line(header, "removed", "added"); + for group in groups { + let values = cells(&group.key); + out.push_str(&line( + values.each_ref().map(String::as_str), + &group.removed.len().to_string(), + &group.added.len().to_string(), + )); + } + out +} + +/// Each distinct canonical row of a surface with its multiplicity, keyed (and +/// therefore ordered) by its compact JSON. +fn counted_rows(rows: &[CanonicalRow]) -> BTreeMap { + let mut counted: BTreeMap = BTreeMap::new(); + for row in rows { + counted.entry(row_json(row)).or_insert((0, row)).0 += 1; + } + counted +} + +/// Rows of `from` that `other` does not match, repeated per unmatched copy. +fn surplus_rows<'a>( + from: &BTreeMap, + other: &BTreeMap, +) -> Vec<(String, &'a CanonicalRow)> { + let mut rows = Vec::new(); + for (json, (count, row)) in from { + let matched = other.get(json).map_or(0, |(other_count, _)| *other_count); + for _ in matched..*count { + rows.push((json.clone(), *row)); + } + } + rows +} + +fn node_languages(graph: &CanonicalDb) -> HashMap<&str, &str> { + graph + .nodes + .iter() + .filter_map(|node| { + let id = node.get("id")?.as_str()?; + let language = node.get("language")?.as_str()?; + Some((id, language)) + }) + .collect() +} + +fn group_key( + surface: Surface, + row: &CanonicalRow, + own_languages: &HashMap<&str, &str>, + other_languages: &HashMap<&str, &str>, +) -> GroupKey { + let (language, kind, resolved_by) = match surface { + Surface::Nodes => (column(row, "language"), column(row, "kind"), NONE.into()), + Surface::Edges => { + let source = row.get("source").and_then(Value::as_str).unwrap_or(""); + let language = own_languages + .get(source) + .or_else(|| other_languages.get(source)) + .map_or(UNKNOWN_LANGUAGE, |language| *language); + ( + language.to_string(), + column(row, "kind"), + edge_resolution(row), + ) + } + Surface::UnresolvedRefs => ( + column(row, "language"), + column(row, "reference_kind"), + NONE.into(), + ), + Surface::Files => (column(row, "language"), "file".into(), NONE.into()), + }; + GroupKey { + surface, + language, + kind, + resolved_by, + } +} + +fn column(row: &CanonicalRow, name: &str) -> String { + match row.get(name) { + Some(Value::String(text)) => text.clone(), + None | Some(Value::Null) => NONE.to_string(), + Some(other) => other.to_string(), + } +} + +fn edge_resolution(row: &CanonicalRow) -> String { + let Some(metadata) = row.get("metadata").and_then(Value::as_object) else { + return NONE.to_string(); + }; + if let Some(resolved_by) = metadata.get("resolvedBy").and_then(Value::as_str) { + return resolved_by.to_string(); + } + if let Some(synthesized_by) = metadata.get("synthesizedBy").and_then(Value::as_str) { + return format!("synthesizedBy:{synthesized_by}"); + } + NONE.to_string() +} + +/// Lines of `from` left over after matching each against one equal line of +/// `other`, in `from` order. +fn unmatched_lines(from: &str, other: &str) -> Vec { + let mut available: HashMap<&str, usize> = HashMap::new(); + for line in other.lines() { + *available.entry(line).or_insert(0) += 1; + } + let mut unmatched = Vec::new(); + for line in from.lines() { + match available.get_mut(line) { + Some(count) if *count > 0 => *count -= 1, + _ => unmatched.push(line.to_string()), + } + } + unmatched +} + +fn row_json(row: &CanonicalRow) -> String { + serde_json::to_string(row).expect("canonical rows serialize") +} + +fn hash_rows(rows: &[CanonicalRow]) -> String { + let mut writer = HashWriter(Sha256::new()); + serde_json::to_writer(&mut writer, rows).expect("canonical rows serialize"); + hex(&writer.0.finalize()) +} + +/// Feeds everything written to it into a SHA-256 hasher. +struct HashWriter(Sha256); + +impl Write for HashWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.update(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from(DIGITS[usize::from(byte >> 4)])); + out.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + out +} + +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(suffix); + PathBuf::from(name) +} + +/// A uniquely named directory under the system temp dir, removed on drop. +pub(crate) struct ScratchDir { + path: PathBuf, +} + +impl ScratchDir { + pub(crate) fn new(label: &str) -> Result { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let base = std::env::temp_dir(); + for _ in 0..64 { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let serial = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = base.join(format!( + "codegraph-bench-{label}-{}-{nanos}-{serial}", + std::process::id() + )); + match fs::create_dir(&path) { + Ok(()) => return Ok(Self { path }), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| format!("creating {}", path.display())); + } + } + } + bail!( + "cannot create a unique scratch directory under {}", + base.display() + ) + } + + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn row(value: Value) -> CanonicalRow { + serde_json::from_value(value).expect("object row") + } + + fn graph(nodes: Vec, edges: Vec) -> CanonicalDb { + CanonicalDb { + nodes: nodes.into_iter().map(row).collect(), + edges: edges.into_iter().map(row).collect(), + unresolved_refs: Vec::new(), + files: Vec::new(), + schema: "CREATE TABLE nodes (id TEXT);\n".to_string(), + } + } + + fn node(id: &str, language: &str, name: &str) -> Value { + json!({"id": id, "kind": "function", "language": language, "name": name}) + } + + fn edge(source: &str, target: &str, resolved_by: Option<&str>) -> Value { + let metadata = resolved_by.map_or(Value::Null, |by| json!({"resolvedBy": by})); + json!({"source": source, "target": target, "kind": "calls", "metadata": metadata}) + } + + #[test] + fn identical_graphs_have_no_groups_and_identical_fingerprints() { + let left = graph( + vec![node("function:a", "go", "a")], + vec![edge("function:a", "function:a", None)], + ); + let diff = diff_graphs(&left, &left.clone()); + assert!(diff.is_identical()); + assert_eq!(fingerprint(&left), fingerprint(&left.clone())); + assert!(render_diff("l", "r", &diff).ends_with("\nresult: identical\n")); + } + + #[test] + fn rows_group_by_surface_language_kind_and_resolution() { + let left = graph( + vec![ + node("function:a", "go", "a"), + node("function:b", "rust", "b"), + ], + vec![ + edge("function:a", "function:b", Some("exact-match")), + edge("function:b", "function:a", Some("import")), + ], + ); + let right = graph( + vec![ + node("function:a", "go", "a"), + node("function:b", "rust", "b_renamed"), + ], + vec![ + edge("function:b", "function:a", Some("import")), + edge("function:b", "function:a", Some("import")), + json!({"source": "function:zz", "target": "function:a", "kind": "calls", + "metadata": {"synthesizedBy": "callback"}}), + ], + ); + let diff = diff_graphs(&left, &right); + let keys: Vec<(&str, &str, &str, &str, usize, usize)> = diff + .groups + .iter() + .map(|group| { + ( + group.key.surface.name(), + group.key.language.as_str(), + group.key.kind.as_str(), + group.key.resolved_by.as_str(), + group.removed.len(), + group.added.len(), + ) + }) + .collect(); + assert_eq!( + keys, + vec![ + ("nodes", "rust", "function", "-", 1, 1), + ("edges", "?", "calls", "synthesizedBy:callback", 0, 1), + ("edges", "go", "calls", "exact-match", 1, 0), + // The duplicate import edge is one surplus copy, not two. + ("edges", "rust", "calls", "import", 0, 1), + ] + ); + assert_eq!(diff.totals(Surface::Edges), (1, 2)); + assert!(diff.schema.is_none()); + let fingerprints = (fingerprint(&left), fingerprint(&right)); + assert_eq!( + differing_surfaces(&fingerprints.0, &fingerprints.1), + vec!["nodes".to_string(), "edges".to_string()] + ); + assert_ne!(fingerprints.0.hash, fingerprints.1.hash); + assert_eq!(fingerprints.1.rows.edges, 3); + assert_eq!(fingerprints.1.rows.total, 5); + } + + #[test] + fn rendering_is_deterministic_and_lists_every_row() { + let left = graph(vec![node("function:a", "go", "a")], Vec::new()); + let mut right = graph(Vec::new(), Vec::new()); + right.schema = "CREATE TABLE nodes (id TEXT);\nCREATE INDEX idx ON nodes(id);\n".into(); + let diff = diff_graphs(&left, &right); + let first = render_diff("left.db (database)", "golden (golden)", &diff); + assert_eq!( + first, + render_diff("left.db (database)", "golden (golden)", &diff) + ); + assert!(first.contains("\n@@ nodes go function - (-1 +0)\n- {")); + assert!(first.contains("\n@@ schema\n+ CREATE INDEX idx ON nodes(id);\n")); + assert!(first.ends_with("\nresult: different (1 removed, 0 added rows; schema differs)\n")); + } + + #[test] + fn reordered_schema_lines_are_reported_as_a_difference() { + let mut left = graph(Vec::new(), Vec::new()); + let mut right = left.clone(); + left.schema = "A;\nB;\n".into(); + right.schema = "B;\nA;\n".into(); + let diff = diff_graphs(&left, &right); + assert!(!diff.is_identical()); + assert!(render_diff("l", "r", &diff).contains("(the same lines in a different order)")); + } + + #[test] + fn hex_encodes_known_sha256() { + assert_eq!( + hex(&Sha256::digest(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } +} diff --git a/crates/codegraph-bench/src/lib.rs b/crates/codegraph-bench/src/lib.rs index dd39a6f5..661b373a 100644 --- a/crates/codegraph-bench/src/lib.rs +++ b/crates/codegraph-bench/src/lib.rs @@ -1,4 +1,6 @@ +pub mod ab; pub mod corpus; +pub mod graph_diff; pub mod markdown; pub mod metrics; pub mod oracle; diff --git a/crates/codegraph-bench/src/main.rs b/crates/codegraph-bench/src/main.rs index f5f59ea2..b4cc3be5 100644 --- a/crates/codegraph-bench/src/main.rs +++ b/crates/codegraph-bench/src/main.rs @@ -1,8 +1,13 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use clap::{Parser, ValueEnum}; -use codegraph_bench::corpus::{fetch_all, list_statuses}; +use codegraph_bench::ab::{ + AbConfig, AbCorpus, DEFAULT_RUN_TIMEOUT, run_ab, write_ab_markdown, write_ab_report, +}; +use codegraph_bench::corpus::{corpora_root, fetch_all_in, list_statuses, list_statuses_in}; +use codegraph_bench::graph_diff::run_graph_diff; use codegraph_bench::markdown::{render_from_json, write_markdown}; use codegraph_bench::oracle::golden::write_golden; use codegraph_bench::pipeline::{PipelineConfig, run_pipeline, select_corpora}; @@ -20,7 +25,10 @@ struct Args { )] list_corpora: bool, - #[arg(long, help = "Fetch pinned corpora into bench/corpora/")] + #[arg( + long, + help = "Fetch pinned corpora into bench/corpora/ (or <--corpora-root>/)" + )] fetch_corpora: bool, #[arg( @@ -32,10 +40,40 @@ struct Args { #[arg( long, value_name = "NAME", - help = "Corpus to benchmark (repeatable). Omit or pass 'all' for every pinned corpus." + value_delimiter = ',', + help = "Corpus to benchmark: repeat the flag or pass a comma-separated list. \ + For --run, omit it or pass 'all' for every pinned corpus; --ab needs a list or 'all'." )] corpora: Vec, + #[arg( + long, + value_name = "DIR", + help = "Directory holding the corpus checkouts for --ab, --list-corpora and \ + --fetch-corpora (default: bench/corpora in the workspace)" + )] + corpora_root: Option, + + #[arg( + long, + help = "A/B-time a cold `init` of --baseline against --candidate on --corpora" + )] + ab: bool, + + #[arg(long, value_name = "BIN", help = "Baseline codegraph binary for --ab")] + baseline: Option, + + #[arg(long, value_name = "BIN", help = "Candidate codegraph binary for --ab")] + candidate: Option, + + #[arg( + long, + value_name = "N", + value_delimiter = ',', + help = "RAYON_NUM_THREADS values for --ab, comma-separated; each is its own cell" + )] + threads: Vec, + #[arg( long, value_name = "CMD", @@ -46,7 +84,7 @@ struct Args { #[arg( long, default_value_t = 12, - help = "Number of process runs for --smoke" + help = "Number of process runs per cell for --smoke, --run and --ab; the first is discarded" )] runs: usize, @@ -56,14 +94,14 @@ struct Args { #[arg( long, value_name = "PATH", - help = "Write a results.json report for --smoke or --run" + help = "Write the JSON report for --smoke, --run or --ab (default: stdout)" )] out: Option, #[arg( long, value_name = "PATH", - help = "Write the rendered markdown report (docs/benchmark-results.md) for --run" + help = "Write the rendered Markdown report for --run (docs/benchmark-results.md) or --ab" )] report_md: Option, @@ -82,6 +120,15 @@ struct Args { help = "Generate canonical golden files from an upstream SQLite database" )] gen_golden: Option>, + + #[arg( + long, + value_names = ["LEFT", "RIGHT"], + num_args = 2, + help = "Compare two canonical graphs, each a SQLite database or a golden directory; \ + exit 0 when identical, 1 when different, 2 when an input cannot be loaded" + )] + graph_diff: Option>, } #[derive(Clone, Copy, Debug, ValueEnum)] @@ -90,17 +137,42 @@ enum CliCacheMode { Cold, } -fn main() -> Result<()> { +fn main() -> ExitCode { let args = Args::parse(); - let workspace_root = workspace_root()?; + if let Some(paths) = &args.graph_diff { + return graph_diff_exit(&paths[0], &paths[1]); + } + match run(&args) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("Error: {error:?}"); + ExitCode::FAILURE + } + } +} +/// `--graph-diff` follows the `cmp`/`diff` convention: 0 identical, 1 +/// different, 2 when an input cannot be loaded or the report cannot be written. +fn graph_diff_exit(left: &Path, right: &Path) -> ExitCode { + let mut stdout = std::io::stdout().lock(); + match run_graph_diff(left, right, &mut stdout) { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::from(1), + Err(error) => { + eprintln!("Error: {error:?}"); + ExitCode::from(2) + } + } +} + +fn run(args: &Args) -> Result<()> { if args.fetch_corpora { - let statuses = fetch_all(&workspace_root)?; + let statuses = fetch_all_in(&selected_corpora_root(args)?)?; print_statuses(&statuses); } if args.list_corpora { - let statuses = list_statuses(&workspace_root); + let statuses = list_statuses_in(&selected_corpora_root(args)?); print_statuses(&statuses); } @@ -110,7 +182,12 @@ fn main() -> Result<()> { return Ok(()); } + if args.ab { + return run_ab_command(args); + } + if args.run { + let workspace_root = workspace_root()?; let names: Vec = args .corpora .iter() @@ -139,14 +216,15 @@ fn main() -> Result<()> { return Ok(()); } - if let Some(paths) = args.gen_golden { + if let Some(paths) = &args.gen_golden { write_golden(&paths[0], &paths[1])?; } - if let Some(command) = args.smoke { + if let Some(command) = &args.smoke { + let workspace_root = workspace_root()?; let summary = run_command( &RunConfig { - command, + command: command.clone(), runs: args.runs, discard_first: true, mode: args.mode.into(), @@ -155,8 +233,8 @@ fn main() -> Result<()> { )?; let report = BenchmarkReport::smoke(&workspace_root, summary, list_statuses(&workspace_root)); - match args.out { - Some(path) => write_report(&path, &report)?, + match &args.out { + Some(path) => write_report(path, &report)?, None => println!("{}", serde_json::to_string_pretty(&report)?), } } @@ -164,6 +242,66 @@ fn main() -> Result<()> { Ok(()) } +fn run_ab_command(args: &Args) -> Result<()> { + let baseline = args + .baseline + .clone() + .context("--ab needs --baseline ")?; + let candidate = args + .candidate + .clone() + .context("--ab needs --candidate ")?; + if args.corpora.is_empty() { + bail!("--ab needs --corpora (or 'all')"); + } + if args.threads.is_empty() { + bail!("--ab needs --threads "); + } + let names: Vec = args + .corpora + .iter() + .filter(|name| name.as_str() != "all") + .cloned() + .collect(); + let root = selected_corpora_root(args)?; + let corpora = select_corpora(&names)? + .into_iter() + .map(|corpus| AbCorpus::from_registry(corpus, &root)) + .collect::>>()?; + let config = AbConfig { + baseline, + candidate, + corpora, + threads: args.threads.clone(), + runs: args.runs, + run_timeout: DEFAULT_RUN_TIMEOUT, + workspace_root: workspace_root().ok(), + argv: std::env::args_os() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(), + }; + let report = run_ab(&config)?; + match &args.out { + Some(path) => { + write_ab_report(path, &report)?; + eprintln!("wrote {}", path.display()); + } + None => println!("{}", serde_json::to_string_pretty(&report)?), + } + if let Some(path) = &args.report_md { + write_ab_markdown(path, &report)?; + eprintln!("wrote {}", path.display()); + } + Ok(()) +} + +fn selected_corpora_root(args: &Args) -> Result { + match &args.corpora_root { + Some(root) => Ok(root.clone()), + None => Ok(corpora_root(&workspace_root()?)), + } +} + impl From for CacheMode { fn from(value: CliCacheMode) -> Self { match value { @@ -174,10 +312,12 @@ impl From for CacheMode { } fn print_statuses(statuses: &[codegraph_bench::corpus::CorpusStatus]) { - println!("name\tcommit\texpected_loc\texpected_files\tactual_loc\tactual_files\tfetched\tpath"); + println!( + "name\tcommit\texpected_loc\texpected_files\tactual_loc\tactual_files\tfetched\tpath\ttag" + ); for status in statuses { println!( - "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", status.name, status.commit, status.expected_loc, @@ -191,7 +331,8 @@ fn print_statuses(statuses: &[codegraph_bench::corpus::CorpusStatus]) { .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_string()), status.fetched, - status.path + status.path, + status.tag.as_deref().unwrap_or("-") ); } } diff --git a/crates/codegraph-bench/src/metrics.rs b/crates/codegraph-bench/src/metrics.rs index aeee4863..0c6455b7 100644 --- a/crates/codegraph-bench/src/metrics.rs +++ b/crates/codegraph-bench/src/metrics.rs @@ -224,7 +224,7 @@ fn duration_to_ms(duration: Duration) -> f64 { duration.as_secs_f64() * 1000.0 } -fn read_proc_status_kb(pid: u32, field: &str) -> Option { +pub(crate) fn read_proc_status_kb(pid: u32, field: &str) -> Option { let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; status.lines().find_map(|line| { let value = line.strip_prefix(field)?.trim(); diff --git a/crates/codegraph-bench/src/pipeline.rs b/crates/codegraph-bench/src/pipeline.rs index 517c44f5..ab57022c 100644 --- a/crates/codegraph-bench/src/pipeline.rs +++ b/crates/codegraph-bench/src/pipeline.rs @@ -1,5 +1,5 @@ //! Task 26 full benchmark pipeline: Rust `codegraph` CLI vs the upstream -//! TypeScript CLI on the three pinned corpora. +//! TypeScript CLI on the pinned corpora. //! //! Methodology is documented in `docs/benchmark.md`. The pipeline: //! @@ -41,6 +41,13 @@ fn corpus_query(name: &str) -> &'static str { "fd-small" => "Walk", "tokio-medium" => "Runtime", "typescript-large" => "Parser", + "express-medium" => "createApplication", + "leveldb-small" => "DBImpl", + "gson-medium" => "Gson", + "newtonsoft-large" => "JsonSerializer", + "alamofire-small" => "Session", + "redis-medium" => "processCommand", + "kong-medium" => "init_worker", _ => "main", } } diff --git a/crates/codegraph-bench/src/report.rs b/crates/codegraph-bench/src/report.rs index bf9fac73..cccc5b2b 100644 --- a/crates/codegraph-bench/src/report.rs +++ b/crates/codegraph-bench/src/report.rs @@ -135,14 +135,14 @@ pub fn write_report(path: &Path, report: &BenchmarkReport) -> Result<()> { fs::write(path, json).with_context(|| format!("writing {}", path.display())) } -fn read_cpu_model() -> Option { +pub(crate) fn read_cpu_model() -> Option { fs::read_to_string("/proc/cpuinfo") .ok()? .lines() .find_map(|line| line.strip_prefix("model name\t: ").map(str::to_string)) } -fn read_mem_total_kb() -> Option { +pub(crate) fn read_mem_total_kb() -> Option { fs::read_to_string("/proc/meminfo") .ok()? .lines() @@ -152,7 +152,7 @@ fn read_mem_total_kb() -> Option { }) } -fn read_os_pretty_name() -> Option { +pub(crate) fn read_os_pretty_name() -> Option { fs::read_to_string("/etc/os-release") .ok()? .lines() @@ -177,7 +177,7 @@ fn read_reference_pin(workspace_root: &Path, heading: &str) -> Option { None } -fn command_stdout(program: &str, args: &[&str]) -> Option { +pub(crate) fn command_stdout(program: &str, args: &[&str]) -> Option { command_stdout_in(Path::new("."), program, args) } diff --git a/crates/codegraph-bench/tests/ab_graph_diff.rs b/crates/codegraph-bench/tests/ab_graph_diff.rs new file mode 100644 index 00000000..107df4d3 --- /dev/null +++ b/crates/codegraph-bench/tests/ab_graph_diff.rs @@ -0,0 +1,763 @@ +//! Failure injection for `bench --graph-diff` and `bench --ab`. +//! +//! The graph-diff tests run the real `bench` binary on committed goldens. The +//! A/B tests drive `run_ab` with stand-in shell scripts instead of a real +//! `codegraph`: each one copies a committed golden database into the project's +//! index directory, so every failure mode the harness must stop on is cheap to +//! provoke and the tests never touch the large corpora. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use codegraph_bench::graph_diff::{diff_graphs, load_graph}; + +fn workspace() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-bench lives under crates/") + .to_path_buf() +} + +fn golden_dir(name: &str) -> PathBuf { + workspace().join("reference/golden").join(name) +} + +fn golden_db(name: &str) -> PathBuf { + golden_dir(name).join("colby.db") +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-bench-ab-test-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + fs::create_dir_all(&path).expect("create test dir"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn graph_diff(left: &Path, right: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_bench")) + .arg("--graph-diff") + .arg(left) + .arg(right) + .output() + .expect("run bench --graph-diff") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(suffix); + PathBuf::from(name) +} + +/// Copy a committed golden database and change it: drop one `calls` edge and +/// rename one function. +fn tampered_copy(golden: &str, destination: &Path) { + fs::copy(golden_db(golden), destination).expect("copy golden db"); + let conn = rusqlite::Connection::open(destination).expect("open copy"); + let removed = conn + .execute( + "DELETE FROM edges WHERE rowid = (SELECT MIN(rowid) FROM edges WHERE kind = 'calls')", + [], + ) + .expect("delete an edge"); + assert_eq!(removed, 1, "the {golden} golden must have a calls edge"); + let renamed = conn + .execute( + "UPDATE nodes SET name = name || '_tampered' \ + WHERE id = (SELECT MIN(id) FROM nodes WHERE kind = 'function')", + [], + ) + .expect("rename a function"); + assert_eq!(renamed, 1, "the {golden} golden must have a function"); +} + +#[test] +fn graph_diff_of_a_golden_directory_with_itself_is_identical() { + let output = graph_diff(&golden_dir("go"), &golden_dir("go")); + assert_eq!(output.status.code(), Some(0), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("(golden)"), "{text}"); + assert!(text.ends_with("\nresult: identical\n"), "{text}"); +} + +#[test] +fn graph_diff_of_a_database_and_its_golden_is_identical_and_leaves_no_sidecars() { + let db = golden_db("typescript"); + let output = graph_diff(&db, &golden_dir("typescript")); + assert_eq!(output.status.code(), Some(0), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("(database)"), "{text}"); + assert!(text.ends_with("\nresult: identical\n"), "{text}"); + for suffix in ["-wal", "-shm", "-journal"] { + assert!( + !with_suffix(&db, suffix).exists(), + "graph-diff must not open the committed database in place ({suffix})" + ); + } +} + +#[test] +fn graph_diff_reports_every_changed_row_of_a_tampered_database() { + let dir = TestDir::new("graph-diff-tampered"); + let tampered = dir.path().join("tampered.db"); + tampered_copy("typescript", &tampered); + + let output = graph_diff(&golden_dir("typescript"), &tampered); + assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); + let text = stdout(&output); + assert!( + text.contains("\nnodes 1 1\n"), + "{text}" + ); + assert!( + text.contains("\nedges 1 0\n"), + "{text}" + ); + assert!(text.contains("\nschema identical\n"), "{text}"); + assert!( + text.ends_with("\nresult: different (2 removed, 1 added rows; schema identical)\n"), + "{text}" + ); + + // Every changed row the library finds is printed under its group header. + let (_, golden) = load_graph(&golden_dir("typescript")).unwrap(); + let (_, changed) = load_graph(&tampered).unwrap(); + let diff = diff_graphs(&golden, &changed); + assert_eq!(diff.groups.len(), 2, "one node group and one edge group"); + for group in &diff.groups { + let key = &group.key; + let header = format!( + "@@ {} {} {} {} (-{} +{})\n", + key.surface.name(), + key.language, + key.kind, + key.resolved_by, + group.removed.len(), + group.added.len() + ); + assert!(text.contains(&header), "missing {header} in {text}"); + for row in &group.removed { + assert!(text.contains(&format!("\n- {row}\n")), "{text}"); + } + for row in &group.added { + assert!(text.contains(&format!("\n+ {row}\n")), "{text}"); + } + } + let edge_group = diff + .groups + .iter() + .find(|group| group.key.surface.name() == "edges") + .expect("an edge group"); + assert_eq!(edge_group.key.kind, "calls"); + assert_eq!(edge_group.key.language, "typescript"); + assert_ne!(edge_group.key.resolved_by, "-"); + + // The report is deterministic. + let again = graph_diff(&golden_dir("typescript"), &tampered); + assert_eq!(again.stdout, output.stdout); +} + +#[test] +fn graph_diff_refuses_a_corrupted_database_without_printing_a_report() { + let dir = TestDir::new("graph-diff-corrupt"); + let garbage = dir.path().join("garbage.db"); + fs::write(&garbage, vec![0xA5_u8; 8192]).unwrap(); + let truncated = dir.path().join("truncated.db"); + let bytes = fs::read(golden_db("typescript")).unwrap(); + fs::write(&truncated, &bytes[..bytes.len() / 2]).unwrap(); + let empty = dir.path().join("empty.db"); + fs::write(&empty, b"").unwrap(); + + for db in [&garbage, &truncated, &empty] { + let output = graph_diff(&golden_dir("typescript"), db); + assert_eq!(output.status.code(), Some(2), "{}", stderr(&output)); + assert!( + output.stdout.is_empty(), + "no partial report: {}", + stdout(&output) + ); + assert!( + stderr(&output).contains("cannot canonicalize the database"), + "{}", + stderr(&output) + ); + } + assert!(stderr(&graph_diff(&golden_dir("go"), &garbage)).contains("file is not a database")); + assert!(stderr(&graph_diff(&golden_dir("go"), &truncated)).contains("malformed")); +} + +/// Overwrite the page-type byte of one index's root page. Table scans never +/// read index pages, so only an integrity check notices. +fn corrupt_an_index_page(db: &Path) { + let (root_page, page_size): (i64, i64) = { + let conn = rusqlite::Connection::open(db).expect("open copy"); + let root_page = conn + .query_row( + "SELECT rootpage FROM sqlite_master WHERE type = 'index' AND rootpage > 1 \ + ORDER BY name LIMIT 1", + [], + |row| row.get(0), + ) + .expect("the golden has an index"); + let page_size = conn + .query_row("PRAGMA page_size", [], |row| row.get(0)) + .expect("page size"); + (root_page, page_size) + }; + let mut bytes = fs::read(db).unwrap(); + let offset = usize::try_from((root_page - 1) * page_size).unwrap(); + bytes[offset] = 0xEE; + fs::write(db, bytes).unwrap(); +} + +#[test] +fn graph_diff_refuses_a_database_whose_index_pages_are_corrupted() { + let dir = TestDir::new("graph-diff-index"); + let db = dir.path().join("index-corrupt.db"); + fs::copy(golden_db("typescript"), &db).unwrap(); + corrupt_an_index_page(&db); + + // Every canonical table still reads, so the corruption is invisible to the + // oracle on its own. + let readable = dir.path().join("readable.db"); + fs::copy(&db, &readable).unwrap(); + codegraph_bench::oracle::canonicalize_db(&readable) + .expect("table scans do not touch index pages"); + + let output = graph_diff(&golden_dir("typescript"), &db); + assert_eq!(output.status.code(), Some(2), "{}", stderr(&output)); + assert!(output.stdout.is_empty()); + assert!( + stderr(&output).contains("PRAGMA quick_check failed"), + "{}", + stderr(&output) + ); +} + +#[test] +fn graph_diff_refuses_missing_and_non_golden_inputs() { + let dir = TestDir::new("graph-diff-missing"); + let output = graph_diff(&golden_dir("go"), &dir.path().join("missing.db")); + assert_eq!(output.status.code(), Some(2), "{}", stderr(&output)); + assert!(output.stdout.is_empty()); + + let source_dir = workspace().join("crates/codegraph-bench/fixtures/go"); + let output = graph_diff(&source_dir, &golden_dir("go")); + assert_eq!(output.status.code(), Some(2), "{}", stderr(&output)); + assert!( + stderr(&output).contains("cannot load the golden directory"), + "{}", + stderr(&output) + ); +} + +#[cfg(unix)] +mod ab { + use std::ffi::OsString; + use std::os::unix::fs::PermissionsExt; + use std::time::{Duration, Instant}; + + use codegraph_bench::ab::{AbConfig, AbCorpus, AbReport, render_ab_markdown, run_ab}; + use codegraph_bench::oracle::load_golden; + + use super::*; + + /// Start a freshly written script once, retrying while another test + /// thread's fork still holds it open for writing (ETXTBSY). + fn wait_until_executable(script: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match Command::new(script) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(mut child) => { + let _ = child.wait(); + return; + } + Err(error) + if error.kind() == std::io::ErrorKind::ExecutableFileBusy + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("cannot run {}: {error}", script.display()), + } + } + } + + fn quoted(path: &Path) -> String { + let text = path.to_str().expect("UTF-8 test path"); + assert!(!text.contains('\''), "test paths must not contain quotes"); + format!("'{text}'") + } + + /// Write an executable `/bin/sh` script. Every script exits 64 unless its + /// first argument is `init`, so the warm-up and `--version` probes are + /// harmless. + fn script(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + fs::write( + &path, + format!("#!/bin/sh\n[ \"$1\" = init ] || exit 64\nproject=$2\n{body}\n"), + ) + .unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + wait_until_executable(&path); + path + } + + /// A stand-in `codegraph` whose `init` checks that it got a fresh copy of + /// the corpus, logs its environment, and installs `db` as the index. + fn copying_codegraph(dir: &Path, name: &str, db: &Path, log: &Path) -> PathBuf { + script( + dir, + name, + &format!( + "[ -f \"$project/main.go\" ] || exit 65\n\ + [ -e \"$project/.codegraph\" ] && exit 66\n\ + [ -e \"$project/.git\" ] && exit 67\n\ + echo \"${{0##*/}} ${{RAYON_NUM_THREADS:-unset}} ${{CODEGRAPH_NO_DAEMON:-unset}} \ + ${{CODEGRAPH_NO_WATCH:-unset}} ${{CODEGRAPH_DIR:-unset}}\" >> {log}\n\ + mkdir \"$project/.codegraph\" && cp {db} \"$project/.codegraph/codegraph.db\"", + log = quoted(log), + db = quoted(db) + ), + ) + } + + /// A one-file corpus that also carries VCS metadata and a stale index, + /// neither of which may reach a run's project. + fn tiny_corpus(dir: &Path) -> PathBuf { + let corpus = dir.join("corpus"); + fs::create_dir_all(corpus.join(".git")).unwrap(); + fs::create_dir_all(corpus.join(".codegraph")).unwrap(); + fs::write(corpus.join(".git/HEAD"), b"ref: refs/heads/main\n").unwrap(); + fs::write(corpus.join(".codegraph/codegraph.db"), b"stale").unwrap(); + fs::write(corpus.join("main.go"), b"package main\n\nfunc main() {}\n").unwrap(); + corpus + } + + fn config( + baseline: PathBuf, + candidate: PathBuf, + corpus: &Path, + threads: Vec, + runs: usize, + ) -> AbConfig { + AbConfig { + baseline, + candidate, + corpora: vec![AbCorpus { + name: "tiny".to_string(), + commit: None, + tag: None, + source: corpus.to_path_buf(), + }], + threads, + runs, + run_timeout: Duration::from_secs(60), + workspace_root: None, + argv: Vec::new(), + } + } + + fn first_existing(candidates: &[&str]) -> PathBuf { + candidates + .iter() + .map(PathBuf::from) + .find(|path| path.is_file()) + .unwrap_or_else(|| panic!("none of {candidates:?} exists")) + } + + fn failure(config: &AbConfig) -> String { + let error = run_ab(config).expect_err("the A/B must stop"); + format!("{error:#}") + } + + #[test] + fn ab_times_both_binaries_at_each_thread_count_and_reports_identical_graphs() { + let dir = TestDir::new("identical"); + let corpus = tiny_corpus(dir.path()); + let log = dir.path().join("runs.log"); + let golden = golden_db("go"); + let baseline = copying_codegraph(dir.path(), "baseline", &golden, &log); + let candidate = copying_codegraph(dir.path(), "candidate", &golden, &log); + + let report = + run_ab(&config(baseline, candidate, &corpus, vec![3, 1], 3)).expect("the A/B succeeds"); + + assert_eq!(report.kind, "codegraph-ab"); + assert_eq!(report.baseline.sha256.len(), 64); + assert_eq!(report.settings.thread_env, "RAYON_NUM_THREADS"); + let corpus_report = &report.corpora[0]; + assert!(corpus_report.graph.identical); + assert!(corpus_report.graph.differing_surfaces.is_empty()); + assert_eq!(corpus_report.copied_files, 1); + let expected = load_golden(&golden_dir("go")).unwrap(); + let rows = corpus_report.graph.baseline.rows; + assert_eq!(rows.nodes, expected.nodes.len() as u64); + assert_eq!(rows.edges, expected.edges.len() as u64); + assert_eq!(rows.unresolved_refs, expected.unresolved_refs.len() as u64); + assert_eq!(rows.files, expected.files.len() as u64); + + let threads: Vec = corpus_report + .cells + .iter() + .map(|cell| cell.threads) + .collect(); + assert_eq!(threads, vec![3, 1]); + for cell in &corpus_report.cells { + for series in [&cell.baseline, &cell.candidate] { + let warmups: Vec = series.runs.iter().map(|run| run.warmup).collect(); + assert_eq!(warmups, vec![true, false, false]); + assert_eq!(series.wall_ms.samples, 2); + assert!(series.peak_rss_kb.is_some(), "wait4 reports peak RSS"); + assert!( + series + .runs + .iter() + .all(|run| run.canonical_hash == corpus_report.graph.baseline.hash) + ); + } + assert!(cell.ratio.wall_ms.is_some()); + assert_eq!(cell.ratio.canonical_rows, Some(1.0)); + assert!(cell.budget.init_wall_ok.is_some()); + } + + // Every run got its thread count and the daemon/watch opt-outs, with + // CODEGRAPH_DIR removed, and the binaries alternated which went first. + let lines: Vec = fs::read_to_string(&log) + .unwrap() + .lines() + .map(str::to_string) + .collect(); + let mut expected_lines = Vec::new(); + for threads in [3, 1] { + for binary in [ + "baseline", + "candidate", + "candidate", + "baseline", + "baseline", + "candidate", + ] { + expected_lines.push(format!("{binary} {threads} 1 1 unset")); + } + } + assert_eq!(lines, expected_lines); + + let json = serde_json::to_string(&report).unwrap(); + let parsed: AbReport = serde_json::from_str(&json).unwrap(); + assert_eq!( + parsed.corpora[0].graph.candidate.hash, + corpus_report.graph.baseline.hash + ); + let markdown = render_ab_markdown(&report); + assert!(markdown.contains("| tiny | 3 |"), "{markdown}"); + assert!(markdown.contains("| identical |"), "{markdown}"); + } + + #[test] + fn ab_reports_a_candidate_that_changes_the_graph() { + let dir = TestDir::new("changed"); + let corpus = tiny_corpus(dir.path()); + let log = dir.path().join("runs.log"); + let tampered = dir.path().join("tampered.db"); + tampered_copy("typescript", &tampered); + let baseline = copying_codegraph(dir.path(), "baseline", &golden_db("typescript"), &log); + let candidate = copying_codegraph(dir.path(), "candidate", &tampered, &log); + + let report = run_ab(&config(baseline, candidate, &corpus, vec![2], 2)) + .expect("a graph difference is reported, not fatal"); + + let graph = &report.corpora[0].graph; + assert!(!graph.identical); + assert_eq!(graph.differing_surfaces, vec!["nodes", "edges"]); + assert_eq!(graph.candidate.rows.edges + 1, graph.baseline.rows.edges); + assert_ne!(graph.baseline.hash, graph.candidate.hash); + let rows_ratio = report.corpora[0].cells[0].ratio.canonical_rows.unwrap(); + assert!(rows_ratio < 1.0, "{rows_ratio}"); + assert!(render_ab_markdown(&report).contains("| differs: nodes, edges |")); + } + + #[test] + fn ab_stops_when_a_run_exits_non_zero() { + let dir = TestDir::new("false"); + let corpus = tiny_corpus(dir.path()); + let log = dir.path().join("runs.log"); + let baseline = copying_codegraph(dir.path(), "baseline", &golden_db("go"), &log); + let false_binary = first_existing(&["/bin/false", "/usr/bin/false"]); + + let message = failure(&config(baseline, false_binary, &corpus, vec![1], 2)); + assert!(message.contains("candidate run 1/2 on tiny"), "{message}"); + assert!(message.contains("exited with status 1"), "{message}"); + } + + #[test] + fn ab_stops_when_a_run_exits_zero_without_a_database() { + let dir = TestDir::new("true"); + let corpus = tiny_corpus(dir.path()); + let true_binary = first_existing(&["/bin/true", "/usr/bin/true"]); + + let message = failure(&config( + true_binary.clone(), + true_binary, + &corpus, + vec![1], + 2, + )); + assert!(message.contains("baseline run 1/2 on tiny"), "{message}"); + assert!(message.contains("produced no database"), "{message}"); + } + + #[test] + fn ab_stops_on_a_database_that_cannot_be_canonicalized() { + let dir = TestDir::new("corrupt"); + let corpus = tiny_corpus(dir.path()); + let log = dir.path().join("runs.log"); + let garbage = dir.path().join("garbage.db"); + fs::write(&garbage, vec![0xA5_u8; 8192]).unwrap(); + let baseline = copying_codegraph(dir.path(), "baseline", &golden_db("go"), &log); + let candidate = copying_codegraph(dir.path(), "candidate", &garbage, &log); + + let message = failure(&config(baseline, candidate, &corpus, vec![1], 2)); + assert!(message.contains("candidate run 1/2 on tiny"), "{message}"); + assert!(message.contains("cannot be canonicalized"), "{message}"); + assert!(message.contains("file is not a database"), "{message}"); + } + + #[test] + fn ab_stops_when_runs_of_one_binary_disagree() { + let dir = TestDir::new("flaky"); + let corpus = tiny_corpus(dir.path()); + let log = dir.path().join("runs.log"); + let tampered = dir.path().join("tampered.db"); + tampered_copy("typescript", &tampered); + let counter = dir.path().join("counter"); + let baseline = copying_codegraph(dir.path(), "baseline", &golden_db("typescript"), &log); + // Odd invocations install the golden graph, even ones the tampered one. + let candidate = script( + dir.path(), + "flaky", + &format!( + "n=$(cat {counter} 2>/dev/null || echo 0)\nn=$((n + 1))\necho \"$n\" > {counter}\n\ + db={golden}\n[ $((n % 2)) -eq 0 ] && db={tampered}\n\ + mkdir \"$project/.codegraph\" && cp \"$db\" \"$project/.codegraph/codegraph.db\"", + counter = quoted(&counter), + golden = quoted(&golden_db("typescript")), + tampered = quoted(&tampered) + ), + ); + + let message = failure(&config(baseline, candidate, &corpus, vec![1], 3)); + assert!(message.contains("non-deterministic graph"), "{message}"); + assert!(message.contains("candidate run 2/3 on tiny"), "{message}"); + assert!( + message.contains("differing surfaces: nodes, edges"), + "{message}" + ); + } + + #[test] + fn ab_stops_when_one_binary_disagrees_with_itself_across_thread_counts() { + let dir = TestDir::new("threads"); + let corpus = tiny_corpus(dir.path()); + let log = dir.path().join("runs.log"); + let tampered = dir.path().join("tampered.db"); + tampered_copy("typescript", &tampered); + let baseline = copying_codegraph(dir.path(), "baseline", &golden_db("typescript"), &log); + let candidate = script( + dir.path(), + "thread-dependent", + &format!( + "db={golden}\n[ \"$RAYON_NUM_THREADS\" = 1 ] || db={tampered}\n\ + mkdir \"$project/.codegraph\" && cp \"$db\" \"$project/.codegraph/codegraph.db\"", + golden = quoted(&golden_db("typescript")), + tampered = quoted(&tampered) + ), + ); + + let message = failure(&config(baseline, candidate, &corpus, vec![1, 2], 2)); + assert!(message.contains("non-deterministic graph"), "{message}"); + assert!(message.contains("RAYON_NUM_THREADS=2"), "{message}"); + } + + #[test] + fn ab_kills_and_stops_on_a_run_past_its_timeout() { + let dir = TestDir::new("hang"); + let corpus = tiny_corpus(dir.path()); + let log = dir.path().join("runs.log"); + let hanging = script(dir.path(), "hanging", "exec sleep 30"); + let candidate = copying_codegraph(dir.path(), "candidate", &golden_db("go"), &log); + let mut config = config(hanging, candidate, &corpus, vec![1], 2); + config.run_timeout = Duration::from_secs(1); + + let started = Instant::now(); + let message = failure(&config); + let elapsed = started.elapsed(); + assert!(message.contains("baseline run 1/2 on tiny"), "{message}"); + assert!( + message.contains("exceeded the 1 s run timeout"), + "{message}" + ); + assert!( + elapsed >= Duration::from_secs(1), + "the timeout elapsed: {elapsed:?}" + ); + assert!( + elapsed < Duration::from_secs(20), + "the hung run was killed: {elapsed:?}" + ); + } + + #[test] + fn ab_rejects_settings_that_cannot_produce_a_measurement() { + let dir = TestDir::new("settings"); + let corpus = tiny_corpus(dir.path()); + let true_binary = first_existing(&["/bin/true", "/usr/bin/true"]); + let base = config(true_binary.clone(), true_binary, &corpus, vec![1], 2); + let rejected = |mutate: &dyn Fn(&mut AbConfig), expected: &str| { + let mut config = base.clone(); + mutate(&mut config); + let message = failure(&config); + assert!(message.contains(expected), "{expected}: {message}"); + }; + rejected(&|config| config.runs = 1, "at least 2"); + rejected(&|config| config.threads.clear(), "thread count"); + rejected(&|config| config.threads = vec![0], "at least 1"); + rejected(&|config| config.threads = vec![2, 2], "distinct"); + rejected(&|config| config.corpora.clear(), "at least one corpus"); + } + + fn bench(args: &[OsString]) -> Output { + Command::new(env!("CARGO_BIN_EXE_bench")) + .args(args) + .current_dir(workspace()) + .output() + .expect("run bench") + } + + fn os_args(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() + } + + #[test] + fn ab_cli_refuses_missing_arguments_and_unpinned_corpora() { + let dir = TestDir::new("cli"); + let true_binary = first_existing(&["/bin/true", "/usr/bin/true"]); + let root = dir.path().join("corpora"); + fs::create_dir_all(&root).unwrap(); + let mut base = os_args(&["--ab", "--baseline"]); + base.push(true_binary.clone().into()); + base.push("--candidate".into()); + base.push(true_binary.into()); + base.extend(os_args(&["--threads", "1", "--corpora-root"])); + base.push(root.clone().into()); + + let output = bench(&os_args(&["--ab"])); + assert_eq!(output.status.code(), Some(1)); + assert!( + stderr(&output).contains("--ab needs --baseline"), + "{}", + stderr(&output) + ); + + let output = bench(&base); + assert!( + stderr(&output).contains("--ab needs --corpora"), + "{}", + stderr(&output) + ); + + let mut args = base.clone(); + args.extend(os_args(&["--corpora", "no-such-corpus"])); + let output = bench(&args); + assert!( + stderr(&output).contains("unknown corpus"), + "{}", + stderr(&output) + ); + + let mut args = base; + args.extend(os_args(&["--corpora", "fd-small"])); + let output = bench(&args); + assert_eq!(output.status.code(), Some(1)); + assert!( + stderr(&output).contains("is not fetched"), + "{}", + stderr(&output) + ); + + // A checkout at another commit than the pin is refused before any run. + let checkout = root.join("fd-small"); + fs::create_dir_all(checkout.join("src")).unwrap(); + for git_args in [ + &["init", "--quiet"][..], + &[ + "-c", + "user.name=CodeGraph Test", + "-c", + "user.email=codegraph@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + "commit", + "--quiet", + "--allow-empty", + "-m", + "not the pin", + ][..], + ] { + let status = Command::new("git") + .arg("-C") + .arg(&checkout) + .args(git_args) + .status() + .expect("run git"); + assert!(status.success(), "git {git_args:?}"); + } + let output = bench(&args); + assert_eq!(output.status.code(), Some(1)); + assert!( + stderr(&output) + .contains("but the registry pins 25461e5ce13dc12ff2a75993285a87e99b33db2d"), + "{}", + stderr(&output) + ); + } +} diff --git a/crates/codegraph-bench/tests/equivalence.rs b/crates/codegraph-bench/tests/equivalence.rs index 79e01872..5c44a80b 100644 --- a/crates/codegraph-bench/tests/equivalence.rs +++ b/crates/codegraph-bench/tests/equivalence.rs @@ -36,398 +36,92 @@ fn an_unparseable_known_diffs_file_fails_the_equivalence_assertion() { assert!(message.contains("may not be allowlisted"), "got: {message}"); } -#[test] -fn generated_golden_matches_committed_mini_fixture() { - let tempdir = TestDir::new("generated-golden"); - write_golden(&mini_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&mini_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn upstream_db_is_self_equivalent_to_mini_golden() { - assert_equivalent(&mini_db(), &mini_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_godot_fixture() { - // Guards Godot extraction (F1 autoload-call→func + F2 signal-handler edges) - // against byte-drift: regenerating the canonical golden from the committed - // godot db must reproduce the committed JSON exactly. - let tempdir = TestDir::new("generated-golden-godot"); - write_golden(&godot_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&godot_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn upstream_db_is_self_equivalent_to_godot_golden() { - assert_equivalent(&godot_db(), &godot_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_ruby_fixture() { - // Guards Ruby #1110 receiver.method extraction (instance-call → Calls, - // class-method call → Calls, `Const.new` → Instantiates, bare include → - // Implements) against byte-drift: regenerating the canonical golden from the - // committed ruby db must reproduce the committed JSON exactly. - let tempdir = TestDir::new("generated-golden-ruby"); - write_golden(&ruby_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&ruby_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn upstream_db_is_self_equivalent_to_ruby_golden() { - assert_equivalent(&ruby_db(), &ruby_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_cpp_fixture() { - // Guards C++ #1043 base_class_clause inheritance (general Extends extraction - // + templated-base stripping) against byte-drift: regenerating the canonical - // golden from the committed cpp db must reproduce the committed JSON exactly. - let tempdir = TestDir::new("generated-golden-cpp"); - write_golden(&cpp_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&cpp_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn cpp_db_is_self_equivalent_to_cpp_golden() { - assert_equivalent(&cpp_db(), &cpp_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_rust_fixture() { - // Guards item 7 (Rust unit structs are indexed, so `impl Trait for Unit` - // forms) and item 9-Rust (`union` is a first-class kind) against byte-drift. - let tempdir = TestDir::new("generated-golden-rust"); - write_golden(&rust_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&rust_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn rust_db_is_self_equivalent_to_rust_golden() { - assert_equivalent(&rust_db(), &rust_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_lua_fixture() { - // Guards #1616 Lua function expressions: local assignment functions become - // Function nodes without duplicate Variables, table members become Methods - // with full qualified names, nested table members retain their namespace, - // body calls belong to the synthesized callable, and both dot/colon calls - // resolve to the same static table member. - let tempdir = TestDir::new("generated-golden-lua"); - write_golden(&lua_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&lua_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn lua_db_is_self_equivalent_to_lua_golden() { - assert_equivalent(&lua_db(), &lua_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_go_fixture() { - // Guards #1500 content-header detection: `files.generated` is byte-pinned at - // BOTH values — 1 for the Go banner, the Wrangler double-`by` banner, and the - // path-only `.pb.go`; 0 for the hand-written sibling, the single-`by` prose - // negative, and the generator whose banner is a `const` in its body. - let tempdir = TestDir::new("generated-golden-go"); - write_golden(&go_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&go_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn go_db_is_self_equivalent_to_go_golden() { - assert_equivalent(&go_db(), &go_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_python_fixture() { - let tempdir = TestDir::new("generated-golden-python"); - write_golden(&python_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&python_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn python_db_is_self_equivalent_to_python_golden() { - assert_equivalent(&python_db(), &python_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_kotlin_fixture() { - let tempdir = TestDir::new("generated-golden-kotlin"); - write_golden(&kotlin_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&kotlin_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn kotlin_db_is_self_equivalent_to_kotlin_golden() { - assert_equivalent(&kotlin_db(), &kotlin_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_scala_fixture() { - // Guards Scala grammar 0.26.2's multi-parameter-list inheritance shape, - // every `extends`/`with` parent, companion object -> Module classification, - // inheritance preference for the real type, and eta-expansion method values. - let tempdir = TestDir::new("generated-golden-scala"); - write_golden(&scala_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&scala_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn scala_db_is_self_equivalent_to_scala_golden() { - assert_equivalent(&scala_db(), &scala_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_dart_fixture() { - // Guards Dart 3 `extension type`: the type is class-like and owns getter / - // method nodes rather than leaving top-level functions or dropping members. - let tempdir = TestDir::new("generated-golden-dart"); - write_golden(&dart_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&dart_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn dart_db_is_self_equivalent_to_dart_golden() { - assert_equivalent(&dart_db(), &dart_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_typescript_fixture() { - let tempdir = TestDir::new("generated-golden-typescript"); - write_golden(&typescript_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&typescript_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn typescript_db_is_self_equivalent_to_typescript_golden() { - assert_equivalent(&typescript_db(), &typescript_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_metal_fixture() { - // Guards Metal (#1121): `.metal`→cpp mapping + the `[[attribute]]` blank that - // prevents the spurious `VertexIn extends float4` inheritance edge. - let tempdir = TestDir::new("generated-golden-metal"); - write_golden(&metal_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&metal_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn metal_db_is_self_equivalent_to_metal_golden() { - assert_equivalent(&metal_db(), &metal_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_cuda_fixture() { - // Guards CUDA (#1172 CUDA-lang parts): `.cu`→cpp mapping, the `<<<…>>>` - // launch-config blank that preserves the host→kernel Calls edge (plain + - // templated), and macro-defined-kernel name recovery (`my_kernel`). - let tempdir = TestDir::new("generated-golden-cuda"); - write_golden(&cuda_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&cuda_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn cuda_db_is_self_equivalent_to_cuda_golden() { - assert_equivalent(&cuda_db(), &cuda_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_arkts_fixture() { - // Guards ArkTS extraction (upstream #1186, extraction slice only): the - // `.ets`->ArkTs mapping, `@Component struct`->NodeKind::Struct via the - // dedicated tree-sitter-arkts grammar, function/class/import/call extraction. - let tempdir = TestDir::new("generated-golden-arkts"); - write_golden(&arkts_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&arkts_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn arkts_db_is_self_equivalent_to_arkts_golden() { - assert_equivalent(&arkts_db(), &arkts_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_solidity_fixture() { - // Guards Solidity extraction (upstream #1170): the `.sol`->Solidity mapping, - // contract/library->Class + interface->Interface + struct->Struct + - // enum->Enum, synthetic constructor/fallback/receive method names, - // state-var/struct-member/event/error->Field, `is`-inheritance->Extends - // (resolver promotes to Implements), and emit/modifier-guard call edges. - let tempdir = TestDir::new("generated-golden-solidity"); - write_golden(&solidity_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&solidity_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn solidity_db_is_self_equivalent_to_solidity_golden() { - assert_equivalent(&solidity_db(), &solidity_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_nix_fixture() { - // Guards Nix extraction (upstream #1190, extraction slice only): the - // `.nix`->Nix mapping, `binding`->Function|Variable, curried lambda->Function - // with a formatted signature, `inherit`->Variable names, `import`/ - // `callPackage`/`imports`-list literal paths->Import node + Imports ref, and - // `apply_expression`->Calls ref with curried-chain dedup. The module-system - // synthesizer / lexical-scope gates / callback synthesizer / import-resolver - // wiring are DEFERRED, so path refs stay unresolved. - let tempdir = TestDir::new("generated-golden-nix"); - write_golden(&nix_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&nix_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn nix_db_is_self_equivalent_to_nix_golden() { - assert_equivalent(&nix_db(), &nix_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_terraform_fixture() { - // Guards Terraform/HCL extraction (upstream #1173, extraction slice only): - // the `.tf`/`.tfvars`/`.tofu`->Terraform mapping, block-type dispatch - // (resource/data->Class, module->Module, variable/output->Variable, - // provider->Namespace, locals->Constant per attr) with qualified names, and - // plain attribute-expression traversal refs - // (var.X/local.X/module.M/data.T.N/.)->References with built-ins - // skipped. The module-boundary TerraformResolver, emitModuleWiring's - // :-scoped refs, and the .tfvars var ref are DEFERRED, so no :-scoped ref is - // emitted; the undeclared aws_kms_key.logs stays unresolved. - let tempdir = TestDir::new("generated-golden-terraform"); - write_golden(&terraform_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&terraform_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn terraform_db_is_self_equivalent_to_terraform_golden() { - assert_equivalent(&terraform_db(), &terraform_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_erlang_fixture() { - // Guards Erlang extraction (upstream #1165, extraction slice only): the - // `.erl`/`.hrl`->Erlang mapping, `-module`->Namespace (so functions qualify - // as `m::f`), clause-merge dedup (a 2-clause `f/1`->one Function), - // `record_decl`->Struct + Field children, `-define`->Constant, - // `type_alias`/`opaque`->TypeAlias, `-include`->Import + file edge, local - // `call`->Calls and remote `mod:f()`->Calls `mod::f`, `fun f/1` value and - // `#state{}` usage->References (not calls). The `-spec`/`-callback` and - // record-field type-position `call` nodes mint NO bogus type call refs. The - // `-behaviour`, gen_server, spawn/apply MFA, and `.app` resource-tuple - // bridges are DEFERRED, so `other::h` (other module absent) stays unresolved. - let tempdir = TestDir::new("generated-golden-erlang"); - write_golden(&erlang_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&erlang_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn erlang_db_is_self_equivalent_to_erlang_golden() { - assert_equivalent(&erlang_db(), &erlang_golden_dir()).unwrap(); -} - -#[test] -fn generated_golden_matches_committed_cfml_fixture() { - // Guards CFML extraction (upstream #1153, scope-B extraction slice): the - // `.cfc`/`.cfm`/`.cfs`->Cfml mapping, the dual-grammar dialect switch - // (`is_bare_script_cfml`: cfscript for bare-script, cfml tag grammar for - // tag files), a bare-script `component`->Class named from the FILE (unnamed - // in the grammar) + script-style `extends`->Extends, a tag - // ``->Class from `name` attr + ``->Method with - // access/returntype + tag `extends`->Extends. Both extends resolve to - // `Base.cfc`; `helper()` stays unresolved. The ``-in-tag delegation - // + cfquery SQL-body extraction + framework resolvers are DEFERRED. - let tempdir = TestDir::new("generated-golden-cfml"); - write_golden(&cfml_db(), tempdir.path()).unwrap(); - - let expected = load_golden(&cfml_golden_dir()).unwrap(); - let actual = load_golden(tempdir.path()).unwrap(); - - diff_canonical(&expected, &actual, None).unwrap(); -} - -#[test] -fn cfml_db_is_self_equivalent_to_cfml_golden() { - assert_equivalent(&cfml_db(), &cfml_golden_dir()).unwrap(); +/// One pair of byte-drift tests per committed golden corpus. Each corpus's +/// docs/equivalence.md section says what it guards; the regeneration recipe is +/// shared (`scripts/regen-goldens.sh`). The first test regenerates the canonical +/// artifacts from the committed `colby.db` and compares them with the committed +/// JSON; the second runs the full oracle over the same pair. +macro_rules! golden_corpora { + ($($corpus:literal => $generated:ident, $equivalent:ident;)+) => { + /// Every corpus with a committed `colby.db`, in declaration order. + const GOLDEN_CORPORA: &[&str] = &[$($corpus),+]; + + $( + #[test] + fn $generated() { + let tempdir = TestDir::new(concat!("generated-golden-", $corpus)); + write_golden(&corpus_db($corpus), tempdir.path()).unwrap(); + + let expected = load_golden(&corpus_golden_dir($corpus)).unwrap(); + let actual = load_golden(tempdir.path()).unwrap(); + + diff_canonical(&expected, &actual, None).unwrap(); + } + + #[test] + fn $equivalent() { + assert_equivalent(&corpus_db($corpus), &corpus_golden_dir($corpus)).unwrap(); + } + )+ + }; +} + +golden_corpora! { + "mini" => generated_golden_matches_committed_mini_fixture, upstream_db_is_self_equivalent_to_mini_golden; + "godot" => generated_golden_matches_committed_godot_fixture, upstream_db_is_self_equivalent_to_godot_golden; + "ruby" => generated_golden_matches_committed_ruby_fixture, upstream_db_is_self_equivalent_to_ruby_golden; + "cpp" => generated_golden_matches_committed_cpp_fixture, cpp_db_is_self_equivalent_to_cpp_golden; + "rust" => generated_golden_matches_committed_rust_fixture, rust_db_is_self_equivalent_to_rust_golden; + "lua" => generated_golden_matches_committed_lua_fixture, lua_db_is_self_equivalent_to_lua_golden; + "go" => generated_golden_matches_committed_go_fixture, go_db_is_self_equivalent_to_go_golden; + "python" => generated_golden_matches_committed_python_fixture, python_db_is_self_equivalent_to_python_golden; + "kotlin" => generated_golden_matches_committed_kotlin_fixture, kotlin_db_is_self_equivalent_to_kotlin_golden; + "scala" => generated_golden_matches_committed_scala_fixture, scala_db_is_self_equivalent_to_scala_golden; + "dart" => generated_golden_matches_committed_dart_fixture, dart_db_is_self_equivalent_to_dart_golden; + "typescript" => generated_golden_matches_committed_typescript_fixture, typescript_db_is_self_equivalent_to_typescript_golden; + "metal" => generated_golden_matches_committed_metal_fixture, metal_db_is_self_equivalent_to_metal_golden; + "cuda" => generated_golden_matches_committed_cuda_fixture, cuda_db_is_self_equivalent_to_cuda_golden; + "arkts" => generated_golden_matches_committed_arkts_fixture, arkts_db_is_self_equivalent_to_arkts_golden; + "solidity" => generated_golden_matches_committed_solidity_fixture, solidity_db_is_self_equivalent_to_solidity_golden; + "nix" => generated_golden_matches_committed_nix_fixture, nix_db_is_self_equivalent_to_nix_golden; + "terraform" => generated_golden_matches_committed_terraform_fixture, terraform_db_is_self_equivalent_to_terraform_golden; + "erlang" => generated_golden_matches_committed_erlang_fixture, erlang_db_is_self_equivalent_to_erlang_golden; + "cfml" => generated_golden_matches_committed_cfml_fixture, cfml_db_is_self_equivalent_to_cfml_golden; + "csharp" => generated_golden_matches_committed_csharp_fixture, csharp_db_is_self_equivalent_to_csharp_golden; + "java" => generated_golden_matches_committed_java_fixture, java_db_is_self_equivalent_to_java_golden; + "swift" => generated_golden_matches_committed_swift_fixture, swift_db_is_self_equivalent_to_swift_golden; + "objc" => generated_golden_matches_committed_objc_fixture, objc_db_is_self_equivalent_to_objc_golden; + "php" => generated_golden_matches_committed_php_fixture, php_db_is_self_equivalent_to_php_golden; + "vue" => generated_golden_matches_committed_vue_fixture, vue_db_is_self_equivalent_to_vue_golden; + "svelte" => generated_golden_matches_committed_svelte_fixture, svelte_db_is_self_equivalent_to_svelte_golden; + "python_bases" => generated_golden_matches_committed_python_bases_fixture, python_bases_db_is_self_equivalent_to_python_bases_golden; + "commonjs" => generated_golden_matches_committed_commonjs_fixture, commonjs_db_is_self_equivalent_to_commonjs_golden; + "routers" => generated_golden_matches_committed_routers_fixture, routers_db_is_self_equivalent_to_routers_golden; + "servers" => generated_golden_matches_committed_servers_fixture, servers_db_is_self_equivalent_to_servers_golden; + "mobile" => generated_golden_matches_committed_mobile_fixture, mobile_db_is_self_equivalent_to_mobile_golden; + "synthesis" => generated_golden_matches_committed_synthesis_fixture, synthesis_db_is_self_equivalent_to_synthesis_golden; +} + +#[test] +fn every_committed_golden_corpus_has_its_test_pair() { + let mut found: Vec = std::fs::read_dir(workspace_root().join("reference/golden")) + .unwrap() + .filter_map(|entry| { + let entry = entry.unwrap(); + entry + .path() + .join("colby.db") + .is_file() + .then(|| entry.file_name().into_string().unwrap()) + }) + .collect(); + found.sort(); + let mut listed: Vec = GOLDEN_CORPORA.iter().map(|c| (*c).to_string()).collect(); + listed.sort(); + assert_eq!( + found, listed, + "every reference/golden//colby.db must be listed in golden_corpora!" + ); } #[test] @@ -467,7 +161,6 @@ fn tier2_edges_are_order_independent_but_counted() { .any(|entry| entry.tier == Tier::Tier2 && entry.surface == "edges") ); } - fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -476,164 +169,23 @@ fn workspace_root() -> PathBuf { .to_path_buf() } -fn mini_db() -> PathBuf { - workspace_root().join("reference/golden/mini/colby.db") -} - -fn mini_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/mini") -} - -fn godot_db() -> PathBuf { - workspace_root().join("reference/golden/godot/colby.db") -} - -fn godot_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/godot") -} - -fn ruby_db() -> PathBuf { - workspace_root().join("reference/golden/ruby/colby.db") -} - -fn ruby_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/ruby") -} - -fn cpp_db() -> PathBuf { - workspace_root().join("reference/golden/cpp/colby.db") -} - -fn cpp_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/cpp") -} - -fn rust_db() -> PathBuf { - workspace_root().join("reference/golden/rust/colby.db") -} - -fn rust_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/rust") -} - -fn lua_db() -> PathBuf { - workspace_root().join("reference/golden/lua/colby.db") -} - -fn lua_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/lua") -} - -fn go_db() -> PathBuf { - workspace_root().join("reference/golden/go/colby.db") -} - -fn go_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/go") -} - -fn python_db() -> PathBuf { - workspace_root().join("reference/golden/python/colby.db") -} - -fn python_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/python") +fn corpus_db(corpus: &str) -> PathBuf { + workspace_root() + .join("reference/golden") + .join(corpus) + .join("colby.db") } -fn kotlin_db() -> PathBuf { - workspace_root().join("reference/golden/kotlin/colby.db") +fn corpus_golden_dir(corpus: &str) -> PathBuf { + workspace_root().join("reference/golden").join(corpus) } -fn kotlin_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/kotlin") -} - -fn scala_db() -> PathBuf { - workspace_root().join("reference/golden/scala/colby.db") -} - -fn scala_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/scala") -} - -fn dart_db() -> PathBuf { - workspace_root().join("reference/golden/dart/colby.db") -} - -fn dart_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/dart") -} - -fn typescript_db() -> PathBuf { - workspace_root().join("reference/golden/typescript/colby.db") -} - -fn typescript_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/typescript") -} - -fn metal_db() -> PathBuf { - workspace_root().join("reference/golden/metal/colby.db") -} - -fn metal_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/metal") -} - -fn cuda_db() -> PathBuf { - workspace_root().join("reference/golden/cuda/colby.db") -} - -fn cuda_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/cuda") -} - -fn arkts_db() -> PathBuf { - workspace_root().join("reference/golden/arkts/colby.db") -} - -fn arkts_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/arkts") -} - -fn solidity_db() -> PathBuf { - workspace_root().join("reference/golden/solidity/colby.db") -} - -fn solidity_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/solidity") -} - -fn nix_db() -> PathBuf { - workspace_root().join("reference/golden/nix/colby.db") -} - -fn nix_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/nix") -} - -fn terraform_db() -> PathBuf { - workspace_root().join("reference/golden/terraform/colby.db") -} - -fn terraform_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/terraform") -} - -fn erlang_db() -> PathBuf { - workspace_root().join("reference/golden/erlang/colby.db") -} - -fn erlang_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/erlang") -} - -fn cfml_db() -> PathBuf { - workspace_root().join("reference/golden/cfml/colby.db") +fn mini_db() -> PathBuf { + corpus_db("mini") } -fn cfml_golden_dir() -> PathBuf { - workspace_root().join("reference/golden/cfml") +fn mini_golden_dir() -> PathBuf { + corpus_golden_dir("mini") } struct TestDir { diff --git a/crates/codegraph-cli/Cargo.toml b/crates/codegraph-cli/Cargo.toml index 17876ad6..5a7556a7 100644 --- a/crates/codegraph-cli/Cargo.toml +++ b/crates/codegraph-cli/Cargo.toml @@ -56,6 +56,8 @@ codegraph-bench = { path = "../codegraph-bench" } # while this package's test targets are built. A normal binary build uses the # feature-free dependency declared above. codegraph-store = { path = "../codegraph-store", features = ["test-hooks"] } +# Same for the daemon's hello-version and control-frame seams (daemon_handover.rs). +codegraph-daemon = { path = "../codegraph-daemon", features = ["test-hooks"] } rstest = { workspace = true } assert_fs = { workspace = true } assert_cmd = { workspace = true } diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index 564bc482..b34e1372 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -22,12 +22,13 @@ use codegraph_core::generated_header::detect_generated_file; use codegraph_core::logger::{LoggerConfig, init_logger}; use codegraph_core::node_id::hash_content; use codegraph_core::types::{Edge, ExtractionResult, FileRecord, Language, Node, NodeKind}; -use codegraph_extract::{ExtractOptions, detect_language_with}; +use codegraph_extract::{ExtractOptions, detect_language_of}; use codegraph_graph::graph::{GodotReach, GraphTraverser, group_definitions}; use codegraph_graph::query::{SearchOptions, search_nodes}; use codegraph_graph::{segment_match, segments}; use codegraph_mcp::{McpServer, RunUntilAdoption}; use codegraph_resolve::ReferenceResolver; +use codegraph_resolve::module_surface::ModuleSurface; use codegraph_store::queries::SearchResult; use codegraph_store::{CorruptReason, ExtractionStatus, IndexLease, SlotOutcome, Store}; use diagnostics::{DiagnosticArgs, DiagnosticRun, IndexTracker}; @@ -1193,6 +1194,15 @@ fn self_update_rate_limit_hint() -> &'static str { "if this is a GitHub API rate limit, authenticate with:\n GITHUB_TOKEN=$(gh auth token) codegraph self-update" } +/// The newest release's version in a `get_latest_release` listing (self_update +/// 1.x returns the listing, newest first, where 0.x returned the release). +fn newest_release_version(releases: &self_update::Releases) -> Result { + releases + .latest() + .map(|release| release.version().to_string()) + .context("the GitHub release listing holds no release") +} + fn cmd_self_update(check: bool, force: bool, tag: Option) -> Result<()> { use self_update::cargo_crate_version; @@ -1223,9 +1233,10 @@ fn cmd_self_update(check: bool, force: bool, tag: Option) -> Result<()> self_update_rate_limit_hint() ) })?; + let latest = newest_release_version(&latest)?; let current = cargo_crate_version!(); - if self_update::version::bump_is_greater(current, &latest.version).unwrap_or(false) { - println!("codegraph {current} -> {} available", latest.version); + if self_update::version::bump_is_greater(current, &latest).unwrap_or(false) { + println!("codegraph {current} -> {latest} available"); println!("run `codegraph self-update` to install it"); } else { println!("codegraph {current} is up to date"); @@ -1253,17 +1264,18 @@ fn cmd_self_update(check: bool, force: bool, tag: Option) -> Result<()> self_update_rate_limit_hint() ) })?; + let latest = newest_release_version(&latest)?; let current = cargo_crate_version!(); - if should_skip_update(current, &latest.version, force, false) { + if should_skip_update(current, &latest, force, false) { println!("codegraph {current} is already up to date"); return Ok(()); } - latest_update_tag(&latest.version) + latest_update_tag(&latest) } }; let mut builder = configure(); - builder.target_version_tag(&target_tag); + builder.release_tag(target_tag.as_str()); let updater = builder .build() .context("configuring the self-update backend")?; @@ -1274,7 +1286,7 @@ fn cmd_self_update(check: bool, force: bool, tag: Option) -> Result<()> self_update_rate_limit_hint() ) })?; - if status.updated() { + if status.is_updated() { println!("Updated codegraph to {}", status.version()); } else { println!("codegraph {} is already up to date", status.version()); @@ -1668,6 +1680,9 @@ fn drain_project_daemon(project: &Path, project_identity: &str) -> Result<(), St Ok(codegraph_daemon::ShutdownOutcome::Unresponsive { pid, detail }) => Err(format!( "daemon {pid} did not acknowledge the shutdown control frame ({detail})" )), + Ok(codegraph_daemon::ShutdownOutcome::Declined { pid, detail }) => Err(format!( + "daemon {pid} was not asked to shut down ({detail})" + )), Err(error) => Err(format!("could not reach this project's daemon: {error:#}")), } } @@ -1789,36 +1804,61 @@ fn cmd_sync(path: Option, quiet: bool, diagnostics: DiagnosticArgs) -> })?; finish_phase(&bar, "Synced files"); diagnostic_run.relocate_to_index_root(paths.current_root()); - if let Some(sink) = &diagnostic_sink - && let Ok(store) = open_store(&project) - { - for relative in &outcome.changed_paths { - let record = store.file_by_path(relative).ok().flatten(); + let emit_file_complete = + |sink: &diagnostics::DiagnosticSink, + relative: &str, + record: Option<&codegraph_core::types::FileRecord>| { sink.emit( "file_complete", json!({ "file": relative, "status": if record.is_some() { "reindexed" } else { "removed" }, - "language": record.as_ref().map(|file| file.language.to_string()), - "sizeBytes": record.as_ref().map(|file| file.size), - "nodes": record.as_ref().map(|file| file.node_count), + "language": record.map(|file| file.language.to_string()), + "sizeBytes": record.map(|file| file.size), + "nodes": record.map(|file| file.node_count), "edges": serde_json::Value::Null, "references": serde_json::Value::Null, - "errors": record.as_ref().map(|file| file.errors.len()), + "errors": record.map(|file| file.errors.len()), }), ); - } - } + }; let mut sync_warnings = Vec::new(); - if let Ok(store) = open_store(&project) { - for relative in &outcome.changed_paths { - if let Some(record) = store.file_by_path(relative)? { - sync_warnings.extend( - record - .errors - .into_iter() - .filter(|error| codegraph_extract::is_extraction_warning(error)), - ); + if let Some(records) = &outcome.changed_records { + // The rows the sync left, handed back by its writer: no second open + // of the index, which reads the whole database (W5-09d). + if let Some(sink) = &diagnostic_sink { + for (relative, record) in records { + emit_file_complete(sink, relative, record.as_ref()); + } + } + for record in records.iter().filter_map(|(_, record)| record.as_ref()) { + sync_warnings.extend( + record + .errors + .iter() + .filter(|error| codegraph_extract::is_extraction_warning(error)) + .cloned(), + ); + } + } else { + if let Some(sink) = &diagnostic_sink + && let Ok(store) = open_store(&project) + { + for relative in &outcome.changed_paths { + let record = store.file_by_path(relative).ok().flatten(); + emit_file_complete(sink, relative, record.as_ref()); + } + } + if let Ok(store) = open_store(&project) { + for relative in &outcome.changed_paths { + if let Some(record) = store.file_by_path(relative)? { + sync_warnings.extend( + record + .errors + .into_iter() + .filter(|error| codegraph_extract::is_extraction_warning(error)), + ); + } } } } @@ -2128,6 +2168,9 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { .is_none_or(|v| v < codegraph_store::CURRENT_EXTRACTION_VERSION); let resolution_incomplete = store.is_resolution_incomplete()?; let pending = codegraph_watch::pending_project_changes(&project, &store)?; + // Files whose content is current but whose symbols are missing: the content + // hash behind "up to date" cannot see them (upstream #2336). + let health = FilesMissingSymbols::of(&store)?; if json_output { let mut index_obj = json!({ @@ -2135,6 +2178,8 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { "builtWithExtractionVersion": built_with_extraction_version, "currentExtractionVersion": codegraph_store::CURRENT_EXTRACTION_VERSION, "reindexRecommended": reindex_recommended, + "filesNeedingReindex": health.needs_reindex.len(), + "filesWithParseErrors": health.parse_errors.len(), }); // #1187: surface the interrupted-index state ONLY when the marker is set, // so a healthy index's status JSON is byte-identical to a pre-#1187 build. @@ -2223,6 +2268,13 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { } } } + let missing_symbols = health.warnings(); + if !missing_symbols.is_empty() { + println!(); + for line in &missing_symbols { + println!("⚠ {line}"); + } + } if resolution_incomplete { println!( "\n⚠ Index is PARTIAL: a resolution pass was interrupted, so some call\n edges are missing. Run `codegraph sync` to heal it.\n" @@ -2232,12 +2284,87 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { "\nIndex has {} pending source change(s). Run `codegraph sync`.\n", pending.total() ); - } else { + } else if missing_symbols.is_empty() { println!("\nIndex is up to date\n"); + } else { + println!(); } Ok(()) } +/// Indexed files whose content is current but whose symbols are missing, +/// grouped by what to do about them (upstream #2336). Paths are sorted. +struct FilesMissingSymbols { + needs_reindex: Vec, + parse_errors: Vec, +} + +impl FilesMissingSymbols { + fn of(store: &Store) -> Result { + let mut health = Self { + needs_reindex: Vec::new(), + parse_errors: Vec::new(), + }; + for file in store.files_without_nodes_or_with_errors()? { + match codegraph_extract::engine::missing_symbols(&file) { + Some(codegraph_extract::engine::MissingSymbols::NeedsReindex) => { + health.needs_reindex.push(file.path); + } + Some(codegraph_extract::engine::MissingSymbols::ParseError) => { + health.parse_errors.push(file.path); + } + None => {} + } + } + Ok(health) + } + + /// One line per non-empty group: what is wrong, what to do, and up to + /// three of the files. A hash-based `sync` does not revisit a row whose + /// content is current, so a wiped row needs `codegraph index`. + fn warnings(&self) -> Vec { + let sample = |paths: &[String]| { + let mut text = paths.iter().take(3).cloned().collect::>().join(", "); + if paths.len() > 3 { + text.push_str(&format!( + " (+{} more)", + format_number(paths.len() as i64 - 3) + )); + } + text + }; + let mut lines = Vec::new(); + let reindex = self.needs_reindex.len(); + if reindex > 0 { + lines.push(format!( + "{} {} symbols — run `codegraph index` to rebuild {}: {}", + format_number(reindex as i64), + if reindex == 1 { + "file is missing its" + } else { + "files are missing their" + }, + if reindex == 1 { "it" } else { "them" }, + sample(&self.needs_reindex) + )); + } + let unparsed = self.parse_errors.len(); + if unparsed > 0 { + lines.push(format!( + "{} {} missing: {} — `codegraph files --json` shows the errors", + format_number(unparsed as i64), + if unparsed == 1 { + "file could not be parsed, so its symbols are" + } else { + "files could not be parsed, so their symbols are" + }, + sample(&self.parse_errors) + )); + } + lines + } +} + fn print_wal_status(wal_size: u64, db_size: u64) { if wal_size > 0 { println!(" WAL Size: {:.2} MB", wal_size as f64 / 1024.0 / 1024.0); @@ -3760,7 +3887,7 @@ fn start_daemon_for_adopted_root(project_root: &Path, no_watch: bool) -> Option< socket_path = %socket_path.display(), "adopted-root: spawned new daemon" ); - socket_path.exists().then_some(socket_path) + codegraph_daemon::rendezvous_accepts(&socket_path).then_some(socket_path) } Err(err) => { tracing::warn!(error = %err, "adopted project daemon start failed"); @@ -3879,9 +4006,10 @@ pub enum ColdStartAction { /// A daemon is ALREADY running: attach to it via the real proxy (the proxy /// answers `initialize`/`tools/list` locally and forwards tool calls). Fast. ProxyToRunningDaemon, - /// COLD start (no live daemon): spawn the shared daemon FIRE-AND-FORGET for - /// the NEXT session's warm attach, but serve THIS session DIRECT immediately - /// so the MCP handshake is answered without waiting on daemon readiness. + /// COLD start (no live daemon): spawn the shared daemon FIRE-AND-FORGET, serve + /// THIS session DIRECT immediately so the MCP handshake is answered without + /// waiting on daemon readiness, and retain a passive lease on the daemon in + /// the background so it stays alive for this session. SpawnDaemonAndServeDirect, } @@ -3903,6 +4031,8 @@ pub fn cold_start_action(daemon_running: bool) -> ColdStartAction { /// handshake without blocking on daemon socket readiness. This is the fix for /// the cold-start handshake race (opencode marking codegraph `failed` when the /// spawn→poll→proxy→heal prelude exceeded its MCP init timeout under load). +/// A background keeper then retains a passive lease on that daemon, so it does +/// not idle-exit while this session lives (upstream #2293). fn serve_spawn_or_proxy( project: Option, project_root: &Path, @@ -3916,8 +4046,24 @@ fn serve_spawn_or_proxy( match cold_start_action(daemon_already_running(project_root)) { ColdStartAction::ProxyToRunningDaemon => { tracing::debug!("serve_spawn_or_proxy: attaching to existing daemon (warm)"); - if let Some(result) = proxy_to_running_daemon(project_root) { - return result; + match proxy_to_running_daemon(project_root) { + WarmAttach::Proxied(result) => return result, + WarmAttach::VersionMismatch => match replace_older_daemon(project_root) { + DaemonReplacement::Drained => { + return serve_after_replacement( + project, + project_root, + no_watch, + explicit_path, + ); + } + DaemonReplacement::Kept(reason) => { + // stdout carries JSON-RPC only; MCP hosts log stderr, so a + // session that quietly stopped syncing is visible there. + eprintln!("[CodeGraph MCP] {READ_ONLY_SESSION_NOTICE}: {reason}."); + } + }, + WarmAttach::Unavailable => {} } // A failed transport attach does not grant a second watcher. Keep the // request path available as a read-only direct session while the live @@ -3925,41 +4071,95 @@ fn serve_spawn_or_proxy( serve_direct_stdio(project, explicit_project_services(no_watch)) } ColdStartAction::SpawnDaemonAndServeDirect => { - // Cold: kick off the shared daemon for FUTURE sessions, then serve - // this session direct immediately. The spawn is best-effort and - // idempotent — `start_or_attach` (via the daemon's pid lock) makes N - // concurrent cold sessions converge on at most one daemon, and a - // failed/lost race just means this session serves direct (which it - // does anyway). We deliberately do NOT poll or proxy here: that - // prelude is exactly what blew past opencode's handshake timeout. - if spawn_shared_daemon_best_effort(project_root, no_watch) { - // The detached daemon is the sole writer. This foreground process - // exists only to answer the first MCP handshake without waiting for - // socket readiness; starting another watcher/catch-up here recreated - // the exact dual-writer race #1740 guards against. - serve_direct_stdio(project, explicit_project_services(no_watch)) - } else { - // If the child could not even be spawned, preserve the established - // in-process fallback. It takes writer.pid itself, so two fallback - // processes still cannot run competing watchers. - serve_direct(project, project_root, no_watch, explicit_path) - } + serve_cold_start(project, project_root, no_watch, explicit_path) } } } -/// Attach to an ALREADY-running shared daemon via the real proxy. Returns -/// `Some(Ok(()))` when the proxy bridged the session (caller must NOT also serve -/// direct), or `None` when it could not attach (socket gone, version mismatch, -/// or connect error) — the caller then falls back to direct serving. Unchanged -/// proxy semantics: `run_proxy` answers `initialize`/`tools/list` locally and +/// The stderr notice of a session that serves reads while another process owns +/// the project's writer slot (upstream's `readOnlyFallback`). +const READ_ONLY_SESSION_NOTICE: &str = "Serving reads in-process without auto-sync"; + +/// Cold: kick off the shared daemon, then serve this session direct immediately. +/// The spawn is best-effort and idempotent — `start_or_attach` (via the daemon's +/// pid lock) makes N concurrent cold sessions converge on at most one daemon, +/// and a failed/lost race just means this session serves direct (which it does +/// anyway). We deliberately do NOT poll or proxy here: that prelude is exactly +/// what blew past opencode's handshake timeout. +fn serve_cold_start( + project: Option, + project_root: &Path, + no_watch: bool, + explicit_path: bool, +) -> Result<()> { + if spawn_shared_daemon_best_effort(project_root, no_watch) { + // The detached daemon is the sole writer. This foreground process exists + // only to answer the first MCP handshake without waiting for socket + // readiness; starting another watcher/catch-up here recreated the exact + // dual-writer race #1740 guards against. + // + // The daemon is also what keeps this session's index live, and a daemon + // with no client idle-exits. Once its socket is up, the keeper retains a + // passive lease for the life of the session and, if that lease is lost, + // starts or re-attaches a daemon with backoff (upstream #2293). It runs + // on its own thread, so the handshake below still never waits on the + // daemon. + let _lease_keeper = + codegraph_daemon::keep_session_attached(project_root.to_path_buf(), no_watch); + serve_direct_stdio(project, explicit_project_services(no_watch)) + } else { + // If the child could not even be spawned, preserve the established + // in-process fallback. It takes writer.pid itself, so two fallback + // processes still cannot run competing watchers. + serve_direct(project, project_root, no_watch, explicit_path) + } +} + +/// After an older install's daemon drained: start this install's daemon and +/// proxy to it once its socket is up within the usual poll window; a slower +/// start is served like a cold start, which attaches as soon as it can. +fn serve_after_replacement( + project: Option, + project_root: &Path, + no_watch: bool, + explicit_path: bool, +) -> Result<()> { + if !spawn_shared_daemon_best_effort(project_root, no_watch) { + return serve_direct(project, project_root, no_watch, explicit_path); + } + poll_for_daemon_socket(project_root); + if let WarmAttach::Proxied(result) = proxy_to_running_daemon(project_root) { + return result; + } + let _lease_keeper = + codegraph_daemon::keep_session_attached(project_root.to_path_buf(), no_watch); + serve_direct_stdio(project, explicit_project_services(no_watch)) +} + +/// What attaching to a running daemon came to. +enum WarmAttach { + /// The proxy bridged the session until one side closed. + Proxied(Result<()>), + /// The daemon is another build: its hello did not match this one. + VersionMismatch, + /// No usable socket, or the attach failed. + Unavailable, +} + +/// Attach to an ALREADY-running shared daemon via the real proxy. On +/// [`WarmAttach::Proxied`] the caller must NOT also serve direct; otherwise it +/// may replace an older daemon or fall back to direct serving. Unchanged proxy +/// semantics: `run_proxy` answers `initialize`/`tools/list` locally and /// forwards tool calls; its fd half-close / ppid-watchdog teardown is untouched. -fn proxy_to_running_daemon(project_root: &Path) -> Option> { - let socket_path = codegraph_daemon::recorded_socket_path(project_root).ok()?; - if !socket_path.exists() { +/// A mismatch is reported before `run_proxy` reads any client input. +fn proxy_to_running_daemon(project_root: &Path) -> WarmAttach { + let Ok(socket_path) = codegraph_daemon::recorded_socket_path(project_root) else { + return WarmAttach::Unavailable; + }; + if !codegraph_daemon::rendezvous_accepts(&socket_path) { tracing::debug!("proxy_to_running_daemon: daemon socket missing; falling back to direct"); heal_stale_daemon_if_dead(project_root); - return None; + return WarmAttach::Unavailable; } let host_ppid = Some(codegraph_daemon::current_ppid()); @@ -3970,18 +4170,106 @@ fn proxy_to_running_daemon(project_root: &Path) -> Option> { BufReader::new(stdin.lock()), io::stdout(), ) { - Ok(codegraph_daemon::ProxyOutcome::Proxied) => Some(Ok(())), + Ok(codegraph_daemon::ProxyOutcome::Proxied) => WarmAttach::Proxied(Ok(())), Ok(codegraph_daemon::ProxyOutcome::VersionMismatch) => { - tracing::debug!( - "proxy_to_running_daemon: daemon version mismatch; falling back to direct" - ); - None + tracing::debug!("proxy_to_running_daemon: daemon version mismatch"); + WarmAttach::VersionMismatch } Err(err) => { tracing::debug!(error = %err, "proxy_to_running_daemon: proxy attach failed; falling back to direct"); heal_stale_daemon_if_dead(project_root); - None + WarmAttach::Unavailable + } + } +} + +/// What a session did about a running daemon of another build. +enum DaemonReplacement { + /// The daemon of an older release drained on request; the project's + /// rendezvous and writer slot are free for this install's daemon. + Drained, + /// The daemon keeps the project, for the reason given. + Kept(String), +} + +/// Replace a running daemon only when it is an OLDER plain `X.Y.Z` release +/// (upstream #2343): ask it to drain over its control channel, which is bound +/// to this project's identity, so nothing is ever signalled by pid. A daemon of +/// a newer release, a prerelease or an unknown version is never stopped, so two +/// installs cannot take turns replacing each other's daemon, and one that does +/// not acknowledge the drain keeps the project and its writer slot. +fn replace_older_daemon(project_root: &Path) -> DaemonReplacement { + use codegraph_daemon::{DaemonPeer, ShutdownOutcome}; + + let hello = match codegraph_daemon::recorded_socket_path(project_root) + .and_then(|socket| codegraph_daemon::attach_to_daemon(&socket)) + { + Ok(client) => client.hello, + Err(error) => { + return DaemonReplacement::Kept(format!( + "the project's daemon could not be reached ({error:#})" + )); + } + }; + let version = match codegraph_daemon::classify_daemon_hello(&hello) { + DaemonPeer::OlderRelease { version } => version, + DaemonPeer::Same => { + return DaemonReplacement::Kept( + "the project's daemon is this build but did not accept the session".to_string(), + ); + } + DaemonPeer::Foreign { + version: Some(version), + } => { + return DaemonReplacement::Kept(format!( + "the project's daemon runs codegraph {version}, which codegraph {VERSION} never \ + replaces" + )); + } + DaemonPeer::Foreign { version: None } => { + return DaemonReplacement::Kept( + "the project's daemon runs an unknown codegraph version, which is never replaced" + .to_string(), + ); + } + }; + let identity = match index_paths(project_root) { + Ok(paths) => paths.project_identity().to_string(), + Err(error) => { + return DaemonReplacement::Kept(format!( + "the codegraph {version} daemon was left running: {error:#}" + )); } + }; + // The frame goes only to a daemon whose hello, on the shutdown connection + // itself, is the older release classified above: a daemon that took the + // project in between is never asked. + let classified = version.clone(); + let is_classified = move |hello: &serde_json::Value| { + matches!( + codegraph_daemon::classify_daemon_hello(hello), + DaemonPeer::OlderRelease { version } if version == classified + ) + }; + match codegraph_daemon::request_daemon_shutdown_of(project_root, &identity, is_classified) { + Ok(ShutdownOutcome::Drained { pid }) => { + tracing::info!( + pid, + %version, + "replaced the daemon an older codegraph install left running" + ); + DaemonReplacement::Drained + } + Ok(ShutdownOutcome::NoDaemon) => DaemonReplacement::Drained, + Ok(ShutdownOutcome::Unresponsive { pid, detail }) => DaemonReplacement::Kept(format!( + "the codegraph {version} daemon (pid {pid}) did not drain when asked ({detail})" + )), + Ok(ShutdownOutcome::Declined { pid, detail }) => DaemonReplacement::Kept(format!( + "the project's daemon (pid {pid}) is no longer the codegraph {version} one ({detail})" + )), + Err(error) => DaemonReplacement::Kept(format!( + "the codegraph {version} daemon could not be asked to drain ({error:#})" + )), } } @@ -4039,7 +4327,8 @@ fn poll_for_daemon_socket(project_root: &Path) { // Re-read the lock each tick: the daemon rewrites the recorded socket to // its bind-fallback choice during startup, so the path can change while // we poll (D-Daemon-b). - if codegraph_daemon::recorded_socket_path(project_root).is_ok_and(|socket| socket.exists()) + if codegraph_daemon::recorded_socket_path(project_root) + .is_ok_and(|socket| codegraph_daemon::rendezvous_accepts(&socket)) { return; } @@ -5442,7 +5731,54 @@ fn index_project_inner( let parse_started = std::time::Instant::now(); // `None` for an MPEG transport stream named `.ts`: video, not source (#1910). - type ParsePayload = Option<(String, FileRecord, ExtractionResult)>; + // A JavaScript-family module's surface rides along for the consumer to + // record once every file row is stored (W4a-F3), and so do what a C/C++ + // file holds for its namespace row (W4-CPP-07) and a Java file's head row + // (W4-JVM-01). + type ParsePayload = Option<( + String, + FileRecord, + ExtractionResult, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + )>; + let mut surfaces: Vec<(String, ModuleSurface)> = Vec::new(); + let mut namespace_scans: Vec<(String, codegraph_watch::cpp_namespace_rows::NamespaceScan)> = + Vec::new(); + let mut head_rows: Vec<(String, codegraph_resolve::java_scope::HeadRow)> = Vec::new(); + let mut kotlin_rows: Vec<(String, codegraph_resolve::kotlin_scope::KotlinSurface)> = Vec::new(); + let mut scala_rows: Vec<(String, codegraph_resolve::scala_scope::ScalaSurface)> = Vec::new(); + let mut csharp_rows: Vec<(String, codegraph_resolve::csharp_types::CsharpSurface)> = Vec::new(); + let mut member_rows: Vec<(String, codegraph_resolve::member_types::MemberSurface)> = Vec::new(); + let mut ruby_rows: Vec<(String, codegraph_resolve::ruby_scope::HeadRow)> = Vec::new(); + let mut cfml_rows: Vec<(String, codegraph_resolve::cfml_scope::HeadRow)> = Vec::new(); + let mut solidity_rows: Vec<(String, codegraph_resolve::solidity_scope::HeadRow)> = Vec::new(); + let mut swift_rows: Vec<(String, codegraph_resolve::swift_type_visibility::HeadRow)> = + Vec::new(); + let mut objc_rows: Vec<(String, codegraph_resolve::objc_scope::HeadRow)> = Vec::new(); + let mut go_rows: Vec<(String, codegraph_resolve::go_packages::HeadRow)> = Vec::new(); + let mut php_rows: Vec<(String, codegraph_resolve::php_scope::HeadRow)> = Vec::new(); + let mut class_head_rows: Vec<(String, codegraph_watch::class_heads::HeadsRow)> = Vec::new(); + let mut lua_rows: Vec<(String, codegraph_resolve::lua_scope::LuaModuleRow)> = Vec::new(); + let mut dart_rows: Vec<(String, codegraph_resolve::dart_scope::HeadRow)> = Vec::new(); + let mut dart_library_rows: Vec<(String, codegraph_resolve::dart_libraries::LibraryRow)> = + Vec::new(); let schedule_tracker = tracker.clone(); let parse_tracker = tracker.clone(); let buffer_tracker = tracker.clone(); @@ -5473,7 +5809,7 @@ fn index_project_inner( return Ok(None); }; parse_tracker.stage(index, "prepare"); - let language = detect_language_with(relative, &options.extensions); + let language = detect_language_of(relative, &source, &options.extensions); parse_tracker.file_info(index, metadata.len(), language); let result = @@ -5496,11 +5832,86 @@ fn index_project_inner( generated: detect_generated_file(relative, &hash_input), }; parse_tracker.parsed(index, &result); - Ok(Some((relative.clone(), file, result))) + let surface = codegraph_watch::module_surfaces::surface_of(relative, language, &source); + let namespace_scan = + codegraph_watch::cpp_namespace_rows::scan_of(relative, language, &source); + let head_row = codegraph_watch::java_heads::row_of(language, &source, &result.nodes); + let kotlin_row = codegraph_watch::kotlin_surfaces::row_of( + relative, + language, + &source, + &result.nodes, + ); + let scala_row = + codegraph_watch::scala_surfaces::row_of(relative, language, &source, &result.nodes); + let csharp_row = + codegraph_watch::csharp_surfaces::row_of(language, &source, &result.nodes); + let member_row = + codegraph_watch::member_surfaces::row_of(language, &source, &result.nodes); + let ruby_row = codegraph_watch::ruby_heads::row_of(language, &source, &result.nodes); + let cfml_row = + codegraph_watch::cfml_heads::row_of(relative, language, &source, &result.nodes); + let solidity_row = + codegraph_watch::solidity_heads::row_of(language, &source, &result.nodes); + let swift_row = codegraph_watch::swift_heads::row_of(language, &source, &result.nodes); + let objc_row = codegraph_watch::objc_heads::row_of(language, &source, &result.nodes); + let go_row = codegraph_watch::go_heads::row_of(language, &source, &result.nodes); + let php_row = codegraph_watch::php_heads::row_of(language, &source, &result.nodes); + let class_head_row = + codegraph_watch::class_heads::row_of(language, &source, &result.nodes); + let lua_row = codegraph_watch::lua_modules::row_of(language, &source, &result.nodes); + let dart_row = codegraph_watch::dart_heads::row_of(language, &source, &result.nodes); + let dart_library_row = codegraph_watch::dart_libraries::row_of(language, &source); + Ok(Some(( + relative.clone(), + file, + result, + surface, + namespace_scan, + head_row, + kotlin_row, + scala_row, + csharp_row, + member_row, + ruby_row, + cfml_row, + solidity_row, + swift_row, + objc_row, + go_row, + php_row, + class_head_row, + lua_row, + dart_row, + dart_library_row, + ))) }, |buffered| buffer_tracker.buffered(buffered), |index, payload| { - let Some((_relative, file, mut result)) = payload else { + let Some(( + relative, + file, + mut result, + surface, + namespace_scan, + head_row, + kotlin_row, + scala_row, + csharp_row, + member_row, + ruby_row, + cfml_row, + solidity_row, + swift_row, + objc_row, + go_row, + php_row, + class_head_row, + lua_row, + dart_row, + dart_library_row, + )) = payload + else { files_not_source += 1; persist_tracker.persisted(index); return Ok(()); @@ -5539,6 +5950,60 @@ fn index_project_inner( persist_tracker.failed(index, &error); return Err(error); } + if let Some(scan) = namespace_scan { + namespace_scans.push((relative.clone(), scan)); + } + if let Some(row) = head_row { + head_rows.push((relative.clone(), row)); + } + if let Some(row) = kotlin_row { + kotlin_rows.push((relative.clone(), row)); + } + if let Some(row) = scala_row { + scala_rows.push((relative.clone(), row)); + } + if let Some(row) = csharp_row { + csharp_rows.push((relative.clone(), row)); + } + if let Some(row) = member_row { + member_rows.push((relative.clone(), row)); + } + if let Some(row) = ruby_row { + ruby_rows.push((relative.clone(), row)); + } + if let Some(row) = cfml_row { + cfml_rows.push((relative.clone(), row)); + } + if let Some(row) = solidity_row { + solidity_rows.push((relative.clone(), row)); + } + if let Some(row) = swift_row { + swift_rows.push((relative.clone(), row)); + } + if let Some(row) = objc_row { + objc_rows.push((relative.clone(), row)); + } + if let Some(row) = go_row { + go_rows.push((relative.clone(), row)); + } + if let Some(row) = php_row { + php_rows.push((relative.clone(), row)); + } + if let Some(row) = class_head_row { + class_head_rows.push((relative.clone(), row)); + } + if let Some(row) = lua_row { + lua_rows.push((relative.clone(), row)); + } + if let Some(row) = dart_row { + dart_rows.push((relative.clone(), row)); + } + if let Some(row) = dart_library_row { + dart_library_rows.push((relative.clone(), row)); + } + if let Some(surface) = surface { + surfaces.push((relative, surface)); + } persist_tracker.persisted(index); Ok(()) }, @@ -5569,6 +6034,79 @@ fn index_project_inner( store.upsert_nodes(&pending_nodes)?; } drop(pending_nodes); + // Every file row is stored: the module-surface rows can resolve what + // their barrels forward (W4a-F3). + codegraph_watch::module_surfaces::write_index_rows( + &store, + project, + options.max_file_size, + &surfaces, + )?; + drop(surfaces); + codegraph_watch::cpp_namespace_rows::write_index_rows(&store, &mut namespace_scans)?; + drop(namespace_scans); + codegraph_watch::java_heads::write_index_rows(&store, &mut head_rows)?; + drop(head_rows); + codegraph_watch::kotlin_surfaces::write_index_rows(&store, &mut kotlin_rows)?; + drop(kotlin_rows); + codegraph_watch::scala_surfaces::write_index_rows(&store, &mut scala_rows)?; + drop(scala_rows); + codegraph_watch::csharp_surfaces::write_index_rows(&store, &mut csharp_rows)?; + drop(csharp_rows); + codegraph_watch::member_surfaces::write_index_rows(&store, &mut member_rows)?; + drop(member_rows); + codegraph_watch::ruby_heads::write_index_rows(&store, &mut ruby_rows)?; + drop(ruby_rows); + codegraph_watch::cfml_heads::write_index_rows(&store, &mut cfml_rows)?; + drop(cfml_rows); + codegraph_watch::solidity_heads::write_index_rows(&store, &mut solidity_rows)?; + codegraph_watch::shopify_themes::write_index_row(&store, project)?; + drop(solidity_rows); + codegraph_watch::swift_heads::write_index_rows(&store, &mut swift_rows)?; + drop(swift_rows); + codegraph_watch::objc_heads::write_index_rows(&store, &mut objc_rows)?; + drop(objc_rows); + codegraph_watch::go_heads::write_index_rows(&store, &mut go_rows)?; + drop(go_rows); + codegraph_watch::php_heads::write_index_rows(&store, &mut php_rows)?; + drop(php_rows); + codegraph_watch::class_heads::write_index_rows(&store, &mut class_head_rows)?; + drop(class_head_rows); + codegraph_watch::lua_modules::write_index_rows(&store, &mut lua_rows)?; + drop(lua_rows); + codegraph_watch::dart_heads::write_index_rows(&store, &mut dart_rows)?; + drop(dart_rows); + codegraph_watch::dart_libraries::write_index_rows(&store, &mut dart_library_rows)?; + drop(dart_library_rows); + codegraph_watch::dart_libraries::write_pubspec_rows(&store, project)?; + // A fresh index leaves no C/C++ (W4-CPP-04), Java (W4-JVM-01), Kotlin + // (W4-JVM-03), Scala (W4-SC-01), C# (W4-CS-01), member-walk (W4-CS-03) + // Ruby (W4-RB-01), CFML (W4-CF-01), Swift (W4-SW-01), Objective-C + // (W4-SW-04), Go (W4-GO-01), Rust receiver (W4-RS-03), PHP + // (W4-PHP-01), case-folded (W4-PHP-03), Lua (W4-LUA-01), R base-binding + // (W4-R-01), Dart head (W4-DART-01) or Dart library (W4-DART-02) + // dependent unrefreshed. + codegraph_watch::cpp_dependents::write_marker(&store)?; + codegraph_watch::java_heads::write_marker(&store)?; + codegraph_watch::kotlin_surfaces::write_marker(&store)?; + codegraph_watch::scala_surfaces::write_marker(&store)?; + codegraph_watch::csharp_surfaces::write_marker(&store)?; + codegraph_watch::member_surfaces::write_marker(&store)?; + codegraph_watch::ruby_heads::write_marker(&store)?; + codegraph_watch::cfml_heads::write_marker(&store)?; + codegraph_watch::solidity_heads::write_marker(&store)?; + codegraph_watch::shopify_themes::write_marker(&store)?; + codegraph_watch::swift_heads::write_marker(&store)?; + codegraph_watch::objc_heads::write_marker(&store)?; + codegraph_watch::go_heads::write_marker(&store)?; + codegraph_watch::rust_receivers::write_marker(&store)?; + codegraph_watch::php_heads::write_marker(&store)?; + codegraph_watch::class_heads::write_marker(&store)?; + codegraph_watch::case_folded::write_marker(&store)?; + codegraph_watch::lua_modules::write_marker(&store)?; + codegraph_watch::r_bindings::write_marker(&store)?; + codegraph_watch::dart_heads::write_marker(&store)?; + codegraph_watch::dart_libraries::write_marker(&store)?; finish_phase(&pb, "Persisted nodes"); diagnostic_run.phase_end("node_write", node_write_started.elapsed(), json!({})); @@ -5621,6 +6159,13 @@ fn index_project_inner( let context = resolver.store_context(&store); resolver.initialize(&context); } + // What resolution reads of the control files, and the frameworks it + // detected (W5-06), once every file row is stored. + codegraph_watch::control_inputs::write_index_rows( + &store, + project, + &resolver.framework_names(), + )?; if resolver.has_framework_resolvers() { let relative_files = store .all_files()? @@ -5722,6 +6267,9 @@ fn index_project_inner( framework_finalize_started.elapsed(), json!({}), ); + // Every reference is now in `unresolved_refs` or `resolved_refs` + // (W5-09e). + codegraph_watch::resolved_refs::write_marker(&store, &options, &framework_context)?; store.set_project_metadata("indexed_with_version", VERSION)?; codegraph_watch::record_followed_links(&store, &followed_links)?; if let Some(capture) = git_capture { @@ -7222,6 +7770,9 @@ struct FileOutput<'a> { language: Language, node_count: i64, size: i64, + /// The errors extraction recorded for the file (upstream #2336); empty when + /// it parsed cleanly. + errors: &'a [String], } impl<'a> From<&'a FileRecord> for FileOutput<'a> { @@ -7231,6 +7782,7 @@ impl<'a> From<&'a FileRecord> for FileOutput<'a> { language: file.language, node_count: file.node_count, size: file.size, + errors: &file.errors, } } } diff --git a/crates/codegraph-cli/tests/batch_m_outdated_migration.rs b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs index 75342c67..9a477221 100644 --- a/crates/codegraph-cli/tests/batch_m_outdated_migration.rs +++ b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs @@ -419,6 +419,1559 @@ fn incremental_sync_on_outdated_v2_forces_all_files() { .expect("canonicalize peer db"); diff_canonical(&rebuilt, &migrated, None) .expect("a forced migration must equal a fresh v2 `index --force`"); + // The module-surface rows and marker (W4a-F3) are no canonical surface, + // but the next sync reads them: a migration writes what a full index does. + let surface_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("js_module_surface") + .expect("module-surface rows") + }; + let migrated_rows = surface_rows(&paths); + assert!( + migrated_rows + .iter() + .any(|(key, _)| key == "js_module_surface_version"), + "a migration writes the module-surface marker: {migrated_rows:?}" + ); + assert_eq!( + migrated_rows, + surface_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the module-surface rows of `index --force`" + ); + // The C/C++ dependents marker (W4-CPP-04), likewise. + let cpp_marker = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("cpp_type_surface") + .expect("C/C++ dependents marker") + }; + assert_eq!( + cpp_marker(&paths), + vec![("cpp_type_surface_version".to_string(), "2".to_string())], + "a migration writes the C/C++ dependents marker" + ); + assert_eq!( + cpp_marker(&paths), + cpp_marker(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the C/C++ marker of `index --force`" + ); + // The case-folded retries' marker (W4-PHP-03), likewise. + let case_marker = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("case_folded_names") + .expect("case-folded marker") + }; + assert_eq!( + case_marker(&paths), + vec![("case_folded_names_version".to_string(), "1".to_string())], + "a migration writes the case-folded marker" + ); + assert_eq!( + case_marker(&paths), + case_marker(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the case-folded marker of `index --force`" + ); + // The Dart head marker (W4-DART-01), likewise; its rows are + // `outdated_migration_writes_the_dart_head_rows_of_index_force`'s. + let dart_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("dart_heads") + .expect("Dart head rows") + }; + assert_eq!( + dart_rows(&paths), + vec![("dart_heads_version".to_string(), "1".to_string())], + "a migration writes the Dart head marker" + ); + assert_eq!( + dart_rows(&paths), + dart_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Dart head marker of `index --force`" + ); + // The Dart library marker (W4-DART-02), likewise; its rows are + // `outdated_migration_writes_the_dart_library_rows_of_index_force`'s. + let dart_library_marker = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .get_project_metadata("dart_libraries_version") + .expect("Dart library marker") + }; + // Version 2 since the pubspec rows (W5-06). + assert_eq!( + dart_library_marker(&paths).as_deref(), + Some("2"), + "a migration writes the Dart library marker" + ); + assert_eq!( + dart_library_marker(&paths), + dart_library_marker(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Dart library marker of `index --force`" + ); + // The R base-binding marker (W4-R-01), likewise. + let r_marker = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("r_base_bindings") + .expect("R base-binding marker") + }; + assert_eq!( + r_marker(&paths), + vec![("r_base_bindings_version".to_string(), "1".to_string())], + "a migration writes the R base-binding marker" + ); + assert_eq!( + r_marker(&paths), + r_marker(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the R base-binding marker of `index --force`" + ); + // The Lua module marker (W4-LUA-01), likewise; its rows are + // `outdated_migration_writes_the_lua_module_rows_of_index_force`'s. + let lua_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("lua_module") + .expect("Lua module rows") + }; + assert_eq!( + lua_rows(&paths), + vec![("lua_modules_version".to_string(), "1".to_string())], + "a migration writes the Lua module marker" + ); + assert_eq!( + lua_rows(&paths), + lua_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Lua module marker of `index --force`" + ); + // And the C/C++ namespace rows (W4-CPP-07). + let namespace_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("cpp_namespace_surface:") + .expect("C/C++ namespace rows") + }; + assert_eq!( + namespace_rows(&paths), + namespace_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the C/C++ namespace rows of `index --force`" + ); +} + +/// W4-JVM-01: a migration writes the Java head rows and their marker a +/// fresh `index --force` writes, so the next sync compares a changed Java +/// file's heads with what it held. +#[test] +fn outdated_migration_writes_the_java_head_rows_of_index_force() { + let dir = TestDir::new("outdated-java-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "src/app/Root.java", + "package app;\n\npublic class Root {\n protected void assist() { }\n}\n", + ), + ( + "src/app/Base.java", + "package app;\n\npublic class Base extends Root implements Runnable {\n public void run() { }\n}\n", + ), + ( + "src/app/Child.java", + "package app;\n\npublic class Child extends Base {\n void go() {\n assist();\n }\n}\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-java-heads", &project); + let head_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("java_type_heads") + .expect("Java head rows") + }; + let migrated = head_rows(&paths); + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "java_type_heads:src/app/Base.java", + "java_type_heads:src/app/Child.java", + "java_type_heads_version", + ], + "a migration writes the head rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Java head rows of `index --force`" + ); +} + +/// W4-JVM-03: a migration writes the Kotlin surface rows and their marker a +/// fresh `index --force` writes. +#[test] +fn outdated_migration_writes_the_kotlin_surface_rows_of_index_force() { + let dir = TestDir::new("outdated-kotlin-surfaces"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "src/app/Root.kt", + "package app\n\nopen class Root {\n fun assist() { }\n}\n", + ), + ( + "src/app/Child.kt", + "package app\n\nclass Child : Root() {\n fun go() {\n assist()\n }\n}\n", + ), + ( + "src/app/Dsl.kt", + "package app\n\ntypealias Block = Root.() -> Unit\n\nfun configure(block: Block) { }\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-kotlin-surfaces", &project); + let surface_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("kotlin_surface") + .expect("Kotlin surface rows") + }; + let migrated = surface_rows(&paths); + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "kotlin_surface:src/app/Child.kt", + "kotlin_surface:src/app/Dsl.kt", + "kotlin_surface:src/app/Root.kt", + "kotlin_surface_version", + ], + "a migration writes the surface rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + surface_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Kotlin surface rows of `index --force`" + ); +} + +/// W4-SC-01: a migration writes the Scala surface rows and their marker a +/// fresh `index --force` writes. +#[test] +fn outdated_migration_writes_the_scala_surface_rows_of_index_force() { + let dir = TestDir::new("outdated-scala-surfaces"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "src/app/Root.scala", + "package app\n\ntrait Root {\n def assist(): Int = 1\n}\n", + ), + ( + "src/app/Child.scala", + "package app\n\nclass Child extends Root {\n def go(): Int = assist()\n}\n", + ), + ( + "src/app/api/package.scala", + "package app\n\npackage object api extends Root\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-scala-surfaces", &project); + let surface_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("scala_surface") + .expect("Scala surface rows") + }; + let migrated = surface_rows(&paths); + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "scala_surface:src/app/Child.scala", + "scala_surface:src/app/Root.scala", + "scala_surface:src/app/api/package.scala", + "scala_surface_version", + ], + "a migration writes the surface rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + surface_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Scala surface rows of `index --force`" + ); +} + +/// W4-CS-01: a migration writes the C# surface rows and their marker a fresh +/// `index --force` writes. +#[test] +fn outdated_migration_writes_the_csharp_surface_rows_of_index_force() { + let dir = TestDir::new("outdated-csharp-surfaces"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "src/Lib/Base.cs", + "namespace Lib\n{\n public class Reader\n {\n internal enum State { Start, Done }\n }\n}\n", + ), + ( + "src/Lib/Leaf.cs", + "namespace Lib\n{\n public class Leaf : Reader\n {\n object Peek() => State.Start;\n }\n}\n", + ), + ( + "src/GlobalUsings.cs", + "global using Lib;\nglobal using static Lib.Reader;\n", + ), + ( + "src/Plain.cs", + "namespace Lib\n{\n public class Plain\n {\n }\n}\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-csharp-surfaces", &project); + let surface_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("csharp_surface") + .expect("C# surface rows") + }; + let migrated = surface_rows(&paths); + // `Plain.cs` and `Base.cs` hold no head with a base and no global using. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "csharp_surface:src/GlobalUsings.cs", + "csharp_surface:src/Lib/Leaf.cs", + "csharp_surface_version", + ], + "a migration writes the surface rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + surface_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the C# surface rows of `index --force`" + ); +} + +/// W4-CS-03: a migration writes the member surface rows and their marker a +/// fresh `index --force` writes. +#[test] +fn outdated_migration_writes_the_member_surface_rows_of_index_force() { + let dir = TestDir::new("outdated-member-surfaces"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "src/Json/WriterBase.cs", + "namespace App.Json;\n\npublic abstract class WriterBase\n{\n protected JsonWriter Writer { get; set; }\n}\n", + ), + ( + "src/main/java/app/WriterBase.java", + "package app;\n\npublic abstract class WriterBase {\n protected JsonWriter writer;\n}\n", + ), + ( + "src/main/kotlin/app/WriterBase.kt", + "package app\n\nopen class WriterBase {\n protected val writer: JsonWriter = JsonWriter()\n}\n", + ), + ("src/notes.ts", "export const notes = 1;\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-member-surfaces", &project); + let surface_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("member_surface") + .expect("member surface rows") + }; + let migrated = surface_rows(&paths); + // No row for the TypeScript file: only C#, Java and Kotlin have one. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "member_surface:src/Json/WriterBase.cs", + "member_surface:src/main/java/app/WriterBase.java", + "member_surface:src/main/kotlin/app/WriterBase.kt", + "member_surface_version", + ], + "a migration writes the surface rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + surface_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the member surface rows of `index --force`" + ); +} + +/// W4-RB-01: a migration writes the Ruby head rows and their marker a fresh +/// `index --force` writes. +#[test] +fn outdated_migration_writes_the_ruby_head_rows_of_index_force() { + let dir = TestDir::new("outdated-ruby-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ("lib/base.rb", "class Base\n include Helpers\nend\n"), + ( + "lib/foo.rb", + "class Foo < Base\n def run\n helper()\n end\nend\n", + ), + ("src/notes.ts", "export const notes = 1;\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-ruby-heads", &project); + let head_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("ruby_heads") + .expect("ruby head rows") + }; + let migrated = head_rows(&paths); + // No row for the TypeScript file: only Ruby files have one. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "ruby_heads:lib/base.rb", + "ruby_heads:lib/foo.rb", + "ruby_heads_version", + ], + "a migration writes the head rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Ruby head rows of `index --force`" + ); +} + +/// W4-CF-01: a migration writes the CFML component rows and their marker a +/// fresh `index --force` writes. +#[test] +fn outdated_migration_writes_the_cfml_component_rows_of_index_force() { + let dir = TestDir::new("outdated-cfml-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "system/Base.cfc", + "component {\n function getSetting( name ) {\n return 1;\n }\n}\n", + ), + ( + "system/Handler.cfc", + "component extends=\"app.system.Base\" {\n function index() {\n return getSetting( \"x\" );\n }\n}\n", + ), + ("views/main.cfm", "#now()#\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-cfml-heads", &project); + let head_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("cfml_heads") + .expect("cfml component rows") + }; + let migrated = head_rows(&paths); + // No row for the template: only `.cfc` files have one. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "cfml_heads:system/Base.cfc", + "cfml_heads:system/Handler.cfc", + "cfml_heads_version", + ], + "a migration writes the component rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the CFML component rows of `index --force`" + ); +} + +/// W4-SOL-01: a migration writes the Solidity head rows and their marker a +/// fresh `index --force` writes. +#[test] +fn outdated_migration_writes_the_solidity_head_rows_of_index_force() { + let dir = TestDir::new("outdated-solidity-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "contracts/Context.sol", + "pragma solidity ^0.8.20;\n\nabstract contract Context {\n function _msgSender() internal view virtual returns (address) {\n return msg.sender;\n }\n}\n", + ), + ( + "contracts/Governor.sol", + "pragma solidity ^0.8.20;\n\nimport \"./Context.sol\";\n\nabstract contract Governor is Context {\n function castVote() public returns (address) {\n return _msgSender();\n }\n}\n", + ), + ( + "contracts/Free.sol", + "pragma solidity ^0.8.20;\n\nfunction helper() pure returns (uint256) {\n return 1;\n}\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-solidity-heads", &project); + let head_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("solidity_heads") + .expect("solidity head rows") + }; + let migrated = head_rows(&paths); + // No row for the file with a free function only: it declares no type. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "solidity_heads:contracts/Context.sol", + "solidity_heads:contracts/Governor.sol", + "solidity_heads_version", + ], + "a migration writes the head rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Solidity head rows of `index --force`" + ); +} + +/// W4-LIQ-01: a migration writes the Shopify theme row and its marker a +/// fresh `index --force` writes. +#[test] +fn outdated_migration_writes_the_shopify_theme_row_of_index_force() { + let dir = TestDir::new("outdated-shopify-themes"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "layout/theme.liquid", + "{% section 'main' %}{{ content_for_layout }}\n", + ), + ("sections/main.liquid", "
      \n"), + ("shop/config/settings_schema.json", "[]"), + ("shop/sections/main.liquid", "
      \n"), + ( + "shop/templates/index.json", + "{\"sections\":{\"main\":{\"type\":\"main\"}},\"order\":[\"main\"]}", + ), + ("extensions/reviews/blocks/stars.liquid", "\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-shopify-themes", &project); + let theme_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("shopify_themes") + .expect("shopify theme rows") + }; + let migrated = theme_rows(&paths); + // No root for the app extension's block: it is in no theme. + assert_eq!( + migrated, + [ + ("shopify_themes".to_string(), "[\"\",\"shop\"]".to_string()), + ("shopify_themes_version".to_string(), "1".to_string()), + ], + "a migration writes the theme row and its marker" + ); + assert_eq!( + migrated, + theme_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Shopify theme row of `index --force`" + ); +} + +/// W4-SW-01: a migration writes the Swift head rows and their marker a +/// fresh `index --force` writes. +#[test] +fn outdated_migration_writes_the_swift_head_rows_of_index_force() { + let dir = TestDir::new("outdated-swift-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "App/Card.swift", + "import SwiftUI\nextension View {\n func cardStyle() -> some View { padding() }\n}\n", + ), + ( + "App/HomeView.swift", + "import SwiftUI\nstruct HomeView: View {\n var body: some View { Text(\"x\") }\n}\n", + ), + ("App/main.swift", "print(1)\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-swift-heads", &project); + let head_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("swift_heads") + .expect("swift head rows") + }; + let migrated = head_rows(&paths); + // No row for a file that declares no type. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "swift_heads:App/Card.swift", + "swift_heads:App/HomeView.swift", + "swift_heads_version", + ], + "a migration writes the head rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Swift head rows of `index --force`" + ); +} + +/// W4-GO-01: a migration writes the Go head rows (each type declaration's +/// embeddings, mentions and digest) and their marker as `index --force` +/// does. +#[test] +fn outdated_migration_writes_the_go_head_rows_of_index_force() { + let dir = TestDir::new("outdated-go-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ("go.mod", "module example.com/app\n\ngo 1.22\n"), + ( + "h/server.go", + "package h\n\ntype server struct {\n\tBaseAPI\n\tstore *store\n}\n", + ), + ("h/run.go", "package h\n\nfunc (s *server) Run() {}\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-go-heads", &project); + let head_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("go_heads") + .expect("go head rows") + }; + let migrated = head_rows(&paths); + // No row for a file that declares no type. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + ["go_heads:h/server.go", "go_heads_version"], + "a migration writes the head rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Go head rows of `index --force`" + ); +} + +/// W4-RS-03: a migration writes the Rust receiver marker as `index --force` +/// does, so the next sync does not re-resolve every Rust file again. +#[test] +fn outdated_migration_writes_the_rust_receiver_marker_of_index_force() { + let dir = TestDir::new("outdated-rust-receivers"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ), + ( + "src/lib.rs", + "pub struct Widget;\n\npub fn run() {\n let _ = Vec::::new();\n}\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-rust-receivers", &project); + let marker = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("rust_receivers") + .expect("rust receiver marker") + }; + let migrated = marker(&paths); + assert_eq!( + migrated, + [("rust_receivers_version".to_string(), "1".to_string())], + "a migration writes the marker: {migrated:?}" + ); + assert_eq!( + migrated, + marker(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the marker of `index --force`" + ); +} + +/// W4-SW-04: a migration writes the Objective-C head rows (a class's +/// written superclass, a property's declared type) and their marker as +/// `index --force` does. +#[test] +fn outdated_migration_writes_the_objc_head_rows_of_index_force() { + let dir = TestDir::new("outdated-objc-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "Core/SDCache.h", + "@interface SDCache : SDBase\n@property (nonatomic) NSURL *url;\n@end\n", + ), + ( + "Core/SDCache.m", + "#import \"SDCache.h\"\n@implementation SDCache\n- (void)run {\n [self prepare];\n}\n@end\n", + ), + ("Core/util.m", "int twice(int x) {\n return x * 2;\n}\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-objc-heads", &project); + let head_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("objc_heads") + .expect("objc head rows") + }; + let migrated = head_rows(&paths); + // No row for a file that declares no class or property. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "objc_heads:Core/SDCache.h", + "objc_heads:Core/SDCache.m", + "objc_heads_version", + ], + "a migration writes the head rows and their marker: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Objective-C head rows of `index --force`" + ); +} + +/// W4-DART-01: a migration writes the Dart head rows (each type +/// declaration's name, whether it is an extension and the supertypes its +/// head names) and their marker as `index --force` does. +#[test] +fn outdated_migration_writes_the_dart_head_rows_of_index_force() { + let dir = TestDir::new("outdated-dart-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ("pubspec.yaml", "name: app\n"), + ( + "lib/cubit.dart", + "class Cubit extends Base with Mixed {}\n\nextension Helpers on Cubit {\n void helper() {}\n}\n", + ), + ("lib/main.dart", "void main() {}\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-dart-heads", &project); + let head_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("dart_heads") + .expect("Dart head rows") + }; + let migrated = head_rows(&paths); + // No row for a file that declares no type. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + ["dart_heads:lib/cubit.dart", "dart_heads_version"], + "a migration writes the head rows and their marker: {migrated:?}" + ); + assert!( + migrated[0].1.contains("\"Base\",\"Mixed\"") + && migrated[0].1.contains("\"extension\":true"), + "a row names the supertypes and the extension: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Dart head rows of `index --force`" + ); +} + +/// W4-DART-02: a migration writes the Dart library rows (a file's `library` +/// name, `part of` target, parts, and imports and exports with their +/// prefixes and combinators) and their marker as `index --force` does, and +/// (W5-06) the pubspec rows of the folders above the Dart files. +#[test] +fn outdated_migration_writes_the_dart_library_rows_of_index_force() { + let dir = TestDir::new("outdated-dart-libraries"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ("pubspec.yaml", "name: app\n"), + ( + "pubspec_overrides.yaml", + "dependency_overrides:\n kit:\n path: packages/kit\n", + ), + ("tools/pubspec.yaml", "name: tools\n"), + ( + "lib/app.dart", + "library app;\n\nimport 'src/util.dart' as util show helper;\nexport 'src/util.dart' hide hidden;\n\npart 'piece.dart';\n", + ), + ("lib/piece.dart", "part of 'app.dart';\n\nint run() => 1;\n"), + ( + "lib/src/util.dart", + "int helper() => 1;\nint hidden() => 2;\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-dart-libraries", &project); + let library_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("dart_librar") + .expect("Dart library rows") + }; + let pubspec_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("dart_pubspec:") + .expect("Dart pubspec rows") + }; + let migrated_pubspecs = pubspec_rows(&paths); + // One row for the root, which holds both manifests; none for a folder + // above no Dart file. + assert_eq!( + migrated_pubspecs + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + ["dart_pubspec:"], + "a migration writes the pubspec rows: {migrated_pubspecs:?}" + ); + assert!( + migrated_pubspecs[0].1.contains("name: app") + && migrated_pubspecs[0].1.contains("dependency_overrides"), + "a row records both manifests: {migrated_pubspecs:?}" + ); + assert_eq!( + migrated_pubspecs, + pubspec_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Dart pubspec rows of `index --force`" + ); + let migrated = library_rows(&paths); + // No row for a file without a directive. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "dart_libraries_version", + "dart_library:lib/app.dart", + "dart_library:lib/piece.dart" + ], + "a migration writes the library rows and their marker: {migrated:?}" + ); + assert!( + migrated[1].1.contains("\"prefix\":\"util\"") + && migrated[1].1.contains("\"show\":[\"helper\"]") + && migrated[1].1.contains("\"hide\":[\"hidden\"]") + && migrated[1].1.contains("\"parts\":[\"piece.dart\"]"), + "a row records the directives: {migrated:?}" + ); + assert_eq!( + migrated, + library_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Dart library rows of `index --force`" + ); +} + +/// W4-LUA-01: a migration writes the Lua module rows (a file's locals, the +/// modules its aliases `require`, what it reads through a global table and a +/// digest of what an alias through it reads) and their marker as `index +/// --force` does. +#[test] +fn outdated_migration_writes_the_lua_module_rows_of_index_force() { + let dir = TestDir::new("outdated-lua-modules"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "kong/pdk/phases.lua", + "local function check_phase(accepted)\n return accepted\nend\n\nreturn {\n check = check_phase,\n}\n", + ), + ( + "kong/handler.lua", + "local phases = require \"kong.pdk.phases\"\nlocal cp = phases.check\nlocal snooze = ngx.sleep\n\nlocal function run()\n cp(\"access\")\n snooze(1)\nend\n\nreturn { run = run }\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-lua-modules", &project); + let module_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("lua_module") + .expect("Lua module rows") + }; + let migrated = module_rows(&paths); + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "lua_module:kong/handler.lua", + "lua_module:kong/pdk/phases.lua", + "lua_modules_version", + ], + "a migration writes the module rows and their marker: {migrated:?}" + ); + assert!( + migrated[0].1.contains("\"kong.pdk.phases\"") && migrated[0].1.contains("\"sleep\""), + "a row names the modules its aliases require and what it reads through a \ + global table: {migrated:?}" + ); + assert_eq!( + migrated, + module_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the Lua module rows of `index --force`" + ); +} + +/// W4-PHP-01: a migration writes the PHP head rows (a type's kind and the +/// supertypes its `extends` and trait `use`s name, through its file's +/// imports) and their marker as `index --force` does. +#[test] +fn outdated_migration_writes_the_php_head_rows_of_index_force() { + let dir = TestDir::new("outdated-php-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "src/Child.php", + "prepare();\n }\n}\n", + ), + ( + "src/Greets.php", + ">(), + [ + "php_heads:src/Child.php", + "php_heads:src/Greets.php", + "php_heads_version", + ], + "a migration writes the head rows and their marker: {migrated:?}" + ); + assert!( + migrated[0].1.contains("Vendor::Base") && migrated[0].1.contains("App::Greets"), + "a row names the supertypes through the file's imports: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the PHP head rows of `index --force`" + ); +} + +/// W5-07: a migration writes the class-head rows (each type declaration's +/// language, name and the supertypes its own head writes) and their marker +/// as `index --force` does. +#[test] +fn outdated_migration_writes_the_class_head_rows_of_index_force() { + let dir = TestDir::new("outdated-class-heads"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "pkg/user.py", + "from pkg.base import Base\n\n\nclass User(Base):\n pass\n", + ), + ( + "pkg/base.py", + "class Base:\n @classmethod\n def create(cls):\n return cls()\n", + ), + ("pkg/helpers.py", "def twice(x):\n return x * 2\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-class-heads", &project); + let head_rows = |paths: &IndexPaths| { + Store::open_for_read(paths, deadline(), || false) + .expect("readable namespace") + .project_metadata_with_prefix("class_heads") + .expect("class head rows") + }; + let migrated = head_rows(&paths); + // No row for a file that declares no type. + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "class_heads:pkg/base.py", + "class_heads:pkg/user.py", + "class_heads_version", + ], + "a migration writes the head rows and their marker: {migrated:?}" + ); + assert!( + migrated[1].1.contains("\"User\"") && migrated[1].1.contains("\"Base\""), + "a row names the type and the supertype its head writes: {migrated:?}" + ); + assert_eq!( + migrated, + head_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the class-head rows of `index --force`" + ); +} + +/// W5-06: a migration writes the control-input rows (what resolution read +/// of the JS/TS configs and manifests, the C# project files and the Cargo +/// manifests), the detected frameworks and both markers as `index --force` +/// does. +#[test] +fn outdated_migration_writes_the_control_rows_of_index_force() { + let dir = TestDir::new("outdated-control-inputs"); + let project = dir.path().join("mini"); + for (path, contents) in [ + ( + "package.json", + "{\"name\":\"web\",\"dependencies\":{\"react\":\"18.2.0\"}}\n", + ), + ( + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@lib/*\":[\"src/lib/*\"]}}}\n", + ), + ( + "src/lib/math.ts", + "export function double(value: number): number {\n return value * 2;\n}\n", + ), + ( + "src/main.ts", + "import { double } from '@lib/math';\n\nexport function run(): number {\n return double(2);\n}\n", + ), + ("App/App.csproj", "\n"), + ( + "App/Program.cs", + "namespace App;\n\npublic class Program {}\n", + ), + ( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ), + ("src/lib.rs", "pub fn one() -> u32 {\n 1\n}\n"), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let (_scratch, peer) = fresh_force_peer("outdated-control-inputs", &project); + let control_rows = |paths: &IndexPaths| { + let store = Store::open_for_read(paths, deadline(), || false).expect("readable namespace"); + let mut rows = store + .project_metadata_with_prefix("control_inputs") + .expect("control rows"); + rows.extend( + store + .project_metadata_with_prefix("frameworks_") + .expect("framework rows"), + ); + rows + }; + let migrated = control_rows(&paths); + assert_eq!( + migrated + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + [ + "control_inputs:csharp", + "control_inputs:js", + "control_inputs:rust", + "control_inputs_version", + "frameworks_detected", + "frameworks_version", + ], + "a migration writes the control rows and both markers: {migrated:?}" + ); + for (row, input) in [ + (0, "\"App/App.csproj\""), + (1, "\"tsconfig.json\""), + (1, "\"package.json\""), + (2, "\"Cargo.toml\""), + (4, "\"react\""), + ] { + assert!( + migrated[row].1.contains(input), + "row {row} records {input}: {migrated:?}" + ); + } + assert_eq!( + migrated, + control_rows(&IndexPaths::resolve(&peer, None).expect("resolve peer v2 paths")), + "a forced migration must write the control rows of `index --force`" + ); } /// Migration is a from-source rebuild, so a tracked file that no longer exists diff --git a/crates/codegraph-cli/tests/cold_start_live_sync.rs b/crates/codegraph-cli/tests/cold_start_live_sync.rs new file mode 100644 index 00000000..c4fd2ac4 --- /dev/null +++ b/crates/codegraph-cli/tests/cold_start_live_sync.rs @@ -0,0 +1,338 @@ +//! A cold-start stdio session keeps the shared daemon it spawned (upstream #2293). +//! +//! A cold `serve --mcp` answers its handshake direct and spawns the shared +//! daemon fire-and-forget. The session itself holds no writer and runs no +//! watcher, so the daemon is what keeps its index live. This test drives the +//! real cold path with a one-second daemon idle timeout and asserts that: +//! +//! - the daemon outlives three idle windows while the session is open; +//! - an edit made during the session reaches `codegraph_search`; +//! - a daemon killed mid-session is started again within the retry window, and +//! the next edit is indexed by the new one; +//! - everything the session writes to stdout is JSON-RPC. +//! +//! The session is pinned with `--path` and never passes `projectPath`, so the +//! explicit-project broker (which retains its own daemon lease) stays out of +//! the picture. + +#![cfg(unix)] + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +/// The daemon's idle timeout in this test; the session must keep it alive for +/// several of these windows. +const IDLE_TIMEOUT: Duration = Duration::from_secs(1); +/// How long the session must keep the daemon alive without any tool call. +const IDLE_OBSERVATION: Duration = Duration::from_secs(3); +/// Upper bound for an edit to reach `codegraph_search`. +const EDIT_VISIBLE_WITHIN: Duration = Duration::from_secs(10); +/// Upper bound for a killed daemon to be replaced: the 200 ms retry delay set +/// below, the daemon's own startup, and slack for a loaded runner. +const RESTART_WITHIN: Duration = Duration::from_secs(10); + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-cold-live-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn write_live_source(project: &Path, symbol: &str) { + fs::write( + project.join("src/live.ts"), + format!("export function {symbol}(): number {{ return 1; }}\n"), + ) + .unwrap(); +} + +fn indexed_project(dir: &TestDir) -> PathBuf { + let project = dir.path().join("project"); + fs::create_dir_all(project.join("src")).unwrap(); + write_live_source(&project, "initialColdSessionSymbol"); + let output = Command::new(bin()) + .args(["init", project.to_str().unwrap()]) + .env_remove("CODEGRAPH_DIR") + .output() + .expect("run codegraph init"); + assert!( + output.status.success(), + "init failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + project +} + +/// A cold `serve --mcp --path ` driven over line-delimited JSON-RPC. +struct ColdSession { + child: Child, + stdin: Option, + stdout: BufReader, + stderr_log: PathBuf, + next_id: i64, +} + +impl ColdSession { + fn start(project: &Path, scratch: &Path) -> Self { + let stderr_log = scratch.join("serve.stderr.log"); + let mut child = Command::new(bin()) + .args(["serve", "--mcp", "--path"]) + .arg(project) + .env_remove("CODEGRAPH_NO_DAEMON") + .env_remove("CODEGRAPH_NO_WATCH") + .env_remove("CODEGRAPH_DIR") + .env("CODEGRAPH_MCP_REGISTRY_DIR", scratch.join("mcp-registry")) + .env("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", "1000") + .env("CODEGRAPH_DAEMON_RETRY_MS", "200") + .env("CODEGRAPH_DAEMON_RETRY_MAX_MS", "1000") + .env("CODEGRAPH_WATCH_DEBOUNCE_MS", "100") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(fs::File::create(&stderr_log).expect("create serve stderr log")) + .spawn() + .expect("spawn cold serve --mcp"); + let stdin = child.stdin.take().expect("serve stdin"); + let stdout = BufReader::new(child.stdout.take().expect("serve stdout")); + let mut session = Self { + child, + stdin: Some(stdin), + stdout, + stderr_log, + next_id: 1, + }; + let init_id = session.next_id(); + session.write(json!({ + "jsonrpc": "2.0", + "id": init_id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "cold-start-live-sync", "version": "0" } + } + })); + let response = session.read_id(init_id); + assert_eq!( + response["result"]["serverInfo"]["name"], "codegraph", + "{response}" + ); + session.write(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + session + } + + fn next_id(&mut self) -> i64 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn write(&mut self, value: Value) { + let stdin = self.stdin.as_mut().expect("serve stdin stays open"); + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + } + + /// Read until the response carrying `want`. Every stdout line must be a + /// JSON-RPC message: a log line or banner here breaks the MCP client. + fn read_id(&mut self, want: i64) -> Value { + loop { + let mut line = String::new(); + let read = self.stdout.read_line(&mut line).expect("read serve stdout"); + assert!( + read > 0, + "serve closed stdout before response {want}; stderr:\n{}", + self.stderr() + ); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value: Value = serde_json::from_str(trimmed).unwrap_or_else(|error| { + panic!("non-JSON line on serve stdout ({error}): {trimmed:?}") + }); + assert_eq!( + value["jsonrpc"], "2.0", + "non-JSON-RPC stdout line: {trimmed}" + ); + if value.get("id").and_then(Value::as_i64) == Some(want) { + return value; + } + } + } + + fn search(&mut self, query: &str) -> String { + let id = self.next_id(); + self.write(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": "codegraph_search", "arguments": { "query": query } } + })); + let response = self.read_id(id); + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string() + } + + fn wait_for_symbol(&mut self, symbol: &str, within: Duration, when: &str) { + let deadline = Instant::now() + within; + loop { + let text = self.search(symbol); + if text.contains(symbol) && text.contains("src/live.ts") { + return; + } + assert!( + Instant::now() < deadline, + "{when}: `{symbol}` was not searchable within {within:?}; last result:\n{text}\nstderr:\n{}", + self.stderr() + ); + std::thread::sleep(Duration::from_millis(100)); + } + } + + fn stderr(&self) -> String { + fs::read_to_string(&self.stderr_log).unwrap_or_default() + } + + fn close(mut self) { + drop(self.stdin.take()); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if self.child.try_wait().unwrap().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("cold serve did not exit after stdin EOF"); + } +} + +impl Drop for ColdSession { + fn drop(&mut self) { + drop(self.stdin.take()); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +fn daemon_pid(project: &Path) -> Option { + let path = codegraph_daemon::daemon_pid_path(project).ok()?; + let raw = fs::read_to_string(path).ok()?; + codegraph_daemon::decode_lock_info(&raw) + .map(|info| info.pid) + .filter(|&pid| pid > 0 && codegraph_daemon::is_process_alive(pid)) +} + +/// Wait for a live daemon owner other than `not`. +fn wait_for_daemon(project: &Path, not: Option, within: Duration, when: &str) -> u32 { + let deadline = Instant::now() + within; + loop { + if let Some(pid) = daemon_pid(project) + && Some(pid) != not + { + return pid; + } + assert!( + Instant::now() < deadline, + "{when}: no live daemon (other than {not:?}) within {within:?}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +/// Fail as soon as `pid` stops being alive during `window`. +fn assert_alive_throughout(pid: u32, window: Duration, session: &ColdSession) { + let deadline = Instant::now() + window; + while Instant::now() < deadline { + assert!( + codegraph_daemon::is_process_alive(pid), + "the daemon {pid} exited while the cold session was still open \ + (idle timeout {IDLE_TIMEOUT:?}); stderr:\n{}", + session.stderr() + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn kill_pid(pid: u32) { + let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status(); +} + +fn shutdown_daemon(project: &Path) { + let paths = codegraph_core::IndexPaths::resolve(project, None).unwrap(); + match codegraph_daemon::request_daemon_shutdown(project, paths.project_identity()) { + Ok(codegraph_daemon::ShutdownOutcome::NoDaemon) + | Ok(codegraph_daemon::ShutdownOutcome::Drained { .. }) => {} + other => panic!("failed to stop the daemon: {other:?}"), + } +} + +#[test] +fn cold_start_session_keeps_its_daemon_and_live_sync() { + let dir = TestDir::new("session"); + let project = indexed_project(&dir); + let mut session = ColdSession::start(&project, dir.path()); + + let first = wait_for_daemon(&project, None, RESTART_WITHIN, "after the cold start"); + assert_alive_throughout(first, IDLE_OBSERVATION, &session); + + let edited = "coldSessionEditAfterIdleWindows"; + write_live_source(&project, edited); + session.wait_for_symbol(edited, EDIT_VISIBLE_WITHIN, "edit with the first daemon"); + + kill_pid(first); + let second = wait_for_daemon( + &project, + Some(first), + RESTART_WITHIN, + "after the first daemon was killed", + ); + let after_restart = "coldSessionEditAfterDaemonRestart"; + write_live_source(&project, after_restart); + session.wait_for_symbol( + after_restart, + EDIT_VISIBLE_WITHIN, + "edit with the restarted daemon", + ); + assert_eq!( + daemon_pid(&project), + Some(second), + "one daemon owns the project after the restart" + ); + + session.close(); + shutdown_daemon(&project); +} diff --git a/crates/codegraph-cli/tests/daemon_handover.rs b/crates/codegraph-cli/tests/daemon_handover.rs new file mode 100644 index 00000000..383452f9 --- /dev/null +++ b/crates/codegraph-cli/tests/daemon_handover.rs @@ -0,0 +1,508 @@ +//! A session replaces a daemon left running by an older install (upstream #2343). +//! +//! Each test stands up a daemon for a project and then starts a stdio session +//! from this build. The daemon is this build too, but its `test-hooks` seams +//! make it announce another version in its hello and, in one test, leave +//! control frames unanswered: +//! +//! - an older plain `X.Y.Z` release is asked to drain, exits, and is replaced +//! by a daemon from this install, which then keeps the session's index live; +//! - a newer release is never touched; +//! - an older release that does not drain keeps the project; +//! +//! In the last two cases the session serves reads without auto-sync and says +//! so on stderr. In every case exactly one daemon holds the writer slot. + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +const READY_WITHIN: Duration = Duration::from_secs(20); +const EDIT_VISIBLE_WITHIN: Duration = Duration::from_secs(15); +const READ_ONLY_NOTICE: &str = "Serving reads in-process without auto-sync"; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-handover-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn write_live_source(project: &Path, symbol: &str) { + fs::write( + project.join("src/live.ts"), + format!("export function {symbol}(): number {{ return 1; }}\n"), + ) + .unwrap(); +} + +fn indexed_project(dir: &TestDir) -> PathBuf { + let project = dir.path().join("project"); + fs::create_dir_all(project.join("src")).unwrap(); + write_live_source(&project, "handoverInitialSymbol"); + let output = Command::new(bin()) + .args(["init"]) + .arg(&project) + .env_remove("CODEGRAPH_DIR") + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run codegraph init"); + assert!( + output.status.success(), + "init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + project.canonicalize().unwrap() +} + +/// The pid `daemon.pid` names, when that process is alive. +fn daemon_pid(project: &Path) -> Option { + let raw = fs::read_to_string(codegraph_daemon::daemon_pid_path(project).ok()?).ok()?; + codegraph_daemon::decode_lock_info(&raw) + .map(|info| info.pid) + .filter(|&pid| pid > 0 && codegraph_daemon::is_process_alive(pid)) +} + +/// The live process holding the project's writer slot. +fn writer_pid(project: &Path) -> Option { + codegraph_daemon::read_writer_lock(project).map(|info| info.pid) +} + +/// A daemon of this build standing in for another install's: `hello_version` +/// is what it announces, and `ignore_control` makes it close every control +/// frame's connection unanswered. +struct StandInDaemon { + child: Child, +} + +impl StandInDaemon { + fn start(project: &Path, scratch: &Path, hello_version: &str, ignore_control: bool) -> Self { + let mut command = Command::new(bin()); + command + .args(["serve", "--mcp", "--path"]) + .arg(project) + .env("CODEGRAPH_DAEMON_INTERNAL", "1") + .env("CODEGRAPH_TEST_DAEMON_HELLO_VERSION", hello_version) + .env("CODEGRAPH_NO_WATCH", "1") + .env("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", "120000") + .env("CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS", "120000") + .env_remove("CODEGRAPH_DIR") + .env_remove("CODEGRAPH_NO_DAEMON") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(fs::File::create(scratch.join("stand-in-daemon.log")).unwrap()); + if ignore_control { + command.env("CODEGRAPH_TEST_DAEMON_IGNORE_CONTROL", "1"); + } + let child = command.spawn().expect("spawn the stand-in daemon"); + let daemon = Self { child }; + let deadline = Instant::now() + READY_WITHIN; + loop { + let attached = codegraph_daemon::recorded_socket_path(project) + .ok() + .and_then(|socket| codegraph_daemon::attach_to_daemon(&socket).ok()); + if daemon_pid(project) == Some(daemon.pid()) + && attached.is_some_and(|client| client.hello["codegraph"] == hello_version) + { + return daemon; + } + assert!( + Instant::now() < deadline, + "the stand-in daemon did not come up within {READY_WITHIN:?}" + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + + fn pid(&self) -> u32 { + self.child.id() + } + + fn exited_within(&mut self, within: Duration) -> bool { + let deadline = Instant::now() + within; + while Instant::now() < deadline { + if self.child.try_wait().unwrap().is_some() { + return true; + } + std::thread::sleep(Duration::from_millis(25)); + } + self.child.try_wait().unwrap().is_some() + } + + fn running(&mut self) -> bool { + self.child.try_wait().unwrap().is_none() + } +} + +impl Drop for StandInDaemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// A stdio `serve --mcp --path ` from this build. +struct Session { + child: Child, + stdin: Option, + stdout: BufReader, + stderr_log: PathBuf, + next_id: i64, +} + +impl Session { + fn start(project: &Path, scratch: &Path) -> Self { + let stderr_log = scratch.join("session.stderr.log"); + let mut child = Command::new(bin()) + .args(["serve", "--mcp", "--path"]) + .arg(project) + .env_remove("CODEGRAPH_NO_DAEMON") + .env_remove("CODEGRAPH_NO_WATCH") + .env_remove("CODEGRAPH_DIR") + .env("CODEGRAPH_MCP_REGISTRY_DIR", scratch.join("mcp-registry")) + .env("CODEGRAPH_WATCH_DEBOUNCE_MS", "100") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(fs::File::create(&stderr_log).unwrap()) + .spawn() + .expect("spawn serve --mcp"); + let stdin = child.stdin.take(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut session = Self { + child, + stdin, + stdout, + stderr_log, + next_id: 1, + }; + let id = session.request( + "initialize", + json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "daemon-handover", "version": "0" } + }), + ); + let response = session.read_id(id); + assert_eq!( + response["result"]["serverInfo"]["name"], "codegraph", + "{response}" + ); + session.notify("notifications/initialized"); + session + } + + fn request(&mut self, method: &str, params: Value) -> i64 { + let id = self.next_id; + self.next_id += 1; + self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + id + } + + fn notify(&mut self, method: &str) { + self.write(json!({ "jsonrpc": "2.0", "method": method })); + } + + fn write(&mut self, value: Value) { + let stdin = self.stdin.as_mut().unwrap(); + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + } + + fn read_id(&mut self, want: i64) -> Value { + loop { + let mut line = String::new(); + let read = self.stdout.read_line(&mut line).unwrap(); + assert!( + read > 0, + "the session closed stdout; stderr:\n{}", + self.stderr() + ); + let Ok(value) = serde_json::from_str::(line.trim()) else { + panic!("non-JSON line on the session's stdout: {line:?}"); + }; + if value.get("id").and_then(Value::as_i64) == Some(want) { + return value; + } + } + } + + fn search(&mut self, query: &str) -> String { + let id = self.request( + "tools/call", + json!({ "name": "codegraph_search", "arguments": { "query": query } }), + ); + let response = self.read_id(id); + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string() + } + + fn wait_for_symbol(&mut self, symbol: &str, within: Duration) { + let deadline = Instant::now() + within; + loop { + let text = self.search(symbol); + if text.contains(symbol) && text.contains("src/live.ts") { + return; + } + assert!( + Instant::now() < deadline, + "`{symbol}` was not searchable within {within:?}: {text}\nstderr:\n{}", + self.stderr() + ); + std::thread::sleep(Duration::from_millis(100)); + } + } + + fn stderr(&self) -> String { + fs::read_to_string(&self.stderr_log).unwrap_or_default() + } + + fn close(mut self) { + drop(self.stdin.take()); + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + if self.child.try_wait().unwrap().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("the session did not exit after stdin EOF"); + } +} + +impl Drop for Session { + fn drop(&mut self) { + drop(self.stdin.take()); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +fn wait_for_new_daemon(project: &Path, old: u32) -> u32 { + let deadline = Instant::now() + READY_WITHIN; + loop { + if let Some(pid) = daemon_pid(project) + && pid != old + { + return pid; + } + assert!( + Instant::now() < deadline, + "no daemon replaced pid {old} within {READY_WITHIN:?}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn shutdown_daemon(project: &Path) { + let paths = codegraph_core::IndexPaths::resolve(project, None).unwrap(); + match codegraph_daemon::request_daemon_shutdown(project, paths.project_identity()) { + Ok(codegraph_daemon::ShutdownOutcome::NoDaemon) + | Ok(codegraph_daemon::ShutdownOutcome::Drained { .. }) => {} + other => panic!("failed to stop the daemon: {other:?}"), + } +} + +#[test] +fn an_older_release_daemon_is_replaced_and_the_session_stays_live() { + let dir = TestDir::new("older"); + let project = indexed_project(&dir); + let mut old = StandInDaemon::start(&project, dir.path(), "0.0.1", false); + let old_pid = old.pid(); + assert_eq!(writer_pid(&project), Some(old_pid)); + + let mut session = Session::start(&project, dir.path()); + assert!( + old.exited_within(READY_WITHIN), + "the older daemon did not drain and exit; session stderr:\n{}", + session.stderr() + ); + let new_pid = wait_for_new_daemon(&project, old_pid); + session.wait_for_symbol("handoverInitialSymbol", EDIT_VISIBLE_WITHIN); + + let edited = "handoverEditAfterReplacement"; + write_live_source(&project, edited); + session.wait_for_symbol(edited, EDIT_VISIBLE_WITHIN); + assert_eq!( + writer_pid(&project), + Some(new_pid), + "the replacement daemon alone holds the writer slot" + ); + assert!( + !session.stderr().contains(READ_ONLY_NOTICE), + "a replaced daemon leaves nothing to serve read-only: {}", + session.stderr() + ); + + session.close(); + shutdown_daemon(&project); +} + +#[test] +fn a_newer_release_daemon_is_left_alone_and_the_session_serves_reads() { + let dir = TestDir::new("newer"); + let project = indexed_project(&dir); + let mut newer = StandInDaemon::start(&project, dir.path(), "999.0.0", false); + + let mut session = Session::start(&project, dir.path()); + session.wait_for_symbol("handoverInitialSymbol", EDIT_VISIBLE_WITHIN); + let stderr = session.stderr(); + assert!( + stderr.contains(READ_ONLY_NOTICE) && stderr.contains("999.0.0"), + "the session says it serves reads only, and why: {stderr}" + ); + assert!(newer.running(), "a newer daemon is never stopped"); + assert_eq!(writer_pid(&project), Some(newer.pid())); + session.close(); +} + +#[test] +fn an_older_release_daemon_that_does_not_drain_keeps_the_project() { + let dir = TestDir::new("undrained"); + let project = indexed_project(&dir); + let mut stuck = StandInDaemon::start(&project, dir.path(), "0.0.1", true); + + let mut session = Session::start(&project, dir.path()); + session.wait_for_symbol("handoverInitialSymbol", EDIT_VISIBLE_WITHIN); + let stderr = session.stderr(); + assert!( + stderr.contains(READ_ONLY_NOTICE) && stderr.contains("did not drain"), + "the session says it serves reads only, and why: {stderr}" + ); + assert!(stuck.running(), "a daemon that did not drain is not killed"); + assert_eq!(writer_pid(&project), Some(stuck.pid())); + session.close(); +} + +/// A session classified an older daemon, and a newer one took the project +/// before the shutdown frame went out: the frame is sent only on a connection +/// whose own hello names the classified peer, so the newer daemon never sees +/// it. +#[test] +fn a_shutdown_bound_to_an_older_peer_never_drains_a_newer_daemon() { + let dir = TestDir::new("race"); + let project = indexed_project(&dir); + let mut newer = StandInDaemon::start(&project, dir.path(), "999.0.0", false); + let paths = codegraph_core::IndexPaths::resolve(&project, None).unwrap(); + + let outcome = + codegraph_daemon::request_daemon_shutdown_of(&project, paths.project_identity(), |hello| { + hello["codegraph"] == "0.0.1" + }); + assert!( + matches!( + outcome, + Ok(codegraph_daemon::ShutdownOutcome::Declined { pid, .. }) if pid == newer.pid() + ), + "{outcome:?}" + ); + assert!(newer.running(), "the newer daemon never received the frame"); + assert_eq!(writer_pid(&project), Some(newer.pid())); +} + +/// The daemon a copied install starts exits once that copy is replaced: the +/// copy is renamed away, the way updaters move a running executable aside, and +/// a new file takes its path. +#[test] +fn a_daemon_exits_once_its_copied_install_is_replaced() { + let dir = TestDir::new("install"); + let project = indexed_project(&dir); + let install_dir = dir.path().join("install"); + fs::create_dir_all(&install_dir).unwrap(); + let copy = install_dir.join(bin().file_name().unwrap()); + fs::copy(bin(), ©).expect("copy the codegraph binary"); + + let mut daemon = Command::new(©) + .args(["serve", "--mcp", "--path"]) + .arg(&project) + .env("CODEGRAPH_DAEMON_INTERNAL", "1") + .env("CODEGRAPH_DAEMON_INSTALL_CHECK_MS", "200") + .env("CODEGRAPH_NO_WATCH", "1") + .env("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", "120000") + .env("CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS", "120000") + .env_remove("CODEGRAPH_DIR") + .env_remove("CODEGRAPH_NO_DAEMON") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(fs::File::create(dir.path().join("copied-daemon.log")).unwrap()) + .spawn() + .expect("start the copied daemon"); + let deadline = Instant::now() + READY_WITHIN; + while daemon_pid(&project) != Some(daemon.id()) { + assert!( + Instant::now() < deadline, + "the copied daemon did not come up" + ); + assert!( + daemon.try_wait().unwrap().is_none(), + "the copied daemon exited early" + ); + std::thread::sleep(Duration::from_millis(25)); + } + // Two check intervals pass without a change: the daemon stays up. + std::thread::sleep(Duration::from_millis(500)); + assert!( + daemon.try_wait().unwrap().is_none(), + "an untouched install keeps its daemon" + ); + + fs::rename(©, install_dir.join("codegraph.old")).expect("move the running copy aside"); + fs::write(©, b"an upgrade put a new build here").expect("install the upgrade"); + + let deadline = Instant::now() + Duration::from_secs(10); + let exited = loop { + if let Some(status) = daemon.try_wait().unwrap() { + break Some(status); + } + if Instant::now() >= deadline { + break None; + } + std::thread::sleep(Duration::from_millis(25)); + }; + if exited.is_none() { + let _ = daemon.kill(); + let _ = daemon.wait(); + } + assert!( + exited.is_some(), + "the daemon kept running after its install was replaced; log:\n{}", + fs::read_to_string(dir.path().join("copied-daemon.log")).unwrap_or_default() + ); + assert_eq!( + daemon_pid(&project), + None, + "the exiting daemon removed its own pid record" + ); +} diff --git a/crates/codegraph-cli/tests/daemon_stale_wal_recovery.rs b/crates/codegraph-cli/tests/daemon_stale_wal_recovery.rs index 896bf205..a13f39f8 100644 --- a/crates/codegraph-cli/tests/daemon_stale_wal_recovery.rs +++ b/crates/codegraph-cli/tests/daemon_stale_wal_recovery.rs @@ -285,6 +285,14 @@ fn assert_preheal_precedes_writer(project: &Path, subcommand: &str, extra_args: .args(extra_args) .arg(project) .env("CODEGRAPH_NO_DAEMON", "1") + // An empty registry of its own: `index --force` lists every registered + // stdio MCP server on stderr, and with the developer's real registry that + // list can outgrow the pipe this test drains only after the barriers, + // parking the command before its second lease. + .env( + "CODEGRAPH_MCP_REGISTRY_DIR", + project.with_file_name("mcp-registry"), + ) .stdout(Stdio::piped()) .stderr(Stdio::piped()); barrier.configure(&mut command); diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_schema_9/README.md b/crates/codegraph-cli/tests/fixtures/upgrade_schema_9/README.md new file mode 100644 index 00000000..03ee3276 --- /dev/null +++ b/crates/codegraph-cli/tests/fixtures/upgrade_schema_9/README.md @@ -0,0 +1,22 @@ +# Index written at schema 9 + +`codegraph.db` is the database a release build of commit `3878d52` (schema 9, +extraction version 22, the head before schema 10 added `resolved_refs`) wrote +for `crates/codegraph-bench/fixtures/mini/`. The upgrade tests open it with the +current binary to prove that a current index one schema behind migrates in +place on its first incremental sync, without a rebuild, and is not trusted for +stored references until a full index. + +Provenance: + +- Binary: `cargo build --release --locked -p codegraph-rs --bin codegraph` at + `3878d5232057da036ed02ef152528a0806d75415` (`codegraph 0.54.0`), sha256 + `e576ec8c08cd37434204e46e51c52e58b163c0d23f6066f924aee8df58340f31`. +- Command: `CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 codegraph init `, then `.codegraph/codegraph.db` copied + here. +- `codegraph.db` sha256: + `70f708187bb53b86a7d07a606ef58f10171d9f6fa4fea658eb71c8d6410209a7`. + +Do not regenerate it with a newer binary: its value is that a schema-9 binary +wrote it. diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_schema_9/codegraph.db b/crates/codegraph-cli/tests/fixtures/upgrade_schema_9/codegraph.db new file mode 100644 index 00000000..33142453 Binary files /dev/null and b/crates/codegraph-cli/tests/fixtures/upgrade_schema_9/codegraph.db differ diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_v0_54/README.md b/crates/codegraph-cli/tests/fixtures/upgrade_v0_54/README.md new file mode 100644 index 00000000..ab605cd5 --- /dev/null +++ b/crates/codegraph-cli/tests/fixtures/upgrade_v0_54/README.md @@ -0,0 +1,20 @@ +# Index written by codegraph v0.54.0 + +`codegraph.db` is the database the released v0.54.0 binary (schema 8, extraction +version 21) wrote for `crates/codegraph-bench/fixtures/mini/`. The upgrade tests +open it with the current binary to prove that an index from the previous release +migrates: the schema migrates in place and the outdated extraction version +triggers one full rebuild. + +Provenance: + +- Binary: `codegraph 0.54.0`, sha256 + `9774545e1e565f90cbda944854f15a77431b3f3b606063b009db1c69f48d222b`. +- Command: `CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 codegraph init `, then `.codegraph/codegraph.db` copied + here. +- `codegraph.db` sha256: + `83b4bbe6cf5f2485c2887432d368a3a7fbf07d419c8b9b0f8dd0f7b643b03faf`. + +Do not regenerate it with a newer binary: its value is that an old release +wrote it. diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_v0_54/codegraph.db b/crates/codegraph-cli/tests/fixtures/upgrade_v0_54/codegraph.db new file mode 100644 index 00000000..6da2d66a Binary files /dev/null and b/crates/codegraph-cli/tests/fixtures/upgrade_v0_54/codegraph.db differ diff --git a/crates/codegraph-cli/tests/golden_reextract.rs b/crates/codegraph-cli/tests/golden_reextract.rs new file mode 100644 index 00000000..07972809 --- /dev/null +++ b/crates/codegraph-cli/tests/golden_reextract.rs @@ -0,0 +1,226 @@ +//! Re-extraction gate for the committed golden corpora. +//! +//! `crates/codegraph-bench/tests/equivalence.rs` only proves that each committed +//! `colby.db` and its canonical JSON agree with each other; it never runs the +//! extractor. This suite closes that gap: every re-indexable corpus is indexed +//! afresh from `crates/codegraph-bench/fixtures//` with the binary under +//! test, dumped through the same canonicalizer `bench --gen-golden` uses, and +//! compared byte for byte with `reference/golden//`. A change that moves +//! extraction or resolution output therefore fails here until the affected +//! golden is regenerated (`scripts/regen-goldens.sh --write `) and the +//! change is recorded in `docs/equivalence.md`. +//! +//! `mini` is not re-indexable (upstream-derived rows); it is covered by +//! `crates/codegraph-resolve/tests/golden_resolution.rs` instead. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use codegraph_bench::oracle::{diff_canonical, load_golden, write_golden}; + +/// The canonical artifacts `bench --gen-golden` writes, compared byte for byte. +const ARTIFACTS: &[&str] = &[ + "nodes.json", + "edges.json", + "refs.json", + "files.json", + "schema.sql", +]; + +macro_rules! reextract_corpora { + ($($corpus:ident),+ $(,)?) => { + /// Every corpus this suite re-extracts, in declaration order. + const CORPORA: &[&str] = &[$(stringify!($corpus)),+]; + + $( + mod $corpus { + #[test] + fn reextracts_byte_identically() { + super::reextract(stringify!($corpus)); + } + } + )+ + }; +} + +reextract_corpora!( + arkts, + cfml, + commonjs, + cpp, + csharp, + cuda, + dart, + erlang, + go, + godot, + java, + kotlin, + lua, + metal, + mobile, + nix, + objc, + php, + python, + python_bases, + routers, + ruby, + rust, + scala, + servers, + solidity, + svelte, + swift, + synthesis, + terraform, + typescript, + vue, +); + +#[test] +fn every_reindexable_golden_corpus_is_re_extracted() { + let root = workspace_root(); + let mut found: Vec = std::fs::read_dir(root.join("reference/golden")) + .expect("reference/golden is readable") + .filter_map(|entry| { + let entry = entry.expect("reference/golden entry"); + let name = entry.file_name().into_string().ok()?; + let is_corpus = entry.path().join("colby.db").is_file() + && root + .join("crates/codegraph-bench/fixtures") + .join(&name) + .is_dir(); + (is_corpus && name != "mini").then_some(name) + }) + .collect(); + found.sort(); + let mut listed: Vec = CORPORA.iter().map(|name| (*name).to_string()).collect(); + listed.sort(); + assert_eq!( + found, listed, + "every re-indexable corpus under reference/golden/ must be listed in reextract_corpora!" + ); +} + +fn reextract(corpus: &str) { + let root = workspace_root(); + let fixture = root.join("crates/codegraph-bench/fixtures").join(corpus); + let golden = root.join("reference/golden").join(corpus); + + let work = TempDir::new(&format!("work-{corpus}")); + copy_tree(&fixture, work.path()); + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .arg("init") + .arg(work.path()) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env_remove("CODEGRAPH_DIR") + .output() + .expect("spawn codegraph init"); + assert!( + output.status.success(), + "codegraph init failed for corpus {corpus}:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let dumped = TempDir::new(&format!("golden-{corpus}")); + write_golden(&work.path().join(".codegraph/codegraph.db"), dumped.path()) + .unwrap_or_else(|error| panic!("dump canonical golden for {corpus}: {error:#}")); + + let changed: Vec<&str> = ARTIFACTS + .iter() + .copied() + .filter(|artifact| { + let fresh = std::fs::read(dumped.path().join(artifact)) + .unwrap_or_else(|error| panic!("read fresh {artifact}: {error}")); + let committed = std::fs::read(golden.join(artifact)) + .unwrap_or_else(|error| panic!("read committed {corpus}/{artifact}: {error}")); + fresh != committed + }) + .collect(); + if changed.is_empty() { + return; + } + + let detail = match (load_golden(&golden), load_golden(dumped.path())) { + (Ok(expected), Ok(actual)) => match diff_canonical(&expected, &actual, None) { + Ok(()) => { + "canonical rows are equal; only artifact bytes differ (e.g. schema.sql order)" + .to_string() + } + Err(report) => report.to_string(), + }, + (expected, actual) => format!( + "could not load goldens for a detailed diff: committed={:?} fresh={:?}", + expected.err(), + actual.err() + ), + }; + panic!( + "re-extracting corpus `{corpus}` changed {changed:?}.\n\ + If the change is intended, regenerate it with \ + `scripts/regen-goldens.sh --write {corpus}` and record it in docs/equivalence.md.\n\ + {detail}" + ); +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn copy_tree(from: &Path, to: &Path) { + for entry in std::fs::read_dir(from).unwrap_or_else(|error| { + panic!("read fixture directory {}: {error}", from.display()); + }) { + let entry = entry.expect("fixture entry"); + let source = entry.path(); + let target = to.join(entry.file_name()); + let file_type = entry.file_type().expect("fixture entry type"); + if file_type.is_dir() { + std::fs::create_dir_all(&target).expect("create fixture subdirectory"); + copy_tree(&source, &target); + } else if file_type.is_file() { + std::fs::copy(&source, &target).expect("copy fixture file"); + } else { + panic!( + "fixture {} contains a non-regular entry; goldens must be built from plain files", + source.display() + ); + } + } +} + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-golden-reextract-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} diff --git a/crates/codegraph-cli/tests/probes/main.rs b/crates/codegraph-cli/tests/probes/main.rs new file mode 100644 index 00000000..2238cb2b --- /dev/null +++ b/crates/codegraph-cli/tests/probes/main.rs @@ -0,0 +1,18672 @@ +//! Real-pipeline probes: small trees indexed by the built `codegraph init`, +//! with assertions over the published graph. +//! +//! A probe pins one behavior through the same scan, extraction, framework +//! detection and resolution a user gets, which an in-process unit test can +//! bypass. Name a probe `pr_` after the upstream or project +//! PR whose behavior it locks, or `smoke_` for a harness check, so +//! `cargo test --test probes -- pr2392` finds every probe of one family. The +//! harness and its selector notation are documented in [`support`]. + +mod support; + +use support::{Project, assert_edge, assert_no_edge, assert_unresolved}; + +#[test] +fn smoke_typescript_call_edge_through_a_named_import() { + let project = Project::new() + .file( + "src/math.ts", + "export function add(a: number, b: number): number {\n return a + b;\n}\n", + ) + .file( + "src/app.ts", + "import { add } from './math';\n\nexport function run(): number {\n return add(1, 2);\n}\n", + ) + .index(); + + assert_edge!(project, "function run" => calls => "function add", resolved_by = "import"); + assert_edge!(project, "file:src/app.ts" => imports => "function add @src/math.ts:1"); + assert_no_edge!(project, "function add" => calls => "function run"); +} + +#[test] +fn smoke_external_import_stays_unresolved() { + let project = Project::new() + .file( + "src/app.ts", + "import { debounce } from 'lodash';\n\nexport function run(): void {\n debounce(run);\n}\n", + ) + .index(); + + assert_unresolved!(project, "file:src/app.ts" => imports => "debounce"); + assert_unresolved!(project, "function run" => calls => "debounce"); + assert!( + project + .graph() + .nodes_named("function", "debounce") + .is_empty(), + "an external import must not mint a project node:\n{}", + project.graph().report() + ); +} + +#[test] +fn smoke_python_method_call_through_a_local_instance() { + let project = Project::new() + .file( + "greeter.py", + "class Greeter:\n def greet(self):\n return \"hi\"\n\n\ndef main():\n g = Greeter()\n return g.greet()\n", + ) + .index(); + + assert_edge!( + project, + "function main" => calls => "method Greeter::greet", + resolved_by = "instance-method", + ); + assert_edge!(project, "function main" => instantiates => "class Greeter"); +} + +#[test] +fn smoke_sync_rereads_the_graph_after_an_edit() { + let mut project = Project::new() + .file("lib.py", "def helper():\n return 1\n") + .file( + "main.py", + "from lib import helper\n\n\ndef main():\n return helper()\n", + ) + .index(); + assert_edge!(project, "function main" => calls => "function helper @lib.py"); + + project.write("lib.py", "def assist():\n return 1\n"); + project.sync(); + + assert_unresolved!(project, "function main" => calls => "helper"); + assert_edge!(project, "file:lib.py" => contains => "function assist"); +} + +#[test] +fn smoke_file_selector_matches_an_embedded_file_node() { + // The Liquid extractor keys its file node by a hash, not `file:{path}`; + // a `file:` selector still names it by path. + let project = Project::new() + .file( + "templates/product.liquid", + "
      \n {% render 'price' %}\n
      \n", + ) + .file( + "snippets/price.liquid", + "{{ product.price }}\n", + ) + .index(); + + assert_edge!( + project, + "file:templates/product.liquid" => references => "file:snippets/price.liquid", + resolved_by = "file-path", + ); +} + +/// Upstream #965 (`0a91d0f5`): an import statement is not a definition, so no +/// reference binds another file's import node by name. App.vue's import of +/// `vue` used to land on Widget.svelte's import of `vue`. +/// Upstream #1789's prerequisite phase (W4-CPP-05): the wiring references +/// (imports, extends, implements) are resolved and persisted before the rest, +/// so a call on a receiver typed as a subclass declared in the same file +/// reaches the method its base declares, on a full index and after a sync +/// that adds the call. +#[test] +fn pr1789_a_subclass_call_reaches_the_inherited_method_on_index_and_sync() { + const CSHARP_BASE: &str = "namespace Json\n{\n public abstract class JsonWriter\n {\n public virtual void WriteEndObject() { }\n }\n}\n"; + const CSHARP_USE: &str = "namespace Json\n{\n public class JsonTextWriter : JsonWriter\n {\n public void Flush() { }\n }\n\n public class Program\n {\n public void Run()\n {\n JsonTextWriter writer = new JsonTextWriter();\n writer.WriteEndObject();\n }\n }\n}\n"; + const JAVA_BASE: &str = "package app;\n\npublic class Base {\n public void run() { }\n}\n"; + const JAVA_USE: &str = "package app;\n\nclass Child extends Base {\n}\n\npublic class Use {\n public void go() {\n Child c = new Child();\n c.run();\n }\n}\n"; + let indexed = Project::new() + .file("src/JsonWriter.cs", CSHARP_BASE) + .file("src/Program.cs", CSHARP_USE) + .file("src/app/Base.java", JAVA_BASE) + .file("src/app/Use.java", JAVA_USE) + .index(); + assert_edge!(indexed, "method Json::Program::Run" => calls => "method Json::JsonWriter::WriteEndObject"); + assert_edge!(indexed, "method app::Use::go" => calls => "method app::Base::run"); + + let mut synced = Project::new() + .file("src/JsonWriter.cs", CSHARP_BASE) + .file( + "src/Program.cs", + "namespace Json\n{\n public class Program\n {\n }\n}\n", + ) + .file("src/app/Base.java", JAVA_BASE) + .file( + "src/app/Use.java", + "package app;\n\npublic class Use {\n}\n", + ) + .index(); + synced + .write("src/Program.cs", CSHARP_USE) + .write("src/app/Use.java", JAVA_USE); + let graph = synced.sync(); + assert_edge!(graph, "method Json::Program::Run" => calls => "method Json::JsonWriter::WriteEndObject"); + assert_edge!(graph, "method app::Use::go" => calls => "method app::Base::run"); +} + +#[test] +fn pr0965_vue_import_never_binds_a_svelte_import_node() { + let project = Project::new() + .file( + "src/App.vue", + "\n\n\n", + ) + .file( + "src/Widget.svelte", + "\n\n

      {count}

      \n", + ) + .index(); + + assert_no_edge!( + project, + "component src/App.vue::App" => imports => "import vue @src/Widget.svelte" + ); + assert_no_edge!(project, "component src/App.vue::App" => imports => "import vue"); + assert_no_edge!(project, "file:src/App.vue" => imports => "import vue"); + // The package is not in the project, so the import stays unresolved, on + // the file that holds the component (an SFC's imports are its file's). + assert_unresolved!(project, "file:src/App.vue" => imports => "vue"); +} + +/// Upstream #2437: a catch-all `"*"` path alias has an empty prefix, which +/// every package specifier starts with. Matching it no longer makes +/// `@mui/material` or `react` look local, so neither the import nor the +/// supertype binds the project's same-named symbol. (A `calls` or +/// `references` ref through an out-of-repo import is the wider #2134 gate.) +#[test] +fn pr2437_catch_all_alias_keeps_package_imports_external() { + let project = Project::new() + .file( + "tsconfig.json", + "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"paths\": { \"*\": [\"./typings/*\"] }\n }\n}\n", + ) + .file( + "src/Typography.tsx", + "export function Typography(props: { text: string }) {\n return {props.text};\n}\n", + ) + .file("src/Component.ts", "export class Component {}\n") + .file( + "src/App.tsx", + "import { Typography } from '@mui/material';\n\nexport function App() {\n return ;\n}\n", + ) + .file( + "src/Widget.ts", + "import { Component } from 'react';\n\nexport class Widget extends Component {}\n", + ) + .file( + "src/Page.tsx", + "import { Typography } from './Typography';\n\nexport function Page() {\n return ;\n}\n", + ) + .index(); + + assert_no_edge!(project, "file:src/App.tsx" => imports => "function Typography"); + assert_no_edge!(project, "class Widget" => extends => "class Component"); + assert_unresolved!(project, "class Widget" => extends => "Component"); + // The project's own import still resolves. + assert_edge!( + project, + "file:src/Page.tsx" => imports => "function Typography @src/Typography.tsx", + resolved_by = "import", + ); +} + +/// Upstream #2437: an alias that lands on disk outside the index (here in +/// `node_modules`) does not make the import the project's either. +#[test] +fn pr2437_alias_into_node_modules_keeps_the_import_external() { + let project = Project::new() + .file( + "tsconfig.json", + "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"paths\": { \"lit/format\": [\"./node_modules/lit/format.js\"] }\n }\n}\n", + ) + .file( + "node_modules/lit/format.js", + "export function format(value) { return String(value); }\n", + ) + .file( + "src/util.ts", + "export function format(value: number): string {\n return value.toFixed(2);\n}\n", + ) + .file( + "src/card.ts", + "import { format } from 'lit/format';\n\nexport function render(): string {\n return format(1);\n}\n", + ) + .index(); + + assert_no_edge!(project, "file:src/card.ts" => imports => "function format"); + assert_unresolved!(project, "file:src/card.ts" => imports => "format"); +} + +/// W3: the cross-language resolution seams (plan section 3 "W3"). Every probe +/// of this wave sits here, so `cargo test --test probes -- seams` runs them. +mod seams { + use super::support::{Project, assert_edge, assert_no_edge, assert_unresolved}; + + /// Upstream #2392 (`3f5a9e2e`) and #2263 (`c4babfce`): an `imports` + /// reference never binds the import statement it was written in. A module + /// path names a file: `import './side'` links `src/side.ts`, while a path + /// no file answers (`./missing`, ``, a Lua module) parks as + /// unresolved instead of an edge to its own statement, which a sync never + /// retries. A Lua `require` reaches a module file or nothing. + #[test] + fn pr2392_an_import_never_binds_its_own_statement() { + let project = Project::new() + .file( + "src/a.ts", + "import { X } from './x';\nimport './side';\nimport './missing';\nexport function run() { return X; }\n", + ) + .file("src/side.ts", "console.log(1);\n") + .file( + "src/main.c", + "#include \"a/b.h\"\n#include \nint main(void) { return 0; }\n", + ) + .file( + "main.lua", + "local m = require(\"missing.mod\")\nlocal p = require(\"pkg\")\nreturn { m, p }\n", + ) + .file("pkg/init.lua", "local P = {}\nreturn P\n") + .index(); + + assert_edge!(project, "file:src/a.ts" => imports => "file:src/side.ts", resolved_by = "import"); + assert_no_edge!(project, "file:src/a.ts" => imports => "import ./side"); + assert_no_edge!(project, "file:src/a.ts" => imports => "import ./missing"); + assert_no_edge!(project, "file:src/a.ts" => imports => "import ./x"); + assert_unresolved!(project, "file:src/a.ts" => imports => "./missing"); + assert_unresolved!(project, "file:src/a.ts" => imports => "./x"); + + assert_no_edge!(project, "file:src/main.c" => imports => "import a/b.h"); + assert_no_edge!(project, "file:src/main.c" => imports => "import stdio.h"); + assert_unresolved!(project, "file:src/main.c" => imports => "a/b.h"); + assert_unresolved!(project, "file:src/main.c" => imports => "stdio.h"); + + assert_edge!(project, "file:main.lua" => imports => "file:pkg/init.lua"); + assert_no_edge!(project, "file:main.lua" => imports => "import missing.mod"); + assert_unresolved!(project, "file:main.lua" => imports => "missing.mod"); + } + + /// A C# `using` of a namespace the project does not declare stays + /// unresolved, in each file that writes it (a C# `using` mints no import + /// node, so there is no statement to bind). + #[test] + fn pr2392_an_outside_csharp_namespace_stays_unresolved() { + let project = Project::new() + .file( + "src/Svc.cs", + "using System;\nusing System.Text;\nnamespace App { class Svc {} }\n", + ) + .file( + "src/Other.cs", + "using System.Text;\nnamespace App { class Other {} }\n", + ) + .file( + "src/Scoped.cs", + "using System.Text;\nnamespace App;\nclass Scoped {}\n", + ) + .index(); + + // A `using` outside a block namespace is its file's reference, as a + // block namespace holds only its body (upstream #2220); a file-scoped + // namespace holds its file's usings. + assert_unresolved!(project, "file:src/Svc.cs" => imports => "System"); + assert_unresolved!(project, "file:src/Svc.cs" => imports => "System.Text"); + assert_unresolved!(project, "file:src/Other.cs" => imports => "System.Text"); + assert_unresolved!(project, "namespace App @src/Scoped.cs" => imports => "System.Text"); + } + + /// Upstream #2120 (`7a51969f`), its general half: a receiver-less JS/TS + /// call never reaches a property or a field (mocha's `it`, `describe`); a + /// member call on a namespace import, or on a binding from a package + /// outside the repository, never takes a method picked by name; and a bare + /// call to a name imported from outside the repository has no project + /// candidate. A package importing itself is not outside. + #[test] + fn pr2120_bare_and_imported_receivers_never_guess_a_member() { + let project = Project::new() + .file( + "package.json", + "{\"name\":\"app\",\"dependencies\":{\"zod\":\"*\",\"supertest\":\"*\",\"vitest\":\"*\"}}\n", + ) + .file( + "src/test/xfail.ts", + "export interface XFailFunction {\n it: (title: string) => void;\n}\n", + ) + .file( + "src/commands/CacheClear.ts", + "export class CacheClearCommand {\n describe = \"Clears the cache.\";\n run() { return 1; }\n}\n", + ) + .file( + "src/test/Mocker.ts", + "export class Mocker {\n string(): string { return 'x'; }\n record(): number { return 1; }\n}\n", + ) + .file( + "src/helpers.ts", + "export function test(name: string) { return name; }\nexport function request(url: string) { return url; }\n", + ) + .file( + "src/schemas.ts", + "import * as mocker from 'zod';\nimport { Mocker as mockerLib } from 'zod';\n\nexport function schemas() {\n const a = mocker.string();\n const b = mockerLib.record();\n return [a, b];\n}\n", + ) + .file( + "src/schemas.test.ts", + "import { test } from 'vitest';\nconst request = require('supertest');\n\ndescribe('schemas', () => {\n it('works', () => { test('x'); request('app'); });\n});\n", + ) + .file("src/lib/adapters.ts", "export function getAdapter() { return 1; }\n") + .file( + "src/own.ts", + "import { getAdapter } from 'app';\n\nexport function own() {\n return getAdapter();\n}\n", + ) + .index(); + + let test_file = "file:src/schemas.test.ts"; + assert_no_edge!(project, test_file => calls => "property XFailFunction::it"); + assert_no_edge!(project, test_file => calls => "property CacheClearCommand::describe"); + assert_no_edge!(project, test_file => calls => "function test"); + assert_no_edge!(project, test_file => calls => "function request"); + // The CommonJS binding is the package's value, not a project symbol. + assert_no_edge!(project, test_file => calls => "constant request"); + assert_no_edge!(project, "function schemas" => calls => "method Mocker::string"); + assert_no_edge!(project, "function schemas" => calls => "method Mocker::record"); + assert_edge!(project, "function own" => calls => "function getAdapter"); + } + + /// Upstream #2134 (`67744468`), its resolution half: a JS/TS member call + /// on an import binding never picks a method declared in the calling file + /// (expo-camera's `CameraManager.isAvailableAsync()` inside `CameraView`'s + /// own `isAvailableAsync`), and a name the file binds from a package + /// outside the repository names nothing in it, for every reference kind + /// (`type Command` from `@tiptap/core` is not a local `command`). + #[test] + fn pr2134_imported_names_never_bind_the_caller_or_a_namesake() { + let project = Project::new() + .file( + "package.json", + "{\"name\":\"app\",\"dependencies\":{\"@tiptap/core\":\"*\",\"@rsbuild/core\":\"*\"}}\n", + ) + .file("src/ExpoCameraManager.ts", "export default {};\n") + .file( + "src/CameraView.ts", + "import CameraManager from './ExpoCameraManager';\n\nexport class CameraView {\n static isAvailableAsync() {\n return CameraManager.isAvailableAsync();\n }\n}\n", + ) + .file("src/menu.ts", "export function command() { return 1; }\n") + .file( + "src/gap.ts", + "import type { Command } from '@tiptap/core';\n\nexport function gapCursor(): Command {\n return () => true;\n}\n", + ) + .file( + "src/rsbuild.ts", + "import { defineConfig, type RsbuildConfig } from '@rsbuild/core';\n\nexport function rsbuildConfig(): RsbuildConfig {\n return defineConfig({});\n}\n", + ) + .index(); + + assert_no_edge!( + project, + "method CameraView::isAvailableAsync" => calls => "method CameraView::isAvailableAsync" + ); + assert_no_edge!(project, "function gapCursor" => references => "function command"); + assert_no_edge!(project, "function rsbuildConfig" => references => "function rsbuildConfig"); + } + + /// Upstream #2136 (`a4f98202`): a call written through `super` / `base` + /// (C#) / `super()` (Python) inside an override calls the parent's + /// implementation, never the method making it. Real recursion keeps its + /// edge. + #[test] + fn pr2136_a_super_call_is_not_a_self_call() { + let project = Project::new() + .file( + "ios/CameraView.swift", + "import UIKit\nclass CameraView: UIView {\n override func didMoveToWindow() {\n super.didMoveToWindow()\n setup()\n }\n func setup() {}\n func countdown(_ n: Int) {\n if n > 0 { countdown(n - 1) }\n }\n}\n", + ) + .file( + "android/CameraView.kt", + "package app\nclass CameraView(context: Context) : FrameLayout(context) {\n override fun onAttachedToWindow() {\n super.onAttachedToWindow()\n }\n}\n", + ) + .file( + "java/Service.java", + "package app;\npublic class Service extends Base {\n @Override\n public void start() {\n super.start();\n }\n}\n", + ) + .file( + "py/views.py", + "class ProfileView(BaseView):\n def dispatch(self, request):\n return super().dispatch(request)\n", + ) + .file( + "ts/list.ts", + "export class List extends Base {\n render(): string {\n return super.render() + '!';\n }\n}\n", + ) + .file( + "cs/Handler.cs", + "public class Handler : BaseHandler {\n public override void Handle() {\n base.Handle();\n }\n}\n", + ) + .index(); + + for method in [ + "method CameraView::didMoveToWindow", + "method app::CameraView::onAttachedToWindow", + "method app::Service::start", + "method ProfileView::dispatch", + "method List::render", + "method Handler::Handle", + ] { + assert_no_edge!(project, method => calls => method); + } + assert_edge!(project, "method CameraView::countdown" => calls => "method CameraView::countdown"); + } + + /// Upstream #2221 (`ff190b30`, its `window` half) and #2223 + /// (`ca1eba98`): a call the extractor recorded by its bare name but the + /// source writes on another object (`window.$events.listen()`, Scala + /// `requestToArmeria(request).execute()`, Rust `self.0.into_route(state)`) + /// is not the calling method. A call on the method's own object, and one + /// through a field of the class's own type, are. + #[test] + fn pr2223_a_call_handed_on_to_another_object_is_not_the_caller() { + let project = Project::new() + .file( + "resources/js/editor-toolbox.ts", + "export class EditorToolbox {\n container: HTMLElement;\n\n toggle(): void {\n this.container.classList.toggle('open');\n this.toggle();\n }\n}\n", + ) + .file( + "resources/js/tree.ts", + "export class TreeNode {\n left: TreeNode | null = null;\n\n insert(v: number): void {\n this.left.insert(v);\n }\n}\n", + ) + .file( + "resources/js/common-events.js", + "export function listen(editor) {\n window.$events.listen('editor::replace', () => editor.reset());\n}\n", + ) + .file( + "core/src/main/scala/sttp/ArmeriaBackend.scala", + "package sttp\n\nclass ArmeriaBackend {\n def requestToArmeria(request: String): Client = new Client\n\n def execute(request: String): Unit = {\n val armeriaRes = requestToArmeria(request).execute()\n }\n}\n", + ) + .file( + "src/routing/route.rs", + "pub struct BoxedIntoRoute(Box);\n\nimpl BoxedIntoRoute {\n pub fn into_route(self, state: u32) -> u32 {\n self.0.into_route(state)\n }\n}\n", + ) + .index(); + + assert_no_edge!(project, "function listen" => calls => "function listen"); + assert_no_edge!(project, "method ArmeriaBackend::execute" => calls => "method ArmeriaBackend::execute"); + assert_no_edge!(project, "method BoxedIntoRoute::into_route" => calls => "method BoxedIntoRoute::into_route"); + assert_edge!(project, "method EditorToolbox::toggle" => calls => "method EditorToolbox::toggle"); + assert_edge!(project, "method TreeNode::insert" => calls => "method TreeNode::insert"); + } + + /// Upstream #2229 (`b2818198`): a type never inherits from itself. cats' + /// `trait BigDecimalInstances extends cats.kernel.instances.BigDecimalInstances` + /// names the other type of its name the written qualifier leads to. + #[test] + fn pr2229_a_type_never_inherits_itself() { + let project = Project::new() + .file( + "kernel/src/main/scala/cats/kernel/instances/BigDecimalInstances.scala", + "package cats.kernel\npackage instances\n\ntrait BigDecimalInstances {\n def order: Int = 0\n}\n", + ) + .file( + "core/src/main/scala/cats/instances/bigDecimal.scala", + "package cats\npackage instances\n\ntrait BigDecimalInstances extends cats.kernel.instances.BigDecimalInstances {\n def show: String = \"\"\n}\n", + ) + .file( + "core/src/main/scala/cats/Functor.scala", + "package cats\n\nobject Functor {\n trait AllOps[F[_], A]\n}\n\nobject Apply {\n trait AllOps[F[_], A] extends Functor.AllOps[F, A]\n}\n\nobject Foldable {\n trait AllOps[F[_], A]\n}\n", + ) + .index(); + + let core = "trait BigDecimalInstances @core/src/main/scala/cats/instances/bigDecimal.scala"; + let kernel = "trait BigDecimalInstances @kernel/src/main/scala/cats/kernel/instances/BigDecimalInstances.scala"; + assert_no_edge!(project, core => extends => core); + assert_edge!(project, core => extends => kernel); + assert_no_edge!(project, "trait Apply::AllOps" => extends => "trait Apply::AllOps"); + assert_edge!(project, "trait Apply::AllOps" => extends => "trait Functor::AllOps"); + } + + /// Upstream #2139 (`66d5dc36`) and #2141 (c) (`ca3bb444`): the last-resort + /// fuzzy match ignores case only in a language whose identifiers do (PHP, + /// Pascal, CFML), and a fuzzy `instantiates` never names a method. (A PHP + /// call written in another case first needs the case-insensitive + /// prefilter of #2141 (a); `match_fuzzy`'s unit tests cover its fuzzy + /// half.) + #[test] + fn pr2139_fuzzy_matching_ignores_case_only_where_the_language_does() { + let project = Project::new() + .file( + "src/main/java/app/CharPredicate.java", + "package app;\npublic interface CharPredicate {\n boolean test(char c);\n}\n", + ) + .file( + "src/main/java/app/Request.java", + "package app;\npublic class Request {\n public Object cookieManager() { return null; }\n}\n", + ) + .file( + "src/test/java/app/ParserTest.java", + "package app;\nimport org.junit.jupiter.api.Test;\nimport java.net.CookieManager;\npublic class ParserTest {\n @Test void parses() {\n Object manager = new CookieManager();\n }\n}\n", + ) + .file("src/mocks.ts", "export function mock() { return 1; }\n") + .file( + "src/fixtures.ts", + "export class Store {\n mockData() { return 1; }\n}\n", + ) + .file( + "src/use.test.ts", + "export function check() {\n const m = Mock();\n return new MockData(m);\n}\n", + ) + .file("tools/paths.py", "def path():\n return 1\n") + .file("tools/main.py", "def main():\n return Path(\".\")\n") + .file("pkg/builder.go", "package pkg\n\nfunc builder() int { return 1 }\n") + .file("pkg/main.go", "package pkg\n\nfunc Run() int { return Builder() }\n") + .index(); + + assert_no_edge!(project, "method app::ParserTest::parses" => decorates => "method app::CharPredicate::test"); + assert_no_edge!(project, "class app::ParserTest" => decorates => "method app::CharPredicate::test"); + assert_no_edge!(project, "method app::ParserTest::parses" => instantiates => "method app::Request::cookieManager"); + assert_no_edge!(project, "function check" => calls => "function mock"); + assert_no_edge!(project, "function check" => instantiates => "method Store::mockData"); + assert_no_edge!(project, "function main" => calls => "function path"); + assert_no_edge!(project, "function Run" => calls => "function builder"); + } + + /// Upstream #2166 (`05740b55`) and #2169 (`791ae397`), its general half: a + /// call on a receiver written as a type the project does not declare + /// (`Object.assign`, Java `String.valueOf`), or on one whose inferred + /// type the project does not declare (`List list`, Python + /// `path = Path("x")`), is that outside type's, never a project method's + /// guessed by name. Go's exported package variables are not type names, + /// and the project's own type still resolves. + #[test] + fn pr2166_a_call_on_an_outside_type_is_not_a_project_method() { + let project = Project::new() + .file( + "src/main/java/app/StringUtils.java", + "package app;\npublic class StringUtils {\n public static String valueOf(Object o) { return String.valueOf(o); }\n}\n", + ) + .file( + "src/main/java/app/Use.java", + "package app;\npublic class Use {\n public int run() {\n Integer.valueOf(1);\n return StringUtils.valueOf(2).length();\n }\n}\n", + ) + .file( + "src/main/java/app/NonNullElementWrapperList.java", + "package app;\npublic class NonNullElementWrapperList {\n public boolean add(Object o) { return true; }\n}\n", + ) + .file( + "src/main/java/app/UseList.java", + "package app;\nimport java.util.ArrayList;\nimport java.util.List;\npublic class UseList {\n public void run() {\n List list = new ArrayList<>();\n list.add(\"x\");\n }\n}\n", + ) + .file( + "src/ts/ObjectUtils.ts", + "export class ObjectUtils {\n static assign(a: unknown) { return a; }\n}\n", + ) + .file( + "src/ts/merge.ts", + "export function merge(a: unknown) {\n return Object.assign(a);\n}\n", + ) + .file( + "tools/helper.py", + "class PathHelper:\n def exists(self):\n return True\n", + ) + .file( + "tools/check.py", + "from pathlib import Path\n\ndef check():\n path = Path(\"x\")\n return path.exists()\n", + ) + .file( + "pkg/form.go", + "package pkg\n\ntype form struct{}\n\nfunc (f form) Bind() int { return 1 }\n\nvar FormPost = form{}\n\nfunc Run() int { return FormPost.Bind() }\n", + ) + .index(); + + assert_no_edge!(project, "method app::StringUtils::valueOf" => calls => "method app::StringUtils::valueOf"); + assert_edge!(project, "method app::Use::run" => calls => "method app::StringUtils::valueOf"); + assert_no_edge!(project, "method app::UseList::run" => calls => "method app::NonNullElementWrapperList::add"); + assert_no_edge!(project, "function merge" => calls => "method ObjectUtils::assign"); + assert_no_edge!(project, "function check" => calls => "method PathHelper::exists"); + assert_edge!(project, "function Run" => calls => "method form::Bind"); + } + + /// Upstream #2169 reads a C++ receiver's declared type through + /// `normalizeCppTypeName`: `SkipList list_` is the + /// project's `SkipList`, so its call keeps the method (leveldb's + /// `list_.Insert(key)`), while a receiver of a type the project does not + /// declare stops the guess (`QString s; s.size()`). + #[test] + fn pr2169_a_cpp_receiver_of_a_project_class_template_keeps_its_method() { + let project = Project::new() + .file( + "db/skiplist.h", + "namespace leveldb {\ntemplate \nclass SkipList {\n public:\n void Insert(const Key& key) {}\n bool Contains(const Key& key) const { return false; }\n};\nclass Sizer {\n public:\n int size() const { return 0; }\n};\n} // namespace leveldb\n", + ) + .file( + "db/skiplist_test.cc", + "#include \"db/skiplist.h\"\n#include \nnamespace leveldb {\nclass ConcurrentTest {\n SkipList list_;\n\n public:\n void WriteStep() { list_.Insert(1); }\n};\nbool Probe() {\n SkipList list;\n return list.Contains(2);\n}\nint Length() {\n QString s;\n return s.size();\n}\n} // namespace leveldb\n", + ) + .index(); + + assert_edge!( + project, + "method leveldb::ConcurrentTest::WriteStep" => calls => "method leveldb::SkipList::Insert" + ); + assert_edge!(project, "function leveldb::Probe" => calls => "method leveldb::SkipList::Contains"); + assert_no_edge!(project, "function leveldb::Length" => calls => "method leveldb::Sizer::size"); + } + + /// W3-05c (KEEP-RUST, owner 2026-10-11): a C# call on a type the project + /// does not declare reaches the one project extension method in scope + /// that takes it as `this` and fits the call's arguments (Newtonsoft's + /// `objectType.IsEnum()` on a `System.Type`), and the overload pass keeps + /// it (`propertyInfo.GetGetMethod(true)` stays the `bool` overload). Two + /// fitting overloads, an extension whose namespace is not in scope (also a + /// sibling block's local `using`), a qualified `this` type, and an outside + /// type with no extension all stay unresolved. + #[test] + fn pr2169_a_csharp_call_on_an_outside_type_reaches_the_projects_extension_method() { + let project = Project::new() + .file( + "src/Utilities/TypeExtensions.cs", + "using System;\nusing System.Collections.Generic;\nusing System.Reflection;\n\nnamespace App.Utilities\n{\n internal static class TypeExtensions\n {\n public static bool IsEnum(this Type type)\n {\n return false;\n }\n\n public static MethodInfo GetGetMethod(this PropertyInfo propertyInfo)\n {\n return propertyInfo.GetGetMethod(false);\n }\n\n public static MethodInfo GetGetMethod(this PropertyInfo propertyInfo, bool nonPublic)\n {\n return null;\n }\n\n public static MethodInfo GetMethod(this Type type, string name, BindingFlags bindingFlags)\n {\n return null;\n }\n\n public static MethodInfo GetMethod(this Type type, string name, IList parameterTypes)\n {\n return null;\n }\n\n public static int Scale(this System.Timers.Timer timer)\n {\n return 0;\n }\n }\n}\n", + ) + .file( + "src/Utilities/ILGeneratorExtensions.cs", + "using System.Reflection;\nusing System.Reflection.Emit;\n\nnamespace App.Utilities\n{\n internal static class ILGeneratorExtensions\n {\n public static void CallMethod(this ILGenerator generator, MethodInfo methodInfo)\n {\n }\n }\n}\n", + ) + .file( + "src/Hidden/HiddenExtensions.cs", + "using System;\n\nnamespace App.Hidden\n{\n internal static class HiddenExtensions\n {\n public static bool IsHidden(this Type type)\n {\n return false;\n }\n }\n}\n", + ) + .file( + "src/Utilities/StringUtils.cs", + "using System;\n\nnamespace App.Utilities\n{\n internal static class StringUtils\n {\n public static string FormatWith(this string format, IFormatProvider provider, object? arg0)\n {\n return format;\n }\n\n public static string FormatWith(this string format, IFormatProvider provider, object? arg0, object? arg1)\n {\n return format.FormatWith(provider, new object?[] { arg0, arg1 });\n }\n\n private static string FormatWith(this string format, IFormatProvider provider, params object?[] args)\n {\n return format;\n }\n }\n}\n", + ) + .file( + "src/Utilities/Boxes.cs", + "namespace App.Utilities\n{\n public class Box\n {\n public T Value;\n }\n\n public static class BoxExtensions\n {\n public static object Open(this Box box, params T[] values)\n {\n return box;\n }\n\n public static object Open(this Box box, U value)\n {\n return box;\n }\n\n public static object Seal(this Box box, params T[] values) where T : struct\n {\n return box;\n }\n\n public static object Seal(this Box box, U value) where U : class\n {\n return box;\n }\n\n public static object Wrap(this Box box, params T[] values) where T: struct\n {\n return box;\n }\n\n public static object Wrap(this Box< T > /* boxed */ box, T value) where T : struct // value types\n {\n return box;\n }\n\n public static object Note(this Box box, params T[] values) where T : struct { return box; }\n\n public static object Note(this Box box, U value, string note = \"\"\"x\" // literal text\"\"\") where U : struct { return box; }\n }\n}\n", + ) + .file( + "src/Utilities/Picks.cs", + "using System;\n\nnamespace App.Utilities\n{\n internal static class Picks\n {\n public static object Pick(this Type type, params object?[] args)\n {\n return type;\n }\n\n public static object Pick(object? arg)\n {\n return arg;\n }\n\n public static object Take(this Type type, params object?[] args)\n {\n return type;\n }\n\n public static object Take(this Exception error, object? arg)\n {\n return error;\n }\n }\n}\n", + ) + .file( + "src/Model/Money.cs", + "namespace App.Model\n{\n public class Money\n {\n public override string ToString()\n {\n return \"0\";\n }\n }\n}\n", + ) + .file( + "src/Serialization/Reader.cs", + "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Reflection;\nusing System.Reflection.Emit;\nusing App.Utilities;\n\nnamespace App.Serialization\n{\n public class Reader\n {\n public bool Check(Type objectType)\n {\n return objectType.IsEnum();\n }\n\n public void Emit(ILGenerator generator, MethodInfo m)\n {\n generator.CallMethod(m);\n }\n\n public MethodInfo Getter(PropertyInfo propertyInfo)\n {\n return propertyInfo.GetGetMethod(true);\n }\n\n public MethodInfo Find(Type type, IList parameterTypes)\n {\n return type.GetMethod(null, parameterTypes);\n }\n\n public bool Hidden(Type type)\n {\n return type.IsHidden();\n }\n\n public int Tick(System.Timers.Timer timer)\n {\n return timer.Scale();\n }\n\n public string Text(StringWriter writer)\n {\n return writer.ToString();\n }\n\n public object Choose(Type type, object? arg)\n {\n return type.Pick(arg);\n }\n\n public object Keep(Type type, object? arg)\n {\n return type.Take(arg);\n }\n\n public object Unbox(Box box)\n {\n return box.Open(1);\n }\n\n public object Close(Box box)\n {\n return box.Seal(1);\n }\n\n public object Fold(Box box)\n {\n return box.Wrap(1);\n }\n\n public object Jot(Box box)\n {\n return box.Note(1);\n }\n }\n}\n", + ) + .file( + "src/Nest.cs", + "using System;\n\nnamespace Outer\n{\n internal static class OuterExtensions\n {\n public static bool IsOuter(this Type type)\n {\n return false;\n }\n }\n\n namespace Inner\n {\n internal static class InnerExtensions\n {\n public static bool IsInner(this Type type)\n {\n return false;\n }\n }\n\n public class Deep\n {\n public bool Down(Type type)\n {\n return type.IsOuter();\n }\n }\n }\n\n public class Top\n {\n public bool Up(Type type)\n {\n return type.IsInner();\n }\n }\n}\n", + ) + .file( + "src/Two.cs", + "using System;\n\nnamespace App.First\n{\n using App.Hidden;\n\n public class A\n {\n public bool Seen(Type type)\n {\n return type.IsHidden();\n }\n }\n}\n\nnamespace App.Second\n{\n public class B\n {\n public bool Unseen(Type type)\n {\n return type.IsHidden();\n }\n }\n}\n", + ) + .index(); + + let extensions = "src/Utilities/TypeExtensions.cs"; + assert_edge!( + project, + "method App.Serialization::Reader::Check" => calls => "method App.Utilities::TypeExtensions::IsEnum" + ); + assert_edge!( + project, + "method App.Serialization::Reader::Emit" => calls => "method App.Utilities::ILGeneratorExtensions::CallMethod" + ); + assert_edge!( + project, + "method App.Serialization::Reader::Getter" => calls => &format!("method App.Utilities::TypeExtensions::GetGetMethod @{extensions}:19") + ); + assert_no_edge!( + project, + "method App.Serialization::Reader::Getter" => calls => &format!("method App.Utilities::TypeExtensions::GetGetMethod @{extensions}:14") + ); + assert_edge!( + project, + &format!("method App.Utilities::TypeExtensions::GetGetMethod @{extensions}:14") => calls => &format!("method App.Utilities::TypeExtensions::GetGetMethod @{extensions}:19") + ); + let strings = "src/Utilities/StringUtils.cs"; + assert_edge!( + project, + &format!("method App.Utilities::StringUtils::FormatWith @{strings}:12") => calls => &format!("method App.Utilities::StringUtils::FormatWith @{strings}:17") + ); + assert_no_edge!( + project, + &format!("method App.Utilities::StringUtils::FormatWith @{strings}:12") => calls => &format!("method App.Utilities::StringUtils::FormatWith @{strings}:7") + ); + // Only overloads that take the receiver as `this` compete: neither + // a plain `Pick(object?)` nor `Take(this Exception, …)` replaces the + // `params` overload the receiver's type chose, though each fits the + // one argument in its normal form. + let picks = "src/Utilities/Picks.cs"; + assert_edge!( + project, + "method App.Serialization::Reader::Choose" => calls => &format!("method App.Utilities::Picks::Pick @{picks}:7") + ); + assert_no_edge!( + project, + "method App.Serialization::Reader::Choose" => calls => &format!("method App.Utilities::Picks::Pick @{picks}:12") + ); + assert_edge!( + project, + "method App.Serialization::Reader::Keep" => calls => &format!("method App.Utilities::Picks::Take @{picks}:17") + ); + assert_no_edge!( + project, + "method App.Serialization::Reader::Keep" => calls => &format!("method App.Utilities::Picks::Take @{picks}:22") + ); + // Overloads whose `this` types differ only in the names of their own + // type parameters (`Box`, `Box`) extend the same type: the one + // argument fits `Open(this Box, U)` in its normal form. + let boxes = "src/Utilities/Boxes.cs"; + assert_edge!( + project, + "method App.Serialization::Reader::Unbox" => calls => &format!("method App.Utilities::BoxExtensions::Open @{boxes}:15") + ); + assert_no_edge!( + project, + "method App.Serialization::Reader::Unbox" => calls => &format!("method App.Utilities::BoxExtensions::Open @{boxes}:10") + ); + // Their `where` clauses are part of the receiver: `U : class` takes + // no `Box`, so `box.Seal(1)` keeps the `T : struct` overload. + assert_edge!( + project, + "method App.Serialization::Reader::Close" => calls => &format!("method App.Utilities::BoxExtensions::Seal @{boxes}:20") + ); + assert_no_edge!( + project, + "method App.Serialization::Reader::Close" => calls => &format!("method App.Utilities::BoxExtensions::Seal @{boxes}:25") + ); + // Spacing and comments are no part of a receiver or a constraint: + // `Box< T > /* boxed */` with `where T : struct // …` is `Box` with + // `where T: struct`, so `box.Wrap(1)` takes the normal-form overload. + assert_edge!( + project, + "method App.Serialization::Reader::Fold" => calls => &format!("method App.Utilities::BoxExtensions::Wrap @{boxes}:35") + ); + assert_no_edge!( + project, + "method App.Serialization::Reader::Fold" => calls => &format!("method App.Utilities::BoxExtensions::Wrap @{boxes}:30") + ); + // A comment marker inside a raw string literal is text: the default + // value `"""x" // literal text"""` hides no `where` clause. + assert_edge!( + project, + "method App.Serialization::Reader::Jot" => calls => &format!("method App.Utilities::BoxExtensions::Note @{boxes}:42") + ); + assert_no_edge!( + project, + "method App.Serialization::Reader::Jot" => calls => &format!("method App.Utilities::BoxExtensions::Note @{boxes}:40") + ); + assert_no_edge!(project, "method App.Serialization::Reader::Find" => calls => "method App.Utilities::TypeExtensions::GetMethod"); + assert_no_edge!(project, "method App.Serialization::Reader::Hidden" => calls => "method App.Hidden::HiddenExtensions::IsHidden"); + assert_no_edge!(project, "method App.Serialization::Reader::Tick" => calls => "method App.Utilities::TypeExtensions::Scale"); + assert_no_edge!(project, "method App.Serialization::Reader::Text" => calls => "method App.Model::Money::ToString"); + assert_edge!(project, "method App.First::A::Seen" => calls => "method App.Hidden::HiddenExtensions::IsHidden"); + // `B` is in `App.Second`, whose block imports no `App.Hidden`. + assert_no_edge!(project, "method App.Second::B::Unseen" => calls => "method App.Hidden::HiddenExtensions::IsHidden"); + assert_unresolved!(project, "method App.Second::B::Unseen" => calls => "type.IsHidden"); + // An enclosing namespace's extension is in scope in a nested one; a + // nested one's is not in scope outside it. + assert_edge!(project, "method Outer.Inner::Deep::Down" => calls => "method Outer::OuterExtensions::IsOuter"); + assert_no_edge!(project, "method Outer::Top::Up" => calls => "method Outer.Inner::InnerExtensions::IsInner"); + assert_unresolved!(project, "method Outer::Top::Up" => calls => "type.IsInner"); + + // The same overload pair with an attributed `this` parameter. + let attributed = Project::new() + .file( + "src/Utilities/TypeExtensions.cs", + "using System.Diagnostics.CodeAnalysis;\nusing System.Reflection;\n\nnamespace App.Utilities\n{\n internal static class TypeExtensions\n {\n public static MethodInfo GetGetMethod([NotNull] this PropertyInfo propertyInfo)\n {\n return propertyInfo.GetGetMethod(false);\n }\n\n public static MethodInfo GetGetMethod([NotNull] this PropertyInfo propertyInfo, bool nonPublic)\n {\n return null;\n }\n }\n}\n", + ) + .file( + "src/Reader.cs", + "using System.Reflection;\nusing App.Utilities;\n\nnamespace App\n{\n public class Reader\n {\n public MethodInfo Getter(PropertyInfo propertyInfo)\n {\n return propertyInfo.GetGetMethod(true);\n }\n }\n}\n", + ) + .index(); + let extensions = "src/Utilities/TypeExtensions.cs"; + assert_edge!( + attributed, + "method App::Reader::Getter" => calls => &format!("method App.Utilities::TypeExtensions::GetGetMethod @{extensions}:13") + ); + assert_no_edge!( + attributed, + "method App::Reader::Getter" => calls => &format!("method App.Utilities::TypeExtensions::GetGetMethod @{extensions}:8") + ); + } + + /// Upstream #2180 (`aa0c73c4`): a call through a class name reaches the + /// class method it inherits, by the supertypes its declaration heads name + /// (Pascal `class(…)`, Python bases), before any guess by name. + #[test] + fn pr2180_a_call_through_a_class_name_reaches_the_method_it_inherits() { + let project = Project::new() + .file( + "src/Horse.Core.pas", + "unit Horse.Core;\n\ninterface\n\ntype\n THorseCore = class\n public\n class function Get(const APath: string): THorseCore;\n end;\n\nimplementation\n\nclass function THorseCore.Get(const APath: string): THorseCore;\nbegin\n Result := nil;\nend;\n\nend.\n", + ) + .file( + "src/Horse.Core.Group.pas", + "unit Horse.Core.Group;\n\ninterface\n\ntype\n IHorseCoreGroup = interface\n function Get(const APath: string): IHorseCoreGroup;\n end;\n\nimplementation\n\nend.\n", + ) + .file( + "src/Horse.pas", + "unit Horse;\n\ninterface\n\nuses Horse.Core;\n\ntype\n THorseProvider = class(THorseCore)\n end;\n\n THorse = class(THorseProvider)\n end;\n\nimplementation\n\nend.\n", + ) + .file( + "samples/Console.dpr", + "program Console;\n\nuses Horse;\n\nbegin\n THorse.Get('/ping');\nend.\n", + ) + .file( + "app/views.py", + "class APIView:\n @classmethod\n def as_view(cls):\n return cls\n\n\nclass GenericAPIView(APIView):\n pass\n\n\nclass UserList(GenericAPIView):\n pass\n\n\nclass Router:\n def as_view(self):\n return None\n", + ) + .file( + "app/urls.py", + "from app.views import UserList\n\n\ndef routes():\n return [UserList.as_view()]\n", + ) + .index(); + + assert_edge!(project, "file:samples/Console.dpr" => calls => "method THorseCore::Get @src/Horse.Core.pas:8"); + assert_no_edge!(project, "file:samples/Console.dpr" => calls => "method IHorseCoreGroup::Get"); + // (In a function: a module-level assignment's initializer is not + // walked for calls, as before.) + assert_edge!(project, "function routes" => calls => "method APIView::as_view"); + assert_no_edge!(project, "function routes" => calls => "method Router::as_view"); + } + + /// Upstream #2175 (`c0a38a87`), #2197 (`85aad42f`) and #2221 (`ff190b30`, + /// its Strategy 3 half): a receiver-word guess is never a handler the web + /// framework dispatches to, never a test double the receiver and the + /// calling file do not name, and never the calling method itself. A test + /// that names its double still calls it. + #[test] + fn pr2175_a_receiver_guess_is_never_a_handler_a_double_or_the_caller() { + let project = Project::new() + .file( + "app/handlers.py", + "from django.views import View\n\n\nclass ClientRegistrationView(View):\n def post(self, request):\n return None\n\n\nclass SessionView(View):\n def post(self, request):\n return None\n", + ) + .file( + "tests/fakes.py", + "class _FakeHTTPResponse:\n def json(self):\n return {}\n\n\nclass Payload:\n def json(self):\n return {}\n", + ) + .file( + "tests/test_views.py", + "def test_login(client):\n resp = client.post(\"/login\")\n assert resp.status_code == 200\n\n\ndef test_api(api_client):\n response = api_client.get(\"/recipes\")\n data = response.json()\n assert data\n", + ) + .file( + "tests/mocking.py", + "class MockedResponse:\n def __init__(self, data):\n self.data = data\n\n def json(self):\n return self.data\n", + ) + .file( + "tests/test_mocking.py", + "from tests.mocking import MockedResponse\n\n\ndef test_mocked():\n resp = MockedResponse({\"ok\": True})\n assert resp.json()[\"ok\"]\n", + ) + .file( + "app/Uploads/FileStorage.php", + "getStorageDisk();\n $storage->delete($path);\n }\n\n protected function getStorageDisk()\n {\n return null;\n }\n}\n", + ) + .file( + "app/Uploads/ImageStorage.php", + " calls => "method ClientRegistrationView::post"); + assert_no_edge!(project, "function test_login" => calls => "method SessionView::post"); + assert_no_edge!(project, "function test_api" => calls => "method _FakeHTTPResponse::json"); + assert_no_edge!(project, "function test_api" => calls => "method MockedResponse::json"); + assert_edge!(project, "function test_mocked" => calls => "method MockedResponse::json"); + assert_no_edge!(project, "method FileStorage::delete" => calls => "method FileStorage::delete"); + } + + /// Upstream #2209 (`de9f346c`): a test suite is not linked into the + /// program, so production code never names one of its symbols, while + /// test-support code a project ships (`testing/`) stays in reach and a + /// test reaches another test's helpers. + #[test] + fn pr2209_production_code_never_names_a_test_suite_symbol() { + let project = Project::new() + .file("package.json", "{\"name\":\"orm\",\"private\":true}\n") + .file("test/functional/entity/Record.ts", "export class Record {\n id = 0;\n}\n") + .file( + "test/functional/helpers.ts", + "export function makeFixture(): number {\n return 1;\n}\n", + ) + .file( + "test/functional/record.test.ts", + "export function uses(): number {\n return makeFixture();\n}\n", + ) + .file( + "src/testing/stub.ts", + "export function stubConnection(): number {\n return 2;\n}\n", + ) + .file( + "src/columns.ts", + "export function toJson(values: Record): string {\n return JSON.stringify(values);\n}\n\nexport function connect(): number {\n return stubConnection();\n}\n", + ) + .file( + "src/main/java/app/Service.java", + "package app;\n\npublic class Service implements Runnable {\n @Override\n public void run() { }\n}\n", + ) + .file( + "src/test/java/app/InterceptorTest.java", + "package app;\n\npublic class InterceptorTest {\n @interface Override { }\n}\n", + ) + .index(); + + assert_no_edge!(project, "function toJson" => references => "class Record"); + assert_no_edge!(project, "method app::Service::run" => decorates => "interface app::InterceptorTest::Override"); + assert_edge!(project, "function connect" => calls => "function stubConnection"); + assert_edge!(project, "function uses" => calls => "function makeFixture"); + } + + /// Upstream #2421 (`22a7ba85`) and #2434 (`1fb8691d`): a C or C++ file + /// named like a test is visible to the code that includes it, a source + /// file's definition through the same-named header it includes + /// (`conformance_test.cc` through `conformance_test.h`); and Google's + /// `foo_unittest.cc` is a test suite production code never reaches. + #[test] + fn pr2421_an_included_test_named_file_is_part_of_the_translation_unit() { + let project = Project::new() + .file( + "conformance/conformance_test.h", + "class ConformanceTestSuite {\n public:\n void ReportFailure(int code);\n};\n", + ) + .file( + "conformance/conformance_test.cc", + "#include \"conformance_test.h\"\n\nvoid ConformanceTestSuite::ReportFailure(int code) {}\n", + ) + .file( + "conformance/binary_suite.cc", + "#include \"conformance_test.h\"\n\nvoid RunSuite(ConformanceTestSuite& suite_) {\n suite_.ReportFailure(1);\n}\n", + ) + .file( + "src/cpp/stack_unittest.cc", + "struct StackHelper {\n int size() { return 0; }\n};\n", + ) + .file( + "src/cpp/walk.cc", + "int Walk() {\n return StackHelper().size();\n}\n", + ) + .index(); + + assert_edge!(project, "function RunSuite" => calls => "method ConformanceTestSuite::ReportFailure"); + assert_no_edge!(project, "function Walk" => calls => "method StackHelper::size"); + assert_no_edge!(project, "function Walk" => instantiates => "struct StackHelper"); + } + + /// Upstream #2212 (`c20283e2`), #2213 (`5f1a6357`), #2238 (`31c5153d`) and + /// #2241 (`a5da8daf`): a call reaches the overload its arguments fit, + /// counted at the call (defaults and varargs counted at the declaration); + /// Swift by its argument labels. Real recursion keeps its self-edge. + #[test] + fn pr2212_a_call_reaches_the_overload_its_arguments_fit() { + let project = Project::new() + .file( + "src/main/java/app/Instants.java", + "package app;\n\npublic class Instants {\n public static Object toInstant(final Object instant) {\n return toInstant(instant, null);\n }\n\n public static Object toInstant(final Object instant, final Object defaultInstant) {\n return instant != null ? instant : defaultInstant;\n }\n\n public static int depth(final int n) {\n return n <= 0 ? 0 : depth(n - 1);\n }\n}\n", + ) + .file( + "src/main/java/app/HashCodeBuilder.java", + "package app;\n\npublic class HashCodeBuilder {\n public static int reflectionHashCode(final int initial, final int multiplier, final Object object) {\n return initial * multiplier;\n }\n\n public static int reflectionHashCode(final Object object, final String... excludeFields) {\n return 1;\n }\n}\n", + ) + .file( + "src/main/java/app/Point.java", + "package app;\n\npublic class Point {\n public int hash() {\n return HashCodeBuilder.reflectionHashCode(this);\n }\n}\n", + ) + .file( + "src/Json/Convert.cs", + "namespace App\n{\n public static class Convert\n {\n public static string DeserializeXNode(string value)\n {\n return DeserializeXNode(value, null);\n }\n\n public static string DeserializeXNode(string value, string root)\n {\n return value + root;\n }\n }\n}\n", + ) + .file( + "Sources/TableController.swift", + "class TableController {\n func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {\n return 0\n }\n\n func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {\n if self.tableView(tableView, numberOfRowsInSection: section) == 0 { return nil }\n return \"x\"\n }\n\n func cancel(producingResumeData shouldProduce: Bool) -> Self {\n return cancel(optionallyProducingResumeData: nil)\n }\n\n private func cancel(optionallyProducingResumeData handler: ((Data?) -> Void)?, retry: Bool = false) -> Self {\n return self\n }\n}\n", + ) + .file( + "contracts/access/AccessControl.sol", + "pragma solidity ^0.8.20;\n\nabstract contract AccessControl {\n function _checkRole(bytes32 role) internal view virtual {\n _checkRole(role, msg.sender);\n }\n\n function _checkRole(bytes32 role, address account) internal view virtual {\n }\n}\n", + ) + .index(); + + let java = "src/main/java/app/Instants.java"; + assert_edge!(project, &format!("method app::Instants::toInstant @{java}:4") => calls => &format!("method app::Instants::toInstant @{java}:8")); + assert_no_edge!(project, &format!("method app::Instants::toInstant @{java}:4") => calls => &format!("method app::Instants::toInstant @{java}:4")); + assert_edge!(project, "method app::Instants::depth" => calls => "method app::Instants::depth"); + let builder = "src/main/java/app/HashCodeBuilder.java"; + assert_edge!(project, "method app::Point::hash" => calls => &format!("method app::HashCodeBuilder::reflectionHashCode @{builder}:8")); + assert_no_edge!(project, "method app::Point::hash" => calls => &format!("method app::HashCodeBuilder::reflectionHashCode @{builder}:4")); + let cs = "src/Json/Convert.cs"; + assert_edge!(project, &format!("method App::Convert::DeserializeXNode @{cs}:5") => calls => &format!("method App::Convert::DeserializeXNode @{cs}:10")); + let swift = "Sources/TableController.swift"; + assert_edge!(project, &format!("method TableController::tableView @{swift}:6") => calls => &format!("method TableController::tableView @{swift}:2")); + assert_edge!(project, &format!("method TableController::cancel @{swift}:11") => calls => &format!("method TableController::cancel @{swift}:15")); + let sol = "contracts/access/AccessControl.sol"; + assert_edge!(project, &format!("method AccessControl::_checkRole @{sol}:4") => calls => &format!("method AccessControl::_checkRole @{sol}:8")); + } + + /// Upstream #2415 (`19f91e27`), #2211 (`5c01f017`) and #2210 (`d7cdb0c0`): + /// a framework resolver answers only references written in its own + /// languages. A React app's hook and context rules never take a Python + /// or Java `useSession()` / `SecurityContext`, and the Vue resolver never + /// takes a Python call for a `.vue` component; the app's own TSX hook call + /// still resolves. + #[test] + fn pr2415_a_framework_resolves_only_its_own_languages() { + let project = Project::new() + .file( + "package.json", + "{\"name\":\"web\",\"dependencies\":{\"react\":\"^19.0.0\",\"vue\":\"^3.0.0\"}}\n", + ) + .file( + "src/hooks/useSession.tsx", + "export function useSession() {\n return null;\n}\n\nexport const SecurityContext = { user: null };\n", + ) + .file( + "src/App.tsx", + "import { useSession } from './hooks/useSession';\n\nexport function App() {\n const session = useSession();\n return
      {String(session)}
      ;\n}\n", + ) + .file( + "src/components/QueryFilterBuilder.vue", + "\n\n", + ) + .file( + "backend/session.py", + "def handle():\n useSession()\n return SecurityContext()\n\n\ndef build():\n return QueryFilterBuilder()\n", + ) + .file( + "server/src/main/java/app/Auth.java", + "package app;\n\npublic class Auth {\n public void check() {\n useSession();\n }\n}\n", + ) + .index(); + + assert_edge!(project, "function App" => calls => "function useSession"); + assert_no_edge!(project, "function handle" => calls => "function useSession"); + assert_no_edge!(project, "function handle" => calls => "constant SecurityContext"); + assert_no_edge!(project, "method app::Auth::check" => calls => "function useSession"); + assert_no_edge!( + project, + "function build" => calls => "component src/components/QueryFilterBuilder.vue::QueryFilterBuilder" + ); + } + /// Upstream #2123 (`c5ba6807`): a name an enclosing declaration declares + /// as a type parameter is that parameter (Scala `def zipWith[A, B, C]`, + /// Java ` T max(`, TS `wrap`, Dart `first`), a Scala + /// def's value parameter is that parameter (`f(fa.head)`), and a type + /// declared inside a function is only in scope in there. A bare Scala + /// type name reaches another type's member only from inside it, a + /// subtype, or a file importing its members; otherwise the type of that + /// name in scope is taken (`trait FlatMap`, not `Eval.FlatMap`). + #[test] + fn pr2123_type_parameters_and_function_local_types_stay_in_scope() { + let project = Project::new() + .file("src/Inst.scala", "package app\nobject Inst {\n implicit def A: Int = 1\n}\n") + .file( + "src/Seqs.scala", + "package app\nobject Seqs {\n def zipWith[A, B, C](fa: List[A], fb: List[B])(f: (A, B) => C): List[C] = ???\n}\n", + ) + .file( + "src/Ops.scala", + "package app\nobject Ops {\n def |+|[A](x: A, y: A): A = x\n def *[B](b: B): B = b\n}\ntrait Heyting[@sp(Int, Long) A] {\n def and(a: A, b: A): A\n}\nfinal case class FlatMapped(f: Int => Int) {\n type A = Int\n}\ntrait Laws {\n type L = Int\n}\nclass MyLaws extends Laws {\n def check(x: L): Boolean = true\n}\nobject UsesImport {\n import FlatMapped._\n def viaImport(x: A): Int = 1\n}\nobject Folds {\n def foldMap[A](fa: List[A])(f: A => Int)(implicit G: Ordering[Int]): Int = { f(fa.head); G.max(1, 2) }\n}\nobject TraverseLaws {\n def updatedRef[A, B >: A](fa: List[A], b: B): Option[B] = None\n}\ntrait UnorderedTraverseTests {\n def unorderedTraverse[\n A,\n B,\n C,\n X,\n Y\n ](implicit\n ArbXB: Option[X],\n ArbYB: List[B]\n ): Unit = ()\n}\n", + ) + .file( + "src/Elsewhere.scala", + "package app\nobject Elsewhere {\n def combineAll(xs: List[A]): Int = 1\n def lift(implicit F: FlatMap): Int = 1\n}\n", + ) + .file("src/FlatMap.scala", "package app\ntrait FlatMap\n") + .file("src/Eval.scala", "package app\nobject Eval {\n final case class FlatMap(x: Int)\n}\n") + .file("src/Fns.scala", "package app\nobject Fns {\n def f(x: Int): Int = x\n}\n") + .file( + "src/SyntaxSuite.scala", + "package app\nclass SyntaxSuite {\n def testOptionEmpty(): Unit = {\n case class B()\n val b: B = B()\n }\n}\n", + ) + .file("src/T.java", "package app;\npublic class T {}\n") + .file("src/Foo.java", "package app;\npublic class Foo {}\n") + .file( + "src/Util.java", + "package app;\nimport java.util.List;\nimport java.util.Map;\npublic class Util {\n public static > T max(List xs) { return null; }\n public static Map index(List xs) { return null; }\n}\n", + ) + .file("src/wrap.ts", "export class T {}\nexport function wrap(x: T): T { return x; }\n") + .file( + "src/local.ts", + "export function build() {\n class Local {}\n return new Local();\n}\n\nexport function other(): Local {\n return null as any;\n}\n", + ) + .file("lib/first.dart", "const T = 1;\nT first(List xs) => xs.first;\n") + .index(); + + let local_b = "class SyntaxSuite::testOptionEmpty::B"; + for owner in [ + "method Seqs::zipWith", + "method Ops::*", + "method TraverseLaws::updatedRef", + "method UnorderedTraverseTests::unorderedTraverse", + ] { + assert_no_edge!(project, owner => references => local_b); + } + assert_no_edge!(project, "method Seqs::zipWith" => references => "method Inst::A"); + assert_no_edge!(project, "method Elsewhere::combineAll" => references => "method Inst::A"); + for owner in [ + "method Ops::|+|", + "method Heyting::and", + "method Folds::foldMap", + "method TraverseLaws::updatedRef", + ] { + assert_no_edge!(project, owner => references => "type_alias FlatMapped::A"); + } + assert_no_edge!(project, "method Folds::foldMap" => calls => "method Fns::f"); + assert_edge!(project, "method Elsewhere::lift" => references => "trait FlatMap"); + assert_no_edge!(project, "method Elsewhere::lift" => references => "class Eval::FlatMap"); + assert_edge!(project, "method MyLaws::check" => references => "type_alias Laws::L"); + assert_edge!(project, "method UsesImport::viaImport" => references => "type_alias FlatMapped::A"); + assert_edge!(project, "method SyntaxSuite::testOptionEmpty" => instantiates => local_b); + + assert_no_edge!(project, "method app::Util::max" => references => "class app::T"); + assert_edge!(project, "method app::Util::index" => references => "class app::Foo"); + assert_no_edge!(project, "function wrap" => references => "class T"); + assert_no_edge!(project, "function first" => references => "constant T"); + assert_edge!(project, "function build" => instantiates => "class build::Local"); + assert_no_edge!(project, "function other" => references => "class build::Local"); + } + + /// Upstream #2219 (`03123335`): a name passed as a value is what is in + /// scope where it is written. A function nested in another function is + /// reached only from inside it (httpx's `self._build_auth(auth)` passes + /// its parameter, not a test's nested `auth`); a Python name the function + /// around it binds is that local's (`auth_flow(self, request)` handing + /// `request` on is not the package's `request()`); a pytest fixture is a + /// test's parameter of its name in its own module or under its + /// `conftest.py`. The `_run_both(func)` call half of the upstream test is + /// a call, not a value: it lands with Python's bare-call rule (W4-PY-01). + #[test] + fn pr2219_a_name_passed_as_a_value_is_what_is_in_scope_where_it_is_written() { + let project = Project::new() + .file( + "httpx/_client.py", + "class Client:\n @property\n def auth(self):\n return self._auth\n\n def _build_auth(self, auth):\n return auth\n\n def set_auth(self, auth):\n self._auth = self._build_auth(auth)\n", + ) + .file("httpx/_api.py", "def request(method, url):\n return method, url\n") + .file( + "httpx/_auth.py", + "class Auth:\n def auth_flow(self, request):\n yield request\n\n def sync_auth_flow(self, request):\n flow = self.auth_flow(request)\n return flow\n\n\nclass Recorder:\n def request(self):\n return None\n", + ) + .file( + "tests/test_auth.py", + "def test_custom_auth():\n def auth(request):\n return request\n\n return register(auth)\n\n\ndef test_basic_auth():\n auth = (\"user\", \"pass\")\n return register(auth)\n", + ) + .file( + "docs/example/test_order.py", + "import pytest\n\n\n@pytest.fixture\ndef func():\n return 1\n", + ) + .file( + "tests/test_coverage.py", + "import pytest\n\n\ndef _run_both(func):\n return func()\n\n\n@pytest.fixture(\n scope=\"module\",\n)\ndef recipe():\n return {}\n\n\ndef test_recipe(recipe):\n return use(recipe)\n", + ) + .index(); + + let nested = "function test_custom_auth::auth"; + assert_no_edge!(project, "method Client::_build_auth" => references => nested); + assert_no_edge!(project, "method Client::set_auth" => references => nested); + assert_no_edge!(project, "function test_basic_auth" => references => nested); + assert_edge!(project, "function test_custom_auth" => references => nested); + assert_no_edge!(project, "method Auth::sync_auth_flow" => references => "function request"); + assert_edge!(project, "function test_recipe" => references => "function recipe @tests/test_coverage.py"); + } + /// Upstream #2122 (`068c422e`) and #2204 (`e7be363f`): a script a bundler + /// or minifier wrote (named `*.min.js` / `*-min.js`, or a few enormous + /// lines of code) is generated, and no name written in another file + /// resolves into it: jQuery's global `$(…)` never reaches a vendored + /// bundle's one-letter helper. Calls inside a bundle still resolve, and + /// an import still binds what it names. + #[test] + fn pr2204_no_name_from_another_file_resolves_into_a_minified_script() { + let functions: Vec = (0..120) + .map(|i| format!("function f{i}(e,t){{return e+t+{i}}}")) + .collect(); + let bundle = format!( + "!function(){{{};function u(e){{return f1(e,2)}}}}();\n", + functions.join(";") + ) + .repeat(4); + let project = Project::new() + .file( + "static/js/vendor.min.js", + "function $(e){return document.querySelector(e)}function q(e){return $(e)}\n", + ) + .file("static/js/bundle.js", &bundle) + .file("static/js/lib-min.js", "export function debounce(e){return e}\n") + .file( + "static/js/app.js", + "function init() {\n $(\"#edit-name\").click(function () {});\n $('#app');\n u(\"x\");\n}\n", + ) + .file( + "src/main.js", + "import { debounce } from '../static/js/lib-min.js';\n\nexport function run() {\n return debounce(1);\n}\n", + ) + .index(); + + assert_no_edge!(project, "function init" => calls => "function $ @static/js/vendor.min.js"); + assert_no_edge!(project, "function init" => calls => "function u @static/js/bundle.js"); + assert_unresolved!(project, "function init" => calls => "$"); + assert_unresolved!(project, "function init" => calls => "u"); + assert_edge!(project, "function q" => calls => "function $ @static/js/vendor.min.js"); + assert_edge!( + project, + "function u @static/js/bundle.js:1" => calls => "function f1 @static/js/bundle.js:1" + ); + assert_edge!( + project, + "function run" => calls => "function debounce @static/js/lib-min.js", + resolved_by = "import" + ); + let generated: Vec<(&str, bool)> = project + .graph() + .files + .iter() + .map(|file| (file.path.as_str(), file.generated)) + .collect(); + assert_eq!( + generated, + [ + ("src/main.js", false), + ("static/js/app.js", false), + ("static/js/bundle.js", true), + ("static/js/lib-min.js", true), + ("static/js/vendor.min.js", true), + ] + ); + } +} + +/// W4a: the JavaScript/TypeScript resolution family (plan section 3 "W4a"). +/// `cargo test --test probes -- js::` runs them. +mod js { + use super::support::{Graph, NodeRow, Project, assert_edge, assert_no_edge}; + + /// The qualified names `calls` edges from any node of `file` reach, + /// sorted (upstream's `callsFrom`). + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut targets: Vec = graph + .edges + .iter() + .filter(|edge| { + edge.kind == "calls" + && graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + targets.sort(); + targets + } + + /// Upstream #2128 (`1ef1eb88`): calls in Vue, Svelte and Astro files + /// follow the JS/TS rules, and "outside the repository" is a package the + /// importing file's package.json chain (or a Deno import map) declares, a + /// Node built-in, `@std/…` or a framework's virtual module. An alias the + /// resolver cannot follow (`~/…`, SvelteKit's `$lib/…`) stays the + /// project's, a `workspace:` dependency is in the repository, SFC files + /// get the JS built-ins, and a file's own import of a built-in's name is + /// the import. + #[test] + fn pr2128_sfc_calls_follow_the_js_rules_and_an_unknown_alias_stays_the_projects() { + let project = Project::new() + .file( + "frontend/package.json", + r#"{"name":"frontend","dependencies":{"vue-i18n":"*","dayjs":"*","@app/shared":"workspace:*"}}"#, + ) + .file( + "shared/index.ts", + "export function formatUrl(u: string) { return u; }\n", + ) + .file( + "frontend/types/context.ts", + "export interface ProvidersContext {\n t: (key: string) => string;\n}\n", + ) + .file( + "frontend/test/mocks.ts", + "export function dayjs() { return 0; }\n", + ) + .file( + "frontend/composables/store.ts", + "export function useLabelStore() { return {}; }\n", + ) + .file( + "frontend/composables/use-labels.ts", + "import { useLabelStore } from '~/composables/store';\nexport function useLabels() { return useLabelStore(); }\n", + ) + .file( + "frontend/pages/labels.vue", + "\n\n", + ) + .file( + "docs/package.json", + r#"{"name":"docs","devDependencies":{"@sveltejs/kit":"*"}}"#, + ) + .file( + "docs/src/lib/design.ts", + "export function useDesignSystem() { return 1; }\n", + ) + .file( + "docs/src/lib/router.ts", + "export function goto(path: string) { return path; }\n", + ) + .file( + "docs/src/lib/use.ts", + "import { useDesignSystem } from '$lib/design';\nexport const current = () => useDesignSystem();\n", + ) + .file( + "docs/src/lib/instance.js", + "export const DEV = true;\nif (DEV) {\n const fetch = globalThis.fetch;\n globalThis.fetch = async (info) => fetch(info);\n}\n", + ) + .file("docs/src/lib/Map.svelte", "
      map
      \n") + .file( + "docs/src/routes/+page.svelte", + "\n\n", + ) + .file( + "deno/deno.json", + r#"{"imports":{"@std/assert":"jsr:@std/assert@^1","app/":"../src/"}}"#, + ) + .file( + "deno/test/assert.ts", + "export function assertEquals(a: unknown, b: unknown) { return a === b; }\n", + ) + .file( + "deno/app.test.ts", + "import { assertEquals } from '@std/assert';\nassertEquals(1, 1);\n", + ) + .index(); + let graph = project.graph(); + + let from_vue = calls_from(graph, "frontend/pages/labels.vue"); + assert!( + !from_vue.iter().any(|q| q == "ProvidersContext::t"), + "{from_vue:?}" + ); + assert!(!from_vue.iter().any(|q| q == "dayjs"), "{from_vue:?}"); + assert!( + from_vue.iter().any(|q| q == "useLabelStore"), + "{from_vue:?}" + ); + // A `workspace:*` dependency is in the repository. + assert!(from_vue.iter().any(|q| q == "formatUrl"), "{from_vue:?}"); + let from_labels = calls_from(graph, "frontend/composables/use-labels.ts"); + assert!( + from_labels.iter().any(|q| q == "useLabelStore"), + "{from_labels:?}" + ); + + let from_svelte = calls_from(graph, "docs/src/routes/+page.svelte"); + assert!( + from_svelte.iter().any(|q| q == "useDesignSystem"), + "{from_svelte:?}" + ); + // SvelteKit's `$app/…` is the framework's; `fetch` is a JS built-in. + assert!(!from_svelte.iter().any(|q| q == "goto"), "{from_svelte:?}"); + assert!(!from_svelte.iter().any(|q| q == "fetch"), "{from_svelte:?}"); + let from_use = calls_from(graph, "docs/src/lib/use.ts"); + assert!( + from_use.iter().any(|q| q == "useDesignSystem"), + "{from_use:?}" + ); + + // Deno's import map says `@std/assert` comes from JSR. + let from_deno = calls_from(graph, "deno/app.test.ts"); + assert!( + !from_deno.iter().any(|q| q == "assertEquals"), + "{from_deno:?}" + ); + + // A built-in's name imported from the project is the import. + assert_edge!(project, "file:docs/src/routes/+page.svelte" => imports => "file:docs/src/lib/Map.svelte"); + assert_no_edge!(project, "file:docs/src/routes/+page.svelte" => imports => "file:docs/src/lib/router.ts"); + } + + /// The `file:name` of every non-`contains` edge target from the nodes + /// `select` picks (upstream's `targetsFrom`). + fn targets_from( + graph: &Graph, + select: impl Fn(&super::support::NodeRow) -> bool, + ) -> Vec { + let mut targets: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind != "contains" && graph.node(&edge.source).is_some_and(&select)) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{}:{}", node.file_path, node.name)) + .collect(); + targets.sort(); + targets + } + + /// Upstream #2162 (`d03e4dbf`): a top-level binding an ES module does not + /// export is not reached from another file; every export form still is + /// (`export { a as b }`, `export default x`, `export default { a, b }`), + /// and so are a function assigned onto a prototype and classic scripts. + #[test] + fn pr2162_an_unexported_module_binding_is_not_reached_from_another_file() { + let project = Project::new() + .file("package.json", r#"{"name":"app","type":"module"}"#) + .file( + "src/manifest.js", + "import { join } from 'node:path';\nfunction resolve(event) { return join(event); }\nexport function manifest() { return resolve('x'); }\n", + ) + .file( + "src/adapters.js", + "import { a } from './a.js';\nfunction getAdapter(name) { return name; }\nexport default { getAdapter };\n", + ) + .file( + "src/listed.js", + "import { a } from './a.js';\nfunction helper() { return 1; }\nexport { helper };\n", + ) + .file( + "src/proto.js", + "import { a } from './a.js';\nfunction Params() {}\nconst prototype = Params.prototype;\nprototype.render = function render() { return ''; };\nexport default Params;\n", + ) + .file("src/a.js", "export const a = 1;\n") + .file( + "src/use.js", + "import { helper } from './listed.js';\nimport Params from './proto.js';\nexport async function handle(event) {\n await resolve(event);\n getAdapter('xhr');\n helper();\n new Params().render();\n}\n", + ) + .file( + "src/classic.js", + "function legacy() { return 1; }\n", + ) + .file( + "src/old.js", + "export function old() { return legacy(); }\n", + ) + .index(); + let graph = project.graph(); + let from_handle = targets_from(graph, |node| { + node.name == "handle" && node.file_path == "src/use.js" + }); + assert!( + !from_handle.iter().any(|t| t == "src/manifest.js:resolve"), + "{from_handle:?}" + ); + assert!( + from_handle + .iter() + .any(|t| t == "src/adapters.js:getAdapter"), + "{from_handle:?}" + ); + assert!( + from_handle.iter().any(|t| t == "src/listed.js:helper"), + "{from_handle:?}" + ); + assert!( + from_handle.iter().any(|t| t == "src/proto.js:render"), + "{from_handle:?}" + ); + // A classic script (no import, no export) keeps its globals. + let from_old = targets_from(graph, |node| node.name == "old"); + assert!( + from_old.iter().any(|t| t == "src/classic.js:legacy"), + "{from_old:?}" + ); + // The module's own call still reaches it. + assert_edge!(project, "function manifest" => calls => "function resolve @src/manifest.js"); + } + + /// Upstream #2227 (`3e6252f3`): what a Svelte instance `\n\n
      {items.length}
      \n", + ) + .file( + "src/lib/examples/item-demo.svelte", + "\n\n{label}\n", + ) + .file( + "src/components/TabItem.vue", + "\n\n\n", + ) + .file( + "src/extensions/code-block.ts", + "import { isActive } from \"@/tiptap/core\";\n\nexport function active(state: unknown) {\n return isActive(state);\n}\n", + ) + .file( + "src/components/CrudTable.vue", + "\n\n\n", + ) + .file( + "src/pages/GroupDataPage.vue", + "\n\n\n", + ) + .index(); + let graph = project.graph(); + let in_file = + |file: &'static str| move |node: &super::support::NodeRow| node.file_path == file; + let from_demo = targets_from(graph, in_file("src/lib/examples/item-demo.svelte")); + assert!( + !from_demo + .iter() + .any(|t| t == "src/lib/examples/attachment-group.svelte:Item"), + "{from_demo:?}" + ); + let from_block = targets_from(graph, in_file("src/extensions/code-block.ts")); + assert!( + !from_block + .iter() + .any(|t| t == "src/components/TabItem.vue:isActive"), + "{from_block:?}" + ); + let from_page = targets_from(graph, in_file("src/pages/GroupDataPage.vue")); + assert!( + from_page + .iter() + .any(|t| t == "src/components/CrudTable.vue:TableConfig"), + "{from_page:?}" + ); + } + + /// Upstream #2226 (`83dac15c`): a JS/TS name the calling function binds + /// itself (a parameter, or a `var` / `let` / `const` above the reference) + /// is that local, never a same-named function declared elsewhere in the + /// file; an unbound name and a `const`'s own recursion are left alone, and + /// `if (handler) handler()` is no parameter list. + #[test] + fn pr2226_a_name_the_calling_function_binds_is_its_local() { + let project = Project::new() + .file( + "lodash.js", + "function runInContext(context) {\n var baseCreate = (function() {\n function object() {}\n return function(proto) {\n object.prototype = proto;\n return new object;\n };\n }());\n\n function baseHas(object, key) {\n return object != null && hasOwnProperty.call(object, key);\n }\n\n function mixin(object, source) {\n var result = object(this.__wrapped__);\n return result;\n }\n\n function handler() {}\n\n function run() {\n if (handler) {\n handler();\n }\n const walk = (node) => (node ? walk(node.next) : null);\n return walk(context);\n }\n\n return { baseCreate, baseHas, mixin, run };\n}\n", + ) + .index(); + let graph = project.graph(); + let named = |qualified: &'static str| { + move |node: &super::support::NodeRow| node.qualified_name == qualified + }; + let qualified_targets = |qualified: &'static str| -> Vec { + let mut targets: Vec = graph + .edges + .iter() + .filter(|edge| { + edge.kind != "contains" + && graph.node(&edge.source).is_some_and(named(qualified)) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + targets.sort(); + targets + }; + let from_base_has = qualified_targets("runInContext::baseHas"); + assert!( + !from_base_has.iter().any(|t| t == "runInContext::object"), + "{from_base_has:?}" + ); + let from_mixin = qualified_targets("runInContext::mixin"); + assert!( + !from_mixin.iter().any(|t| t == "runInContext::object"), + "{from_mixin:?}" + ); + let from_run = qualified_targets("runInContext::run"); + assert!( + from_run.iter().any(|t| t == "runInContext::handler"), + "{from_run:?}" + ); + let from_walk = qualified_targets("runInContext::run::walk"); + assert!( + from_walk.iter().any(|t| t == "runInContext::run::walk"), + "{from_walk:?}" + ); + } + + /// Upstream #2163 (`cf449fda`): a bare call through a name destructured + /// from a call's result reaches the function the callee returns under + /// that key: one declared in the callee's body, else a module-level one + /// it returns. A later declaration of the name at the call's scope + /// shadows the binding. + #[test] + fn pr2163_a_name_destructured_from_a_call_reaches_what_the_callee_returns() { + let project = Project::new() + .file("package.json", r#"{"name":"app","dependencies":{"vue":"^3"}}"#) + .file( + "src/composables/use-default-activity.ts", + "function getDefaultActivityRoute(key?: string): string {\n return key ?? '/';\n}\nexport default function useDefaultActivity() {\n return { getDefaultActivityRoute };\n}\n", + ) + .file( + "src/hooks/useAuth.ts", + "export function useAuth() {\n function login(user: string) {\n return user;\n }\n const logout = () => null;\n return { login, signOut: logout };\n}\n", + ) + .file( + "src/pages/index.ts", + "import useDefaultActivity from '../composables/use-default-activity';\nimport { useAuth } from '../hooks/useAuth';\nexport function go() {\n const { getDefaultActivityRoute } = useDefaultActivity();\n const { login, signOut: leave } = useAuth();\n login('ada');\n leave();\n return getDefaultActivityRoute('x');\n}\nexport function shadowed() {\n const { login } = useAuth();\n {\n const login = (name: string) => name;\n return login('bob');\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let from_go = targets_from(graph, |node| node.name == "go"); + assert!( + from_go + .iter() + .any(|t| t == "src/composables/use-default-activity.ts:getDefaultActivityRoute"), + "{from_go:?}" + ); + assert!( + from_go.iter().any(|t| t == "src/hooks/useAuth.ts:login"), + "{from_go:?}" + ); + let from_shadowed = targets_from(graph, |node| node.name == "shadowed"); + assert!( + !from_shadowed + .iter() + .any(|t| t == "src/hooks/useAuth.ts:login"), + "{from_shadowed:?}" + ); + } + + /// Upstream #2357 (`8998697f`) (a): a destructuring declaration at module + /// or `\n\n", + ) + .index(); + assert_edge!(project, "file:src/app.ts" => calls => "function useFoo"); + assert_edge!(project, "component src/components/Panel.vue::Panel" => calls => "function useFoo"); + } + + /// Upstream #2357 (d): a file's own function shadows a Nuxt auto-import + /// of its name: elk's lists page calls its own `clearError(true)`, not + /// Nuxt's, and the call is no edge from the caller to itself. + #[test] + fn pr2357_a_files_own_function_shadows_a_nuxt_auto_import() { + let project = Project::new() + .file("package.json", r#"{"name":"app","dependencies":{"nuxt":"^3","vue":"^3"}}"#) + .file("nuxt.config.ts", "export default defineNuxtConfig({});\n") + .file( + "pages/lists.vue", + "\n\n", + ) + .index(); + assert_edge!(project, "function retry" => calls => "function clearError @pages/lists.vue"); + assert_no_edge!(project, "function retry" => calls => "function retry"); + } + + /// The distinct files an `imports` edge from any node of `file` reaches, + /// other than `file` itself. + fn imported_files(graph: &Graph, file: &str) -> Vec { + let mut files: Vec = graph + .edges + .iter() + .filter(|edge| { + edge.kind == "imports" + && graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.file_path.clone()) + .filter(|path| path != file) + .collect(); + files.sort(); + files.dedup(); + files + } + + /// `kind file:name` of the calls and instantiations out of `file`'s nodes. + fn out_of(graph: &Graph, file: &str) -> Vec { + let mut edges: Vec = graph + .edges + .iter() + .filter(|edge| { + (edge.kind == "calls" || edge.kind == "instantiates") + && graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| { + graph + .node(&edge.target) + .map(|target| format!("{} {}:{}", edge.kind, target.file_path, target.name)) + }) + .collect(); + edges.sort(); + edges + } + + /// `kind source-file resolvedBy` of the edges of `kinds` into the node of + /// `file` named `name` (an import node aside). + fn into(graph: &Graph, file: &str, name: &str, kinds: &[&str]) -> Vec { + let target = graph + .nodes + .iter() + .find(|node| node.file_path == file && node.name == name && node.kind != "import") + .unwrap_or_else(|| panic!("{file}: {name}")); + let mut edges: Vec = graph + .edges + .iter() + .filter(|edge| edge.target == target.id && kinds.contains(&edge.kind.as_str())) + .map(|edge| { + format!( + "{} {} {}", + edge.kind, + graph + .node(&edge.source) + .map_or("?", |node| node.file_path.as_str()), + edge.resolved_by().unwrap_or("?") + ) + }) + .collect(); + edges.sort(); + edges + } + + /// Upstream #2263 (`c4babfce`), its extraction half: a CommonJS + /// `require('./x')` imports the file it names, as ESM's `import` does, + /// used as a value or not; a package stays outside the index, and a + /// directory require reaches its package entry (with #2189's root + /// `index.js`). + #[test] + fn pr2263_a_commonjs_require_imports_the_file_it_names() { + let project = Project::new() + .file("package.json", "{\"name\":\"web\",\"main\":\"index.js\"}") + .file("index.js", "module.exports = require('./lib/express');\n") + .file("lib/express.js", "var EventEmitter = require('events').EventEmitter;\nvar mixin = require('merge-descriptors');\nvar proto = require('./application');\nvar req = require('./request');\n\nfunction createApplication() {\n var app = function () {};\n mixin(app, EventEmitter.prototype, false);\n mixin(app, proto, false);\n app.request = Object.create(req);\n return app;\n}\n\nmodule.exports = createApplication;\n") + .file("lib/application.js", "var app = exports = module.exports = {};\napp.init = function init() {};\n") + .file("lib/request.js", "var req = Object.create({});\nmodule.exports = req;\n") + .file("test/app.js", "var express = require('..');\nvar assert = require(\"assert\");\n") + .file("src/main.ts", "import './polyfills';\nimport helper = require('./helper');\nexport const value = helper.run();\n") + .file("src/polyfills.ts", "export {};\n") + .file("src/helper.ts", "export function run(): number {\n return 1;\n}\n") + .index(); + let graph = project.graph(); + assert_eq!( + imported_files(graph, "lib/express.js"), + ["lib/application.js", "lib/request.js"] + ); + assert_eq!(imported_files(graph, "index.js"), ["lib/express.js"]); + assert_eq!(imported_files(graph, "test/app.js"), ["index.js"]); + assert!( + imported_files(graph, "src/main.ts") + .iter() + .any(|f| f == "src/polyfills.ts") + ); + let to_helper = graph + .edges + .iter() + .filter(|edge| { + edge.kind == "imports" + && graph + .node(&edge.source) + .is_some_and(|node| node.file_path == "src/main.ts") + && graph.node(&edge.target).is_some_and(|node| { + node.file_path == "src/helper.ts" && node.kind == "file" + }) + }) + .count(); + assert!(to_helper <= 1, "{to_helper}"); + } + + /// Upstream #2189 (`e77ca836`): calls through CommonJS `require` bindings + /// follow `module.exports` through the root `index.js` to the exported + /// function, read every declarator of a list, take `require(…).member` as + /// that member, and leave a package's call without an edge. + #[test] + fn pr2189_calls_through_commonjs_require_bindings_reach_the_right_function() { + let project = Project::new() + .file("package.json", "{\"name\":\"web\",\"main\":\"index.js\",\"devDependencies\":{\"supertest\":\"^6\"}}") + .file("index.js", "'use strict';\n\nmodule.exports = require('./lib/express');\n") + .file("lib/express.js", "'use strict';\n\nexports = module.exports = createApplication;\n\nfunction createApplication() {\n return {};\n}\n") + .file("lib/utils.js", "'use strict';\n\nexports.normalizeType = function (type) {\n return type;\n};\n\nexports.setCharset = function setCharset(type, charset) {\n return type + charset;\n};\n") + .file("lib/response.js", "'use strict';\n\nvar normalizeType = require('./utils').normalizeType;\nvar setCharset = require('./utils').setCharset;\n\nfunction send(body) {\n return setCharset(normalizeType(body), 'utf-8');\n}\n\nmodule.exports = { send };\n") + .file("test/app.js", "'use strict';\n\nvar express = require('../')\n , request = require('supertest');\n\ndescribe('app', function () {\n it('responds', function () {\n var app = express();\n request(app);\n });\n});\n") + .index(); + let graph = project.graph(); + let calls_out = |file: &str| -> Vec { + let mut calls: Vec = graph + .edges + .iter() + .filter(|edge| { + edge.kind == "calls" + && graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{}: {}", node.file_path, node.qualified_name)) + .collect(); + calls.sort(); + calls + }; + assert_eq!( + calls_out("test/app.js"), + ["lib/express.js: createApplication"] + ); + assert_eq!( + calls_out("lib/response.js"), + ["lib/utils.js: normalizeType", "lib/utils.js: setCharset"] + ); + } + + /// Upstream #2433 (`9a1d4bd9`): `require('./x').default` and + /// `const { default: X } = require('./x')` read the module's `default` + /// property: what `exports.default` names in a CommonJS module, else an + /// ES module's default export (in the module asked for only), and nothing + /// for a CommonJS module that sets no `default`. + #[test] + fn pr2433_require_default_reaches_the_modules_default_export() { + let project = Project::new() + .file("package.json", "{\"name\":\"desktop\",\"devDependencies\":{\"svelte\":\"^4\"}}") + .file("src/biometrics/os-biometrics.service.ts", "export interface OsBiometricService {\n enrollPersistent(userId: string): Promise;\n}\n") + .file("src/biometrics/os-biometrics-mac.service.ts", "import { OsBiometricService } from './os-biometrics.service';\n\nfunction getLookupKeyForUser(userId: string): string {\n return `${userId}_user_biometric`;\n}\n\nexport default class OsBiometricsServiceMac implements OsBiometricService {\n async enrollPersistent(userId: string): Promise {\n return getLookupKeyForUser(userId);\n }\n}\n") + .file("src/biometrics/main-biometrics.service.ts", "import { OsBiometricService } from './os-biometrics.service';\n\nexport class MainBiometricsService {\n private osBiometricsService: OsBiometricService | undefined;\n\n constructor(platform: string) {\n if (platform === 'darwin') {\n // eslint-disable-next-line\n const OsBiometricsServiceMac = require('./os-biometrics-mac.service').default;\n this.osBiometricsService = new OsBiometricsServiceMac();\n }\n }\n}\n") + .file("src/biometrics/os-biometrics-mac.service.spec.ts", "describe('OsBiometricsServiceMac', () => {\n it('enrolls in an isolated module registry', async () => {\n const { default: ServiceCtor } = require('./os-biometrics-mac.service');\n const isolated = new ServiceCtor();\n await isolated.enrollPersistent('user');\n });\n});\n") + .file("src/App.svelte", "\n\n

      Hello {name}!

      \n") + .file("src/main.js", "const App = require('./App.svelte').default;\n\nconst app = new App({ target: document.body });\n\nmodule.exports = app;\n") + .file("lib/format.js", "'use strict';\n\nexports.pad = function (value) {\n return String(value).padStart(2, '0');\n};\n\nfunction formatDate(date) {\n return exports.pad(date.getDate());\n}\n\nexports.default = formatDate;\n") + .file("lib/server.js", "'use strict';\n\nfunction createServer(options) {\n return { options };\n}\n\nmodule.exports = createServer;\nmodule.exports.default = createServer;\nmodule.exports.createServer = createServer;\n") + .file("lib/plain.js", "'use strict';\n\nexports.first = function () {\n return 1;\n};\n\nexports.second = () => 2;\n") + .file("src/widgets/Dial.ts", "export const DIAL_SIZE = 3;\n\nexport default class Dial {\n size = DIAL_SIZE;\n}\n") + .file("src/widgets/index.ts", "export * from './Dial';\n") + .file("src/dashboard.ts", "export function mountDashboard(): unknown {\n const Widget = require('./widgets').default;\n return new Widget();\n}\n") + .file("lib/app.js", "'use strict';\n\nconst format = require('./format').default;\nconst build = require('./server').default;\nconst { default: fastify } = require('./server');\nconst plain = require('./plain').default;\n\nfunction start() {\n const server = build({ port: 3000 });\n fastify({ logger: true });\n plain();\n return format(new Date()) + server.options.port;\n}\n\nmodule.exports = start;\n") + .file("py/__init__.py", "") + .file("py/mod.py", "def first():\n return 1\n") + .file("py/use.py", "from .mod import default\n\n\ndef run():\n return default()\n") + .index(); + let graph = project.graph(); + assert_eq!( + into( + graph, + "src/biometrics/os-biometrics-mac.service.ts", + "OsBiometricsServiceMac", + &["instantiates"] + ), + [ + "instantiates src/biometrics/main-biometrics.service.ts import", + "instantiates src/biometrics/os-biometrics-mac.service.spec.ts import", + ] + ); + assert_eq!( + into(graph, "src/App.svelte", "App", &["instantiates"]), + ["instantiates src/main.js import"] + ); + assert!( + out_of(graph, "lib/app.js") + .iter() + .any(|e| e == "calls lib/format.js:formatDate") + ); + assert!(into(graph, "lib/format.js", "pad", &["calls"]).is_empty()); + assert_eq!( + into(graph, "lib/server.js", "createServer", &["calls"]), + ["calls lib/app.js import", "calls lib/app.js import"] + ); + assert!(into(graph, "lib/plain.js", "first", &["calls"]).is_empty()); + assert!(into(graph, "lib/plain.js", "second", &["calls"]).is_empty()); + assert!( + into( + graph, + "src/widgets/Dial.ts", + "Dial", + &["instantiates", "calls"] + ) + .is_empty() + ); + assert!( + out_of(graph, "src/dashboard.ts").is_empty(), + "{:?}", + out_of(graph, "src/dashboard.ts") + ); + let foreign: Vec = out_of(graph, "lib/app.js") + .into_iter() + .filter(|edge| !edge.contains(" lib/app.js:")) + .collect(); + assert_eq!( + foreign, + [ + "calls lib/format.js:formatDate", + "calls lib/server.js:createServer", + "calls lib/server.js:createServer", + ] + ); + assert!(into(graph, "py/mod.py", "first", &["calls"]).is_empty()); + } + + /// Upstream #2192 (`7c1c9a36`): `export * as ns from './x'` exports the + /// namespace, not every member: a later `export *` supplies `z.number()`, + /// `z.core.safeParse()` continues in the namespaced module, and a member a + /// barrel forwards by name is found for a namespace import. + #[test] + fn pr2192_a_namespace_reexport_exports_the_namespace_only() { + let project = Project::new() + .file("package.json", "{\"name\":\"schema\",\"private\":true}") + .file("src/core/regexes.ts", "export const number = /^-?\\d+$/;\n") + .file("src/core/parse.ts", "export function safeParse(value: unknown) {\n return { success: true, data: value };\n}\n") + .file("src/core/api.ts", "export function _lt(limit: number) {\n return limit;\n}\n") + .file("src/core/index.ts", "export * as regexes from \"./regexes.js\";\nexport * from \"./parse.js\";\nexport * from \"./api.js\";\n") + .file("src/classic/schemas.ts", "export function number() {\n return { kind: \"number\" };\n}\n") + .file("src/classic/external.ts", "export * as core from \"../core/index.js\";\nexport * from \"./schemas.js\";\nexport { _lt as lt } from \"../core/index.js\";\n") + .file("src/mini/parse.ts", "export { safeParse } from \"../core/index.js\";\n") + .file("src/mini/external.ts", "export * as core from \"../core/index.js\";\nexport * from \"./parse.js\";\n") + .file("src/tests/classic.test.ts", "import * as z from \"../classic/external.js\";\n\nexport function classic() {\n z.number();\n z.lt(10);\n z.core.safeParse(1);\n}\n") + .file("src/tests/mini.test.ts", "import * as m from \"../mini/external.js\";\n\nexport function mini() {\n m.safeParse(1);\n}\n") + .index(); + let graph = project.graph(); + let calls_out = |file: &str| -> Vec { + let mut calls: Vec = graph + .edges + .iter() + .filter(|edge| { + edge.kind == "calls" + && graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{}: {}", node.file_path, node.qualified_name)) + .collect(); + calls.sort(); + calls + }; + assert_eq!( + calls_out("src/tests/classic.test.ts"), + [ + "src/classic/schemas.ts: number", + "src/core/api.ts: _lt", + "src/core/parse.ts: safeParse", + ] + ); + assert_eq!( + calls_out("src/tests/mini.test.ts"), + ["src/core/parse.ts: safeParse"] + ); + } + + /// Upstream #2412 (`f261fda5`): `import { default as X }` is the module's + /// default import, so `AppRoot()` reaches the module's default export, + /// not another file's `AppRoot`, and the binding's import links the module + /// file when its default has no symbol. + #[test] + fn pr2412_import_default_as_is_the_default_import() { + let project = Project::new() + .file("package.json", "{\"name\":\"web\",\"dependencies\":{\"react\":\"^18\"}}") + .file("src/app/routes/app/root.tsx", "export const ErrorBoundary = () => {\n return
      Something went wrong!
      ;\n};\n\nconst AppRoot = () => {\n return
      ;\n};\n\nexport default AppRoot;\n") + .file("src/config.ts", "export default { theme: 'dark' };\n") + .file( + "src/app.ts", + "import { default as config } from './config';\n\nexport const theme = config.theme;\n", + ) + .file("src/legacy/shell.tsx", "export function AppRoot() {\n return
      ;\n}\n") + .file("src/app/router.tsx", "import { default as AppRoot, ErrorBoundary as AppRootErrorBoundary } from './routes/app/root';\n\nexport function render() {\n AppRootErrorBoundary();\n return AppRoot();\n}\n") + .index(); + assert_edge!(project, "function render" => calls => "function AppRoot @src/app/routes/app/root.tsx", resolved_by = "import"); + assert_no_edge!(project, "function render" => calls => "function AppRoot @src/legacy/shell.tsx"); + assert_edge!(project, "function render" => calls => "function ErrorBoundary @src/app/routes/app/root.tsx"); + // A default export with no symbol of its own still links the module + // file through the binding's import, as `import config from` does. + let graph = project.graph(); + let parked: Vec<&str> = graph + .unresolved + .iter() + .filter(|reference| { + reference.file_path == "src/app.ts" && reference.reference_kind == "imports" + }) + .map(|reference| reference.reference_name.as_str()) + .collect(); + assert!(!parked.contains(&"config"), "{parked:?}"); + } + + /// Upstream #2378 (`05712869`): an import binding whose name contains `$` + /// maps like any other (a default `$fmt`, a renamed `$alias`). + #[test] + fn pr2378_a_dollar_import_binding_is_an_import() { + let project = Project::new() + .file("package.json", "{\"name\":\"web\",\"private\":true}") + .file("src/fmt.ts", "export default function $fmt(value: string) {\n return value;\n}\n") + .file("src/other.ts", "export function $fmt(value: string) {\n return value + '!';\n}\n") + .file("src/use.ts", "import $fmt from './fmt';\nimport { $fmt as $alias } from './other';\n\nexport function show(value: string) {\n return $fmt(value) + $alias(value);\n}\n\nexport function shadow($fmt: (v: string) => string) {\n return $fmt('x');\n}\n") + .index(); + assert_edge!(project, "function show" => calls => "function $fmt @src/fmt.ts", resolved_by = "import"); + assert_edge!(project, "function show" => calls => "function $fmt @src/other.ts", resolved_by = "import"); + } + + /// Upstream #2384 (`5f5b8db6`): a store exported by an `export { … }` list + /// owns its actions when its name ends with `$`; `export { useStore$ }` + /// does not export a store named `useStore`. + #[test] + fn pr2384_an_export_list_exports_a_dollar_named_store() { + let project = Project::new() + .file("package.json", "{\"name\":\"web\",\"dependencies\":{\"zustand\":\"^4\"}}") + .file("src/items.ts", "import { create } from 'zustand';\n\nconst items$ = create((set) => ({\n inc: () => set({}),\n}));\n\nconst useStore = create((set) => ({\n reset: () => set({}),\n}));\n\nexport { items$ };\nexport { useStore$ };\n") + .index(); + let graph = project.graph(); + let names: Vec<&str> = graph + .nodes + .iter() + .filter(|node| node.file_path == "src/items.ts") + .map(|node| node.qualified_name.as_str()) + .collect(); + assert!(names.contains(&"items$::inc"), "{names:?}"); + assert!(!names.contains(&"useStore::reset"), "{names:?}"); + } + + /// Upstream #2378 / #2384's identifier grammar in the local alias forms: + /// `export default $fmt;`, `export default items$;` and `export const + /// $make = make$;` bind the declaration they name, so a default import + /// reaches that function or constant rather than the module's first + /// exported function. `items$` is not its namesake `items`, the default + /// is never another module's `$fmt`, and a function binding `$fmt` + /// itself calls its local. + #[test] + fn pr2378_a_dollar_default_or_const_alias_binds_its_declaration() { + let project = Project::new() + .file("package.json", "{\"name\":\"web\",\"private\":true}") + .file("src/fmt.ts", "export function helper(value: string) {\n return value;\n}\n\nconst $fmt = (value: string) => value + '!';\n\nexport default $fmt;\n") + .file("src/items.ts", "export function reset() {\n return 0;\n}\n\nexport const items = { grow() { return 0; } };\n\nconst items$ = { grow() { return 1; } };\n\nexport default items$;\n") + .file("src/alias.ts", "export function other() {\n return 2;\n}\n\nfunction make$() {\n return 1;\n}\n\nexport const $make = make$;\n") + .file("src/other.ts", "export function $fmt(value: string) {\n return value + '?';\n}\n") + .file("src/use.ts", "import fmt from './fmt';\nimport store from './items';\nimport { $make } from './alias';\n\nexport function show(value: string) {\n return fmt(value) + $make() + store.grow();\n}\n\nexport function local(value: string) {\n const $fmt = (v: string) => v;\n return $fmt(value);\n}\n") + .index(); + assert_edge!(project, "function show" => calls => "function $fmt @src/fmt.ts", resolved_by = "import"); + assert_no_edge!(project, "function show" => calls => "function helper"); + assert_no_edge!(project, "function show" => calls => "function $fmt @src/other.ts"); + assert_edge!(project, "function show" => calls => "function make$ @src/alias.ts"); + assert_no_edge!(project, "function show" => calls => "function other"); + assert_edge!(project, "function show" => calls => "function items$::grow @src/items.ts", resolved_by = "import"); + assert_no_edge!(project, "function show" => calls => "function items::grow"); + assert_no_edge!(project, "function show" => calls => "function reset"); + assert_no_edge!(project, "function local" => calls => "function $fmt @src/fmt.ts"); + assert_no_edge!(project, "function local" => calls => "function $fmt @src/other.ts"); + } + + /// Upstream #2161 (`2d1b6441`): a `.js` whose leading comments carry + /// Flow's `@flow` pragma is read as TSX once Flow's own syntax is blanked, + /// so `render(): React.Node` no longer cuts its class short, and its file + /// row records `tsx`; a `.js` without the pragma, or with `@noflow`, + /// stays JavaScript. + #[test] + fn pr2161_a_flow_typed_javascript_file_is_read_as_tsx() { + let project = Project::new() + .file("js/SegmentedControl.js", "/**\n * @flow strict-local\n * @format\n */\nimport typeof * as React from 'react';\nimport * as React from 'react';\n\ntype Props = $ReadOnly<{|\n values: ?Array,\n onChange?: ?(event: {nativeEvent: {value: string}, ...}) => mixed,\n|}>;\n\nopaque type Token = string;\n\nclass SegmentedControl extends React.Component {\n render(): React.Node {\n return this._renderItems();\n }\n\n _renderItems(): React.Node {\n return null;\n }\n}\n\nmodule.exports = SegmentedControl;\n") + .file("js/plain.js", "function plain() { return 1; }\nmodule.exports = plain;\n") + .file("js/noflow.js", "// @noflow\nfunction off() { return 1; }\nmodule.exports = off;\n") + .index(); + let graph = project.graph(); + let language = |path: &str| { + graph + .files + .iter() + .find(|file| file.path == path) + .map(|file| file.language.as_str()) + }; + assert_eq!(language("js/SegmentedControl.js"), Some("tsx")); + assert_eq!(language("js/plain.js"), Some("javascript")); + assert_eq!(language("js/noflow.js"), Some("javascript")); + let mut methods: Vec<&str> = graph + .nodes + .iter() + .filter(|node| node.file_path == "js/SegmentedControl.js" && node.kind == "method") + .map(|node| node.name.as_str()) + .collect(); + methods.sort_unstable(); + assert_eq!(methods, ["_renderItems", "render"], "{}", graph.report()); + assert_edge!(project, "method SegmentedControl::render" => calls => "method SegmentedControl::_renderItems"); + } + + /// The one node with this qualified name (in `file`, when given). + #[track_caller] + fn the_node<'g>(graph: &'g Graph, qualified: &str, file: Option<&str>) -> &'g NodeRow { + let found: Vec<&NodeRow> = graph + .nodes + .iter() + .filter(|node| { + node.qualified_name == qualified && file.is_none_or(|file| node.file_path == file) + }) + .collect(); + assert_eq!(found.len(), 1, "{qualified}:\n{}", graph.report()); + found[0] + } + + /// The qualified names at the other end of a node's `calls` edges, sorted. + fn calls_of(graph: &Graph, id: &str, incoming: bool) -> Vec { + let mut names: Vec = graph + .edges + .iter() + .filter(|edge| { + edge.kind == "calls" + && if incoming { + edge.target == id + } else { + edge.source == id + } + }) + .filter_map(|edge| graph.node(if incoming { &edge.source } else { &edge.target })) + .map(|node| node.qualified_name.clone()) + .collect(); + names.sort(); + names + } + + #[track_caller] + fn callers_of(graph: &Graph, qualified: &str) -> Vec { + calls_of(graph, &the_node(graph, qualified, None).id, true) + } + + #[track_caller] + fn callees_of(graph: &Graph, qualified: &str) -> Vec { + calls_of(graph, &the_node(graph, qualified, None).id, false) + } + + /// Upstream #2363 (`f40db4b9`, issue #2300): a named object literal owns + /// its function members whether it is exported or not, declared in an + /// IIFE, or hung on the global object, and the calls written in a member + /// are the member's; `window.WS.wsM()` and another classic script's + /// `WS.wsM()` reach `window.WS::wsM`. + #[test] + fn pr2363_every_named_literal_owns_its_function_members() { + let project = Project::new() + .file("src/a_export.ts", "export const objTsExp = { tsExpM() { return 1; }, tsExpProp: () => 1 };\n") + .file("src/b_plain.ts", "const objTsPlain = { tsPlainM() { return 1; }, tsPlainProp: () => 1 };\n") + .file("src/c_export.js", "export const objJsExp = { jsExpM() { return 1; }, jsExpProp: () => 1 };\n") + .file("src/d_plain.js", "const objJsPlain = { jsPlainM() { return 1; }, jsPlainProp: () => 1 };\n") + .file("src/e_iife.js", "(function () {\n const objIife = { iifeM() { return 1; } };\n window.WS = { wsM() { return objIife.iifeM(); } };\n})();\n") + .file("src/f_calls.js", "function helper() { return 2; }\nvar store = { shorthand() { return helper(); } };\nfunction useStore() { return store.shorthand(); }\nwindow.api = { load() { return store.shorthand(); } };\nfunction viaWindow() { return window.WS.wsM(); }\nfunction viaGlobal() { return WS.wsM(); }\n") + .index(); + let graph = project.graph(); + for qualified in [ + "objTsExp::tsExpM", + "objTsExp::tsExpProp", + "objTsPlain::tsPlainM", + "objTsPlain::tsPlainProp", + "objJsExp::jsExpM", + "objJsExp::jsExpProp", + "objJsPlain::jsPlainM", + "objJsPlain::jsPlainProp", + "objIife::iifeM", + "window.WS::wsM", + "store::shorthand", + "window.api::load", + ] { + assert_eq!( + the_node(graph, qualified, None).kind, + "function", + "{qualified}" + ); + } + let plain = the_node(graph, "objTsPlain", None); + let mut members: Vec<&str> = graph + .edges + .iter() + .filter(|edge| edge.kind == "contains" && edge.source == plain.id) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.name.as_str()) + .collect(); + members.sort_unstable(); + assert_eq!(members, ["tsPlainM", "tsPlainProp"]); + let ws: Vec = graph + .nodes + .iter() + .filter(|node| node.name == "WS") + .map(|node| format!("{} {}", node.kind, node.qualified_name)) + .collect(); + assert_eq!(ws, ["variable window.WS"]); + // An exported literal's members stay exported, a plain one's do not. + assert!(the_node(graph, "objTsExp::tsExpM", None).is_exported); + assert!(!the_node(graph, "objTsPlain::tsPlainM", None).is_exported); + // The call inside a member is the member's, not the constant's. + assert_eq!(callers_of(graph, "helper"), ["store::shorthand"]); + assert_eq!(callers_of(graph, "objIife::iifeM"), ["window.WS::wsM"]); + assert_eq!( + callers_of(graph, "store::shorthand"), + ["useStore", "window.api::load"] + ); + assert_eq!( + callers_of(graph, "window.WS::wsM"), + ["viaGlobal", "viaWindow"] + ); + } + + /// Upstream #2363: a member is reached through the object a call names + /// (`App.init()`, `window.App.render()`, `App.utils.pad()`, a sibling's + /// `this.render()`), from another classic script too, and never by its + /// bare name; a parameter named like the global shadows it. + #[test] + fn pr2363_a_member_is_reached_through_its_object_never_a_bare_name() { + let project = Project::new() + .file("app.js", "function init() { return \"global\"; }\nvar App = {\n init: function () { this.render(); helpers.fmt(); },\n render: function () { return 1; },\n};\nvar helpers = { fmt() { return 2; } };\nApp.utils = { pad(s) { return s; } };\nfunction boot() { App.init(); App.utils.pad(\"x\"); init(); }\ndw_page = { start() { App.render(); } };\n") + .file("page.js", "function main() { App.init(); window.App.render(); dw_page.start(); App.utils.pad(\"y\"); }\nfunction shadow(App) { return App.init(); }\nfunction bare() { return render(); }\n") + .index(); + let graph = project.graph(); + assert_eq!( + callees_of(graph, "boot"), + ["App.utils::pad", "App::init", "init"] + ); + assert_eq!( + callees_of(graph, "App::init"), + ["App::render", "helpers::fmt"] + ); + assert_eq!(callees_of(graph, "dw_page::start"), ["App::render"]); + assert_eq!( + callees_of(graph, "main"), + [ + "App.utils::pad", + "App::init", + "App::render", + "dw_page::start" + ] + ); + assert_eq!(callees_of(graph, "shadow"), Vec::::new()); + assert_eq!(callees_of(graph, "bare"), Vec::::new()); + assert_eq!(callers_of(graph, "init"), ["boot"]); + } + + /// Upstream #2363: an object hung on a path (`$.event.special.swipe = + /// {…}`) or local to an IIFE is no name elsewhere: `this.swipe()` and + /// `new this.model()` are not those objects, and a plain script's `$n` is + /// its own function. + #[test] + fn pr2363_a_path_or_iife_object_is_no_name_elsewhere() { + let project = Project::new() + .file("lib.js", "$.event.special.swipe = { setup: function () { return 1; } };\ntodos.model = { add: function () { return 2; } };\n(function () { var n = { touch: function () { return 3; } }; n.touch(); })();\n") + .file("widget.js", "var Widget = {\n drag: function () { this.swipe(1); },\n make: function () { return new this.model(); },\n};\n") + .file("gwt1.js", "function $n(a) { this.a = a; }\nfunction make1() { return new $n(1); }\n") + .file("gwt2.js", "function $n(a) { this.a = a; }\nfunction make2() { return new $n(2); }\n") + .index(); + let graph = project.graph(); + let edges = |qualified: &str| -> Vec { + let id = &the_node(graph, qualified, None).id; + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| &edge.source == id && edge.kind != "contains") + .filter_map(|edge| { + graph.node(&edge.target).map(|target| { + format!( + "{} {} {}", + edge.kind, target.qualified_name, target.file_path + ) + }) + }) + .collect(); + out.sort(); + out + }; + assert_eq!( + the_node(graph, "$.event.special.swipe::setup", None).kind, + "function" + ); + assert_eq!(the_node(graph, "n::touch", None).kind, "function"); + assert_eq!(callers_of(graph, "n::touch"), ["lib.js"]); + assert_eq!(edges("Widget::drag"), Vec::::new()); + assert_eq!(edges("Widget::make"), Vec::::new()); + assert_eq!(edges("make1"), ["instantiates $n gwt1.js"]); + assert_eq!(edges("make2"), ["instantiates $n gwt2.js"]); + } + + /// Upstream #2363: each IIFE and function keeps its own literal, and a + /// module its private one; a call outside them reaches none. + #[test] + fn pr2363_each_iife_and_function_keeps_its_own_literal() { + let project = Project::new() + .file("iife.js", "(function () { const Api = { run() { left(); } }; Api.run(); })();\n(function () { const Api = { run() { right(); } }; Api.run(); })();\nfunction left() {}\nfunction right() {}\nfunction outside() { return Api.run(); }\nfunction a() { const local = { go() { left(); } }; local.go(); }\nfunction b() { const local = { go() { right(); } }; local.go(); }\n") + .file("mod.js", "import { x } from \"./x.js\";\nconst Local = { run() { return x; } };\nexport function useLocal() { return Local.run(); }\n") + .file("other.js", "import { y } from \"./y.js\";\nexport function nope() { return Local.run(y); }\n") + .index(); + let graph = project.graph(); + let mut runs: Vec<&NodeRow> = graph + .nodes + .iter() + .filter(|node| node.qualified_name == "Api::run") + .collect(); + runs.sort_by_key(|node| node.start_line); + assert_eq!(runs.len(), 2, "{}", graph.report()); + assert_eq!(calls_of(graph, &runs[0].id, false), ["left"]); + assert_eq!(calls_of(graph, &runs[1].id, false), ["right"]); + assert_eq!(calls_of(graph, &runs[0].id, true), ["iife.js"]); + assert_eq!(calls_of(graph, &runs[1].id, true), ["iife.js"]); + // Outside both IIFEs, `Api` is neither of them, not even as a value read. + let outside = &the_node(graph, "outside", None).id; + assert!( + graph + .edges + .iter() + .all(|edge| &edge.source != outside || edge.kind == "contains"), + "{}", + graph.report() + ); + assert_eq!(callees_of(graph, "a"), ["a::local::go"]); + assert_eq!(callees_of(graph, "b"), ["b::local::go"]); + assert_eq!(callees_of(graph, "useLocal"), ["Local::run"]); + assert_eq!(callees_of(graph, "nope"), Vec::::new()); + } + + /// Upstream #2193 (`8410db42`), the part #2363's local owners rely on: a + /// `const` declared in a function body is that body's alone, so a bare + /// `handlers()` or `new handlers()` in another function of the file never + /// reaches the multi-line function `a`'s local owner `a::handlers`, while + /// `a`'s own `handlers.load()` still does (carried from W4-JS-07). + #[test] + fn pr2193_a_function_local_owner_is_no_name_in_another_function() { + let project = Project::new() + .file( + "src/app.js", + "function a() {\n const handlers = {\n load() { return 1; },\n };\n return handlers.load();\n}\n\nfunction b() {\n return handlers();\n}\n\nfunction c() {\n return new handlers();\n}\n", + ) + .index(); + let graph = project.graph(); + for function in ["b", "c"] { + let id = &the_node(graph, function, None).id; + assert!( + graph.edges.iter().all(|edge| &edge.source != id + || edge.kind == "contains" + || graph + .node(&edge.target) + .is_none_or(|target| !target.qualified_name.starts_with("a::"))), + "{function}: {}", + graph.report() + ); + } + assert_eq!( + callees_of(graph, "a"), + ["a::handlers::load"], + "{}", + graph.report() + ); + } + + /// Upstream #2363: an arrow member and a value written in a method's + /// literal take the method's `this`; an object's own method's `this` is + /// the object. + #[test] + fn pr2363_arrow_members_take_the_this_of_the_method_around_the_literal() { + let project = Project::new() + .file("app.ts", "class Emitter { on(cb: () => void) { return cb; } }\nexport class App {\n private emitter = new Emitter();\n updateScene() { return 1; }\n createApi() {\n const api = {\n updateScene: this.updateScene,\n onChange: (cb: () => void) => this.emitter.on(cb),\n refresh: () => this.updateScene(),\n own() { return this.refresh(); },\n };\n return api;\n }\n}\n") + .index(); + let graph = project.graph(); + let create_api = &the_node(graph, "App::createApi", None).id; + assert!( + graph.edges.iter().any(|edge| { + &edge.source == create_api + && edge.kind == "references" + && graph + .node(&edge.target) + .is_some_and(|target| target.qualified_name == "App::updateScene") + }), + "{}", + graph.report() + ); + assert_eq!( + callees_of(graph, "App::createApi::api::onChange"), + ["Emitter::on"] + ); + assert_eq!( + callees_of(graph, "App::createApi::api::refresh"), + ["App::updateScene"] + ); + assert_eq!( + callees_of(graph, "App::createApi::api::own"), + ["App::createApi::api::refresh"] + ); + } + + /// Upstream #2363: a cross-file member edge follows edits of the script + /// that defines the namespace. + #[test] + fn pr2363_a_cross_file_member_edge_follows_edits_of_its_script() { + let mut project = Project::new() + .file( + "ns.js", + "window.WS = { a() { return 1; }, wsM() { return 2; } };\n", + ) + .file("use.js", "function viaGlobal() { return WS.wsM(); }\n") + .index(); + assert_eq!(callers_of(project.graph(), "window.WS::wsM"), ["viaGlobal"]); + project.write( + "ns.js", + "// moved\nwindow.WS = {\n wsM() { return 3; },\n};\n", + ); + assert_eq!(callers_of(project.sync(), "window.WS::wsM"), ["viaGlobal"]); + project.write("ns.js", "window.WS = { renamed() { return 4; } };\n"); + let graph = project.sync(); + assert!( + graph + .nodes + .iter() + .all(|node| node.qualified_name != "window.WS::wsM") + ); + assert_eq!(callees_of(graph, "viaGlobal"), Vec::::new()); + } + + /// Upstream #2363: an imported literal's member, and a binding + /// destructured off a literal, reach the member; a name never + /// destructured does not. + #[test] + fn pr2363_an_imported_literal_and_a_destructured_binding_reach_the_member() { + let project = Project::new() + .file("api.js", "export const api = { get() { return 1; }, post() { return 2; } };\nconst { post } = api;\nexport function b() { return post(); }\nexport function c() { return get(); }\n") + .file("use.js", "import { api } from \"./api.js\";\nexport function a() { return api.get(); }\n") + .index(); + let graph = project.graph(); + assert_eq!(callees_of(graph, "a"), ["api::get"]); + assert_eq!(callees_of(graph, "b"), ["api::post"]); + assert_eq!(callees_of(graph, "c"), Vec::::new()); + } + + /// Upstream #2363: CommonJS exports, prototypes, call arguments, data, + /// `this.x = {…}`, a plain name reassigned in a function and minified + /// scripts keep their shape: no member nodes. + #[test] + fn pr2363_commonjs_prototypes_arguments_data_and_minified_scripts_keep_their_shape() { + let bundle = "var q={b:function(){return r(1,2)},c:function(s){return s}};window.M={d:function(){return q.b()}};" + .repeat(60); + let project = Project::new() + .file("shapes.js", "module.exports = { cjs() { return 1; } };\nFoo.prototype = { proto() { return 1; } };\nconsume({ ephemeral() { return 1; } });\nconst data = { a: 1, b: [1, 2] };\nthis.state = { onThis() { return 1; } };\n") + .file("assets/lib.min.js", "var a={b:function(){return 1},c:function(){return 2}};window.L={d:function(){return a.b()}};\n") + .file("assets/bundle.js", &format!("{bundle}\n{bundle}\n")) + .file("iife.js", "(function () { var e; if (!e) { e = { getItem: function () { return 1; } }; } })();\n") + .index(); + let graph = project.graph(); + for name in [ + "cjs", + "proto", + "ephemeral", + "onThis", + "b", + "c", + "d", + "getItem", + ] { + assert!( + graph.nodes.iter().all(|node| node.name != name), + "{name}:\n{}", + graph.report() + ); + } + assert_eq!(the_node(graph, "data", None).kind, "constant"); + } + + /// Upstream #2363: a `\n") + .index(); + let graph = project.graph(); + let owner = &the_node(graph, "handlers", None).id; + let member = &the_node(graph, "handlers::onSave", None).id; + let parents: Vec<&str> = graph + .edges + .iter() + .filter(|edge| edge.kind == "contains" && &edge.target == member) + .map(|edge| edge.source.as_str()) + .collect(); + assert_eq!(parents, [owner.as_str()]); + assert_eq!(callees_of(graph, "handlers::onSave"), ["persist"]); + assert_eq!(callees_of(graph, "save"), ["handlers::onSave"]); + } + + /// Upstream #2363 (`extraction.test.ts`, #693): a module-scope literal's + /// values run where it is written, so their calls are the constant's, and + /// each function member's calls are its own; a data literal's calls are + /// its constant's too. + #[test] + fn pr2363_a_module_literal_walks_its_values_under_its_constant() { + let project = Project::new() + .file("app.ts", "\nconst eager = load();\nconst obj = { handler: () => target(), plain: target() };\nconst list = [() => target()];\nexport const exported = { handler: () => target() };\nconst cfg = { a: compute(), b: [1, 2] };\n") + .index(); + let graph = project.graph(); + let callers = |name: &str| -> Vec { + let mut out: Vec = graph + .unresolved + .iter() + .filter(|reference| { + reference.reference_kind == "calls" && reference.reference_name == name + }) + .map(|reference| { + graph + .node(&reference.from_node_id) + .map_or("?".to_string(), |node| { + format!("{}:{}", node.kind, node.qualified_name) + }) + }) + .collect(); + out.sort(); + out + }; + assert_eq!(callers("load"), ["constant:eager"]); + assert_eq!( + callers("target"), + [ + "constant:list", + "constant:obj", + "function:exported::handler", + "function:obj::handler" + ] + ); + assert_eq!(callers("compute"), ["constant:cfg"]); + } + + /// Upstream #2363's negatives: a namespace two pages define with the same + /// member is the caller's own directory's or nobody's; a binding + /// destructured under another key, a global whose holder lacks the + /// member, and a file that imports the name never reach a global member. + #[test] + fn pr2363_ambiguous_or_shadowed_holders_stay_unresolved() { + let project = Project::new() + .file("a/ns.js", "window.App = { go() { return 1; } };\n") + .file("b/ns.js", "window.App = { go() { return 2; } };\n") + .file("a/use.js", "function near() { return App.go(); }\n") + .file("c/use.js", "function far() { return App.go(); }\n") + .file("d/keys.js", "var Tool = { load() { return 1; }, x() { return 2; } };\nvar { x: load } = Tool;\nfunction renamed() { return load(); }\n") + .file("d/other.js", "window.Other = { ping() { return 1; } };\nfunction missing() { return window.Other.go(); }\n") + .file("e/imported.js", "import { App } from './app.js';\nexport function viaImport() { return App.go(); }\n") + .index(); + let graph = project.graph(); + let go_files = |caller: &str| -> Vec { + let id = &the_node(graph, caller, None).id; + let mut files: Vec = graph + .edges + .iter() + .filter(|edge| &edge.source == id && edge.kind == "calls") + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{} {}", node.qualified_name, node.file_path)) + .collect(); + files.sort(); + files + }; + assert_eq!(go_files("near"), ["window.App::go a/ns.js"]); + assert_eq!(go_files("far"), Vec::::new()); + assert_eq!(go_files("renamed"), Vec::::new()); + assert_eq!(go_files("missing"), Vec::::new()); + assert_eq!(go_files("viaImport"), Vec::::new()); + } + + /// `name file:line` of each node a JSX tag in the node named `parent` + /// reaches (a `references` edge written right after `<`), sorted: the + /// components the parent renders. + fn renders(project: &super::support::Indexed, parent: &str) -> Vec { + let graph = project.graph(); + let parents: Vec<&super::support::NodeRow> = graph + .nodes + .iter() + .filter(|node| node.name == parent && node.kind != "import") + .collect(); + let at_tag = |file: &str, line: Option, col: Option| -> bool { + let (Some(line), Some(col)) = (line, col) else { + return false; + }; + let text = std::fs::read_to_string(project.root().join(file)).unwrap_or_default(); + let Some(row) = text + .lines() + .nth(usize::try_from(line - 1).unwrap_or(usize::MAX)) + else { + return false; + }; + usize::try_from(col) + .ok() + .and_then(|col| col.checked_sub(1)) + .is_some_and(|before| row.as_bytes().get(before) == Some(&b'<')) + }; + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| { + edge.kind == "references" + && parents.iter().any(|parent| { + parent.id == edge.source && at_tag(&parent.file_path, edge.line, edge.col) + }) + }) + .filter_map(|edge| graph.node(&edge.target)) + .filter(|node| matches!(node.kind.as_str(), "component" | "function" | "class")) + .map(|node| format!("{} {}:{}", node.name, node.file_path, node.start_line)) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// Upstream #2442 (`47311494`): a JSX tag whose name its component binds + /// itself, a `const` or a parameter by the tag's line, renders that local: + /// a component the parent declares inside itself, else nothing; a tag + /// above the local still renders the outer one. A name written only in a + /// type argument or parameter list is no tag of the syntax tree, so it + /// renders nothing either. + #[test] + fn pr2442_a_jsx_tag_bound_to_a_local_renders_no_other_component() { + let project = Project::new() + .file("package.json", "{\"dependencies\":{\"react\":\"^18.0.0\"}}") + .file("app/components/CommandBarItem.tsx", "export function Content() {\n return
      ;\n}\n\nexport function Icon() {\n return ;\n}\n") + .file("shared/editor/Widget.tsx", "export function Widget() {\n return
      ;\n}\n") + .file("app/components/Portal.tsx", "export function Portal({ children }) {\n return
      {children}
      ;\n}\n") + .file("app/components/Menu.tsx", "import * as DropdownMenu from '@radix-ui/react-dropdown-menu';\nimport * as ContextMenu from '@radix-ui/react-context-menu';\n\nconst Portal = DropdownMenu.Portal;\n\nexport function SubMenuContent({ variant, children }) {\n const Portal =\n variant === 'dropdown' ? DropdownMenu.Portal : ContextMenu.Portal;\n const Content =\n variant === 'dropdown'\n ? DropdownMenu.SubContent\n : ContextMenu.SubContent;\n return (\n \n {children}\n \n );\n}\n\nexport function SettingsLinks({ config }) {\n return (\n
        \n {config.map((item) => {\n const Icon = item.icon;\n return
      • ;\n })}\n
      \n );\n}\n\nexport function Widgets({ widgets }) {\n return
      {Object.values(widgets).map((Widget, index) => )}
      ;\n}\n") + .file("a-decoy/row.tsx", "export function Row() {\n return ;\n}\n") + .file("app/icon.tsx", "export function Icon() {\n return ;\n}\n") + .file("app/table.tsx", "import { Icon } from './icon';\n\nfunction Row() {\n return ;\n}\n\nexport function Table({ rows }) {\n const Row = ({ row }) => {row.name};\n return {rows.map((row) => )}
      ;\n}\n\nexport function Toolbar({ actions }) {\n return (\n
      \n \n {actions.map((action) => {\n const Icon = action.icon;\n return ;\n })}\n
      \n );\n}\n") + .file("app/profile/entry.tsx", "export function Entry() {\n return
      ;\n}\n") + .file("app/components/GenericTable.tsx", "type BaseEntity = { id: string };\n\nexport const GenericTable = ({ data }: { data: Entry[] }) => {\n return {data.length}
      ;\n};\n") + .index(); + assert_eq!( + renders(&project, "SubMenuContent"), + Vec::::new(), + "{}", + project.graph().report() + ); + assert_eq!(renders(&project, "SettingsLinks"), Vec::::new()); + assert_eq!(renders(&project, "Widgets"), Vec::::new()); + assert_eq!(renders(&project, "Table"), ["Row app/table.tsx:8"]); + assert_eq!(renders(&project, "Toolbar"), ["Icon app/icon.tsx:1"]); + assert_eq!(renders(&project, "GenericTable"), Vec::::new()); + } + + /// Upstream #2140 (`a3d675ef`): a Vue Options API component's functions + /// (`methods`, `computed`, `watch` entries, `data`, lifecycle hooks) are + /// method nodes of the component, each owning the calls written in it; + /// the member-call and exact-name strategies reach such a method only as + /// `this.m()` in its own file, never through `e.preventDefault()` or + /// `this.editor.setValue()`. (`@click="handleLogin"` reaching the method + /// is upstream's vue-handler synthesizer, W7-18.) + #[test] + fn pr2140_options_api_functions_are_methods_of_their_component() { + let project = Project::new() + .file("package.json", "{\"name\":\"admin\",\"dependencies\":{\"vue\":\"^2.6.0\"}}") + .file("src/views/login/index.vue", "\n\n\n") + .file("src/components/ImageCropper.vue", "\n\n") + .file("src/components/Editor.vue", "\n\n") + .file("src/components/Tab.vue", "\n\n") + .file("src/components/Pane.vue", "\n\n") + .file("src/components/Panel.vue", "\n\n") + .index(); + let graph = project.graph(); + let mut methods: Vec<&str> = graph + .nodes + .iter() + .filter(|node| node.file_path == "src/views/login/index.vue" && node.kind == "method") + .map(|node| node.name.as_str()) + .collect(); + methods.sort_unstable(); + assert_eq!( + methods, + ["data", "handleLogin", "validate"], + "{}", + graph.report() + ); + assert_eq!(the_node(graph, "index::handleLogin", None).kind, "method"); + assert_eq!(callees_of(graph, "index::handleLogin"), ["index::validate"]); + assert_eq!(callees_of(graph, "Editor::onKey"), Vec::::new()); + // A template ref names a child this cannot tell apart: neither + // component's own `focus`. + assert_eq!(callees_of(graph, "Panel::open"), Vec::::new()); + } + + /// Upstream #2357 (b) (`8998697f`, issue #2340): a call written in a Vue + /// template is the component's, at its own line: an interpolation, a + /// binding, a handler call; a bare call to the component's own Options + /// API method links to it, and the import decides which helper. + #[test] + fn pr2357_a_template_call_is_the_components() { + let project = Project::new() + .file("package.json", "{\"name\":\"nuxt-app\",\"dependencies\":{\"nuxt\":\"^4.0.0\"}}") + .file("composables/useBar.ts", "export function useBar(x: unknown) { return String(x); }\n") + .file("components/H.vue", "\n") + .file("components/I.vue", "\n") + .file("utils/format.ts", "export function formatPrice(n: number) { return `$${n}`; }\n") + .file("other/format.ts", "export function formatPrice(n: number) { return n; }\n") + .file("components/Setup.vue", "\n\n\n") + .file("components/Options.vue", "\n\n\n") + .file("pages/lists.vue", "\n\n\n") + .file("lib/bus.ts", "export class Bus {\n emit(event: string) { return event; }\n}\n") + .file("components/Dialog.vue", "\n\n\n") + .index(); + let graph = project.graph(); + let component_calls = |file: &str| -> Vec { + let component = graph + .nodes + .iter() + .find(|node| node.file_path == file && node.kind == "component") + .unwrap_or_else(|| panic!("component of {file}")); + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.source == component.id && edge.kind == "calls") + .filter_map(|edge| { + graph.node(&edge.target).map(|target| { + format!( + "{}@{}:{}", + target.name, + target.file_path, + edge.line.unwrap_or(0) + ) + }) + }) + .collect(); + out.sort(); + out + }; + assert_eq!( + component_calls("components/H.vue"), + ["useBar@composables/useBar.ts:1"] + ); + assert_eq!( + component_calls("components/I.vue"), + ["useBar@composables/useBar.ts:1"] + ); + assert_eq!( + component_calls("components/Setup.vue"), + [ + "formatPrice@utils/format.ts:2", + "save@components/Setup.vue:3" + ] + ); + // A template calls the component's own methods without `this.`. + assert_eq!( + component_calls("components/Options.vue"), + [ + "submit@components/Options.vue:3", + "total@components/Options.vue:2" + ] + ); + // Both calls are the local function's, not Nuxt's `clearError`. + assert_eq!( + component_calls("pages/lists.vue"), + [ + "clearError@pages/lists.vue:2", + "clearError@pages/lists.vue:7" + ] + ); + // `emit('cancel')` is the component's `defineEmits` binding, and `open + // = !open` calls nothing: neither reaches Bus.emit. + assert!( + component_calls("components/Dialog.vue") + .iter() + .all(|call| !call.starts_with("emit@lib/bus.ts")), + "{:?}", + component_calls("components/Dialog.vue") + ); + } + + /// Upstream #2177 (`48498021`), its JavaScript part: fetch's Response, + /// Request and Blob body methods (`text`, `json`, `arrayBuffer`, `blob`, + /// `formData`) are builtins, so a call on an untyped receiver is fetch's, + /// not a project class's method of that name, whatever the receiver is + /// called; a receiver the code constructs still reaches the class. + #[test] + fn pr2177_fetch_body_methods_need_receiver_evidence() { + let project = Project::new() + .file("src/file.js", "export class LazyFile {\n text() { return ''; }\n json() { return {}; }\n blob() { return null; }\n formData() { return null; }\n arrayBuffer() { return null; }\n}\n\nexport async function load(response, file, lazyFile) {\n const body = await response.text();\n await file.text();\n await lazyFile.json();\n await lazyFile.blob();\n await file.arrayBuffer();\n await file.formData();\n return body;\n}\n\nexport function local() {\n const made = new LazyFile();\n return made.text();\n}\n") + .index(); + let graph = project.graph(); + let id = |name: &str| the_node(graph, name, None).id.clone(); + assert_eq!( + calls_of(graph, &id("load"), false), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!(calls_of(graph, &id("local"), false), ["LazyFile::text"]); + } +} + +mod python { + use super::support::{EdgeRow, Graph, Project}; + + /// `file: qualified` of every `kind` edge out of `file`'s nodes that + /// `keep` accepts, sorted. + fn edges_where( + graph: &Graph, + file: &str, + kind: &str, + keep: impl Fn(&EdgeRow) -> bool, + ) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| { + edge.kind == kind + && keep(edge) + && graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{}: {}", node.file_path, node.qualified_name)) + .collect(); + out.sort(); + out + } + + /// `file: qualified` of every `kind` edge out of `file`'s nodes, sorted. + fn edges_from(graph: &Graph, file: &str, kind: &str) -> Vec { + edges_where(graph, file, kind, |_| true) + } + + /// `file: qualified` of every `calls` edge out of `file`'s nodes, sorted. + fn calls_from(graph: &Graph, file: &str) -> Vec { + edges_from(graph, file, "calls") + } + + /// `file: qualified` of every `calls` edge out of the node named `name`. + fn calls_of(graph: &Graph, name: &str) -> Vec { + let ids: Vec<&str> = graph + .nodes + .iter() + .filter(|node| node.name == name && node.kind != "import") + .map(|node| node.id.as_str()) + .collect(); + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls" && ids.contains(&edge.source.as_str())) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{}: {}", node.file_path, node.qualified_name)) + .collect(); + out.sort(); + out + } + + /// Upstream #2125 (`99209b6f`): a Python call whose receiver the + /// extractor could not keep (`User.objects.get`, `self.client.login`) + /// reaches only a member of what its chain names last; a bare call + /// (`get(1)`) is no method, and a name taken `from` a module outside the + /// project (`from django.shortcuts import render`) is the package's; a + /// module path names the module's function; `self.helper()` is the + /// class's own. + #[test] + fn pr2125_a_python_method_is_reached_through_its_receiver() { + let project = Project::new() + .file("app/tests.py", "class AuthTestCase:\n def get(self, url):\n return url\n\n def login(self, name):\n return name\n") + .file("app/curl.py", "def get(url):\n return url\n") + .file("app/helpers.py", "def slugify(text):\n return text\n") + .file("app/tags.py", "def render(context):\n return context\n") + .file("app/pages.py", "import app.helpers\nfrom django.shortcuts import render\n\ndef page(request):\n kind = request.POST.get(\"kind\")\n slug = app.helpers.slugify(kind)\n return render(request, \"page.html\", {\"slug\": slug})\n") + .file("app/views.py", "from django.contrib.auth.models import User\n\nclass Backend:\n def authenticate(self, username):\n user = User.objects.get(email=username)\n self.client.login(username=\"a\")\n self.helper()\n self.assertEqual(self.filterset(params, self.queryset).qs.get(), 2)\n get(1)\n return user\n\n def helper(self):\n return 1\n") + .file("app/config.py", "class UserConfig:\n def all(self):\n return []\n\n\ndef run(prefs):\n everything = prefs.objects.all()\n return all([everything])\n") + .file("app/bare.py", "def bare_login():\n return login(\"x\")\n") + .index(); + let graph = project.graph(); + let authenticate = calls_of(graph, "authenticate"); + assert!( + !authenticate.contains(&"app/tests.py: AuthTestCase::get".to_string()), + "{authenticate:?}" + ); + assert!( + !authenticate.contains(&"app/tests.py: AuthTestCase::login".to_string()), + "{authenticate:?}" + ); + assert!( + authenticate.contains(&"app/views.py: Backend::helper".to_string()), + "{authenticate:?}" + ); + let page = calls_of(graph, "page"); + assert!(!page.contains(&"app/curl.py: get".to_string()), "{page:?}"); + assert!( + !page.contains(&"app/tags.py: render".to_string()), + "{page:?}" + ); + assert!( + page.contains(&"app/helpers.py: slugify".to_string()), + "{page:?}" + ); + // `all(…)` is the builtin and `prefs.objects.all()` a member of + // `objects`: neither is `UserConfig.all`. + assert_eq!(calls_of(graph, "run"), Vec::::new()); + // A bare call is no method: Python has no implicit self. + assert_eq!(calls_of(graph, "bare_login"), Vec::::new()); + } + + /// Upstream #2125: a method of a class declared inside a function is in + /// scope only in that function, in every language: a `this.run()` in + /// another class is not `f`'s local `Local.run`, while the function's own + /// code still reaches its local class's method. + #[test] + fn pr2125_a_method_of_a_local_class_is_reachable_only_in_its_function() { + let project = Project::new() + .file("tests/test_perms.py", "def test_perm():\n class Request:\n def has_perm(self, perm):\n return perm\n\n def check(self):\n return self.has_perm(\"x\")\n\n return Request().check()\n") + .file("web/a.js", "function f() {\n class Local { run() { return 1; } }\n return new Local();\n}\nclass Other {\n go() { return this.run(); }\n}\n") + .index(); + let graph = project.graph(); + assert_eq!( + calls_of(graph, "go"), + Vec::::new(), + "{}", + graph.report() + ); + assert!( + calls_of(graph, "check") + .contains(&"tests/test_perms.py: test_perm::Request::has_perm".to_string()), + "{}", + graph.report() + ); + } + + /// Upstream #2198 (`71202c38`): a bare Python call to a name its function + /// (or module) binds itself is that value, not another file's function + /// of the name; a pytest fixture the test takes as a parameter is still + /// the fixture, and an imported name is the import's whatever a + /// docstring says. + #[test] + fn pr2198_a_bare_call_to_a_locally_bound_name_is_that_value() { + let project = Project::new() + .file("tests/test_testing.py", "def view(request):\n return request\n") + .file("tests/test_versioning.py", "class TestVersion:\n def test_version(self, request):\n view = MyView.as_view()\n response = view(request)\n return response\n") + .file("tests/conftest.py", "import pytest\n\n\n@pytest.fixture\ndef user_factory():\n def make():\n return 1\n return make\n") + .file("tests/test_users.py", "def test_user(user_factory):\n user = user_factory()\n return user\n") + .file("app/utils.py", "def user_display(user):\n return str(user)\n") + .file("app/tags.py", "from app.utils import user_display\n\n\ndef user_display_tag(user):\n \"\"\"\n {% user_display user as user_display %}\n \"\"\"\n return user_display(user)\n") + .file("docs/example.py", "import pytest\n\n\n@pytest.fixture\ndef func():\n return 1\n") + .file("src/runner.py", "def _run_both(func):\n return func()\n") + .index(); + let graph = project.graph(); + assert!( + !calls_from(graph, "tests/test_versioning.py") + .contains(&"tests/test_testing.py: view".to_string()), + "{}", + graph.report() + ); + assert!( + calls_from(graph, "tests/test_users.py") + .contains(&"tests/conftest.py: user_factory".to_string()) + ); + assert!( + calls_from(graph, "app/tags.py").contains(&"app/utils.py: user_display".to_string()) + ); + // A parameter is its own value: a fixture another module defines, + // with no conftest above the caller, is out of reach. + assert_eq!(calls_from(graph, "src/runner.py"), Vec::::new()); + } + + /// Upstream #2247 (`6d8802ce`): a name imported from a package follows + /// the package's re-exports to its definition, by name (`from .tokens + /// import Token`) and through a star import (`from .users import *`), + /// never to a test's or a migration's namesake. + #[test] + fn pr2247_an_imported_name_follows_the_package_reexports() { + let project = Project::new() + .file("netbox/users/__init__.py", "") + .file( + "netbox/users/models/__init__.py", + "from .tokens import Token\nfrom .users import *\n", + ) + .file("netbox/users/models/users.py", "class User:\n pass\n") + .file("netbox/users/models/tokens.py", "class Token:\n pass\n") + .file("netbox/extras/tests/test_templatetags.py", "class User:\n pass\n") + .file("netbox/alembic/versions/0001_tokens.py", "class Token:\n pass\n") + .file( + "netbox/core/views.py", + "from users.models import Token, User\n\n\ndef make():\n return User(), Token()\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + edges_from(graph, "netbox/core/views.py", "instantiates"), + [ + "netbox/users/models/tokens.py: Token", + "netbox/users/models/users.py: User" + ], + "{}", + graph.report() + ); + } + + /// The re-export walk fails closed: two star-imported modules that both + /// define the name, two imports rebinding it, a cycle of star imports + /// and a chain four modules deep all leave the name unresolved. + #[test] + fn pr2247_an_ambiguous_or_endless_reexport_stays_unresolved() { + let project = Project::new() + .file("app/__init__.py", "") + .file("app/both/__init__.py", "from .one import *\nfrom .two import *\n") + .file("app/both/one.py", "class Client:\n pass\n") + .file("app/both/two.py", "class Client:\n pass\n") + .file( + "app/rebound/__init__.py", + "try:\n from .fast import Parser\nexcept ImportError:\n from .slow import Parser\n", + ) + .file("app/rebound/fast.py", "class Parser:\n pass\n") + .file("app/rebound/slow.py", "class Parser:\n pass\n") + .file("app/loop/__init__.py", "from .a import *\n") + .file("app/loop/a.py", "from .b import *\n") + .file("app/loop/b.py", "from .a import *\n") + .file("app/deep/__init__.py", "from .l1 import *\n") + .file("app/deep/l1.py", "from .l2 import *\n") + .file("app/deep/l2.py", "from .l3 import *\n") + .file("app/deep/l3.py", "from .l4 import *\n") + .file("app/deep/l4.py", "class Engine:\n pass\n") + .file("app/near/__init__.py", "from .l1 import *\n") + .file("app/near/l1.py", "from .l2 import *\n") + .file("app/near/l2.py", "from .l3 import *\n") + .file("app/near/l3.py", "class Motor:\n pass\n") + .file( + "app/use.py", + "from app.both import Client\nfrom app.rebound import Parser\nfrom app.loop import Missing\nfrom app.deep import Engine\nfrom app.near import Motor\n\n\ndef build():\n return Client(), Parser(), Missing(), Engine(), Motor()\n", + ) + .file("other/client.py", "class Client:\n pass\n") + .index(); + let graph = project.graph(); + // Three modules deep is followed; four is not. (What the import + // declines, name matching may still guess, as before.) + assert_eq!( + edges_where(graph, "app/use.py", "instantiates", |edge| { + edge.resolved_by() == Some("import") + }), + ["app/near/l3.py: Motor"], + "{}", + graph.report() + ); + } + + /// Upstream #2248 (`34ede4de`): a module member used through the + /// imported module (`generic.ObjectEditView` after `from netbox.views + /// import generic`) follows the package's re-exports too. + #[test] + fn pr2248_a_module_member_follows_the_package_reexports() { + let project = Project::new() + .file("netbox/netbox/__init__.py", "") + .file("netbox/netbox/views/__init__.py", "") + .file( + "netbox/netbox/views/generic/__init__.py", + "from .object_views import *\n", + ) + .file( + "netbox/netbox/views/generic/object_views.py", + "class ObjectEditView:\n pass\n", + ) + .file( + "netbox/dcim/views.py", + "from netbox.views import generic\n\n\nclass DeviceEditView(generic.ObjectEditView):\n pass\n", + ) + .file("netbox/extras/views.py", "class ObjectEditView:\n pass\n") + .index(); + let graph = project.graph(); + assert_eq!( + edges_from(graph, "netbox/dcim/views.py", "extends"), + ["netbox/netbox/views/generic/object_views.py: ObjectEditView"], + "{}", + graph.report() + ); + } + + /// Upstream #2291 (`f838c5a0`, parts b and c): a parenthesized + /// from-import, one name per line and with a comment holding a call, + /// binds every name it lists; and `pkg.mod.Cls` is the class `import + /// pkg.mod` names, unless another import shares the module's last + /// segment. + #[test] + fn pr2291_parenthesized_imports_and_dotted_module_classes_resolve() { + let project = Project::new() + .file("store/__init__.py", "") + .file( + "store/base.py", + "class Base:\n def fetch(self, ids):\n return ids\n\n\ndef helper():\n return 1\n", + ) + .file("decoy/base.py", "class Base:\n pass\n\n\ndef helper():\n return 2\n") + .file( + "store/rows.py", + "from store.base import (\n Base,\n helper as h, # a comment, with a comma and a call()\n)\n\n\nclass Row(Base):\n def load(self):\n return h()\n", + ) + .file("alpha/__init__.py", "") + .file("beta/__init__.py", "") + .file("alpha/foo.py", "class Client:\n def fetch(self):\n return 1\n") + .file("beta/foo.py", "class Client:\n def fetch(self):\n return 2\n") + .file( + "one.py", + "import alpha.foo\n\n\nclass Sub(alpha.foo.Client):\n pass\n\n\ndef go(pool, s: Sub):\n pool.submit(s.fetch)\n", + ) + .file( + "two.py", + "import beta.foo\nimport alpha.foo\n\n\nclass Sub(alpha.foo.Client):\n pass\n\n\ndef go(pool, s: Sub):\n pool.submit(s.fetch)\n", + ) + .index(); + let graph = project.graph(); + // Name matching guessed `Base` before; the import binds it now. + assert_eq!( + edges_where(graph, "store/rows.py", "extends", |edge| { + edge.resolved_by() == Some("import") + }), + ["store/base.py: Base"], + "{}", + graph.report() + ); + assert_eq!( + calls_from(graph, "store/rows.py"), + ["store/base.py: helper"], + "{}", + graph.report() + ); + let fn_ref = |edge: &EdgeRow| edge.metadata["fnRef"] == true; + assert_eq!( + edges_where(graph, "one.py", "references", fn_ref), + ["alpha/foo.py: Client::fetch"], + "{}", + graph.report() + ); + assert_eq!( + edges_where(graph, "two.py", "references", fn_ref), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// `source name -> target qualified name` of every function-value + /// `references` edge into a node named `member`, sorted. + fn fn_refs_into(graph: &Graph, member: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "references" && edge.metadata["fnRef"] == true) + .filter_map(|edge| { + let target = graph.node(&edge.target)?; + let source = graph.node(&edge.source)?; + (target.name == member) + .then(|| format!("{} -> {}", source.name, target.qualified_name)) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2290 (`a4bd890e`): `self.get_ip(request)` is a method call + /// on the instance even when the file imports a function named `get_ip`; + /// a plain `get_ip(request)` is still the import's. + #[test] + fn pr2290_a_self_call_never_resolves_through_a_same_named_import() { + let httpkit = "def get_ip(request):\n return \"1.2.3.4\"\n"; + let own = Project::new() + .file("pkg/__init__.py", "") + .file("pkg/httpkit.py", httpkit) + .file( + "pkg/adapter.py", + "from pkg.httpkit import get_ip\n\n\nclass Adapter:\n def get_ip(self, request):\n return get_ip(request)\n\n def notify(self, request):\n return self.get_ip(request)\n", + ) + .index(); + let graph = own.graph(); + assert_eq!( + calls_of(graph, "get_ip"), + ["pkg/httpkit.py: get_ip"], + "{}", + graph.report() + ); + assert_eq!( + calls_of(graph, "notify"), + ["pkg/adapter.py: Adapter::get_ip"], + "{}", + graph.report() + ); + let inherited = Project::new() + .file("pkg/__init__.py", "") + .file("pkg/httpkit.py", httpkit) + .file( + "pkg/base.py", + "class Base:\n def get_ip(self, request):\n return \"base\"\n", + ) + .file( + "pkg/adapter.py", + "from pkg.base import Base\nfrom pkg.httpkit import get_ip\n\n\nclass Adapter(Base):\n def notify(self, request):\n return self.get_ip(request)\n\n def raw(self, request):\n return get_ip(request)\n", + ) + .index(); + let graph = inherited.graph(); + assert_eq!( + calls_of(graph, "notify"), + ["pkg/base.py: Base::get_ip"], + "{}", + graph.report() + ); + assert_eq!( + calls_of(graph, "raw"), + ["pkg/httpkit.py: get_ip"], + "{}", + graph.report() + ); + } + + /// Upstream #2291 (a): a method value on a module global with no static + /// type (`conn = None`, rebound by `global conn; conn = Store()`) is a + /// method of the class its module assigns; a parameter, a loop variable + /// or an import shadowing the module is not the global. + #[test] + fn pr2291_a_module_global_bound_by_its_assignments_resolves_as_a_receiver() { + let project = Project::new() + .file("store.py", "class Store:\n def fetch(self, ids):\n return ids\n") + .file("decoy.py", "class Decoy:\n def fetch(self, ids):\n return []\n") + .file( + "settings.py", + "from decoy import Decoy\nconn = None\n\ndef init():\n global conn\n from store import Store\n conn = Store()\n\ndef shadow():\n conn = Decoy()\n return conn\n\ndef same_file(pool):\n pool.submit(conn.fetch, [])\n\ndef param_shadow(pool, conn):\n pool.submit(conn.fetch, [])\n\ndef loop_shadow(pool, items):\n for conn in items:\n pool.submit(conn.fetch, [])\n", + ) + .file( + "consumer.py", + "import settings\nfrom settings import conn\n\ndef via_module(pool):\n pool.submit(settings.conn.fetch, [])\n\ndef via_name(pool):\n pool.submit(conn.fetch, [])\n\ndef import_shadow(pool, settings):\n pool.submit(settings.conn.fetch, [])\n\ndef local_import(pool):\n from settings import conn\n pool.submit(conn.fetch, [])\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + fn_refs_into(graph, "fetch"), + [ + "local_import -> Store::fetch", + "same_file -> Store::fetch", + "via_module -> Store::fetch", + "via_name -> Store::fetch" + ], + "{}", + graph.report() + ); + } + + /// Upstream #2291 (a): a module global several backends can hold binds + /// to the nearest declaration they all inherit; an opaque initializer + /// (`make_conn()`, a conditional) leaves its type unknown. + #[test] + fn pr2291_a_module_global_with_several_backends_binds_their_shared_declaration() { + let project = Project::new() + .file("backends/__init__.py", "") + .file( + "backends/base.py", + "class Base:\n def fetch(self, ids):\n raise NotImplementedError(\"backend\")\n def search(self):\n raise NotImplementedError(\"backend\")\n", + ) + .file( + "backends/es.py", + "from backends.base import Base\nclass ES(Base):\n def search(self):\n return []\n", + ) + .file( + "backends/vec.py", + "from backends.base import (\n Base,\n)\nclass Vec(Base):\n def fetch(self, ids):\n return ids\n def search(self):\n return []\n", + ) + .file("other.py", "class Other:\n def fetch(self, ids):\n return ids\n") + .file( + "settings.py", + "import backends.es\nconn = None\n\ndef init(engine):\n global conn\n if engine == \"es\":\n conn = backends.es.ES()\n else:\n from backends import vec as vec_module\n conn = vec_module.Vec()\n", + ) + .file( + "unknown.py", + "from other import Other\nconn = None\nmixed = None\n\ndef init(flag):\n global conn, mixed\n conn = make_conn()\n mixed = Other() if flag else None\n", + ) + .file( + "consumer.py", + "import settings\nimport unknown\n\ndef gc(pool):\n pool.submit(settings.conn.fetch, [])\n\ndef find(pool):\n pool.submit(settings.conn.search)\n\ndef opaque(pool):\n pool.submit(unknown.conn.fetch, [])\n pool.submit(unknown.mixed.fetch, [])\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + fn_refs_into(graph, "fetch"), + ["gc -> Base::fetch"], + "{}", + graph.report() + ); + assert_eq!( + fn_refs_into(graph, "search"), + ["find -> Base::search"], + "{}", + graph.report() + ); + } + + /// Upstream #2291 (a): the module-global rule never binds through a + /// shadow, an unknown value, a deeper chain, a write it cannot place, or + /// a test double: upstream's adversarial table, one project per case. + #[test] + fn pr2291_a_module_global_never_binds_through_a_shadow_or_an_unknown_value() { + #[allow(clippy::type_complexity)] + let cases: &[(&str, &[(&str, &str)], &[&str])] = &[ + ( + "control", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &["cb -> Store::fetch"], + ), + ( + "tuple_first", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool):\n conn, _ = (Decoy(), None)\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "loop_and_nested_global", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool, items):\n for conn in items:\n pass\n def nested():\n global conn\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "multiline_conditional", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store(\n x=1,\n ) if flag else Decoy()\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "multiline_constructor", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store(\n x=1,\n )\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &["cb -> Store::fetch"], + ), + ( + "closure_param", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef outer(conn):\n def cb(pool):\n pool.submit(conn.fetch)\n return cb\n", + )], + &[], + ), + ( + "closure_local", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef outer(pool):\n conn = Decoy()\n def cb():\n pool.submit(conn.fetch)\n return cb\n", + )], + &[], + ), + ( + "lambda_param", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool):\n pool.submit(lambda conn: pool.map(conn.fetch))\n", + )], + &[], + ), + ( + "comprehension", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool, xs):\n [pool.submit(conn.fetch) for conn in xs]\n", + )], + &[], + ), + ( + "except_as", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool):\n try:\n pass\n except Exception as conn:\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "match_as", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool, x):\n match x:\n case Decoy() as conn:\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "global_in_nested_def", + &[( + "m.py", + "from decoy import Decoy\nconn = None\n\ndef init():\n def helper():\n global conn\n conn = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "keyword_in_multiline_call", + &[( + "m.py", + "from decoy import Decoy\nconn = None\napp = dict(\n conn=Decoy()\n)\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "tuple_rebind", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef reset():\n global conn\n conn, other = Decoy(), 1\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "with_rebind", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef reset():\n global conn\n with open_decoy() as conn:\n pass\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "docstring", + &[( + "m.py", + "from store import Store\nconn = None\n\"\"\"\nconn = Store()\n\"\"\"\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "deeper_chain", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.pool.fetch)\n", + ), + ], + &[], + ), + ( + "importer_rebinds", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "c.py", + "from settings import conn\nfrom decoy import Decoy\n\ndef reset():\n global conn\n conn = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + ), + ], + &[], + ), + ( + "local_import_of_other_module", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "other_settings.py", + "from decoy import Decoy\nconn = Decoy()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n import other_settings as settings\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "local_from_import_of_other_module", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "other_settings.py", + "from decoy import Decoy\nconn = Decoy()\n", + ), + ( + "c.py", + "from settings import conn\n\ndef cb(pool):\n from other_settings import conn\n pool.submit(conn.fetch)\n", + ), + ], + &[], + ), + ( + "comment_in_parenthesized_import", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\nother = 1\n", + ), + ( + "c.py", + "from settings import (\n other, # see fetch()\n conn,\n)\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "star_import_override", + &[ + ("local.py", "from decoy import Decoy\nconn = Decoy()\n"), + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\nfrom local import *\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "match_sequence_capture", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool, x):\n match x:\n case [conn]:\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "match_keyword_capture", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool, x):\n match x:\n case Decoy(inner=conn):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "match_bare_capture", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool, x):\n match x:\n case conn:\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "multiline_loop_target", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool, xs):\n for (a,\n conn) in xs:\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "multiline_tuple_local", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef cb(pool):\n (a,\n conn) = 1, Decoy()\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "multiline_tuple_rebind", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef reset():\n global conn\n (a,\n conn) = 1, Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "annotation_contradicts_value", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn: Store = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "annotation_over_factory", + &[( + "m.py", + "from store import Store\nconn: Store = make_store()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &["cb -> Store::fetch"], + ), + ( + "external_production_write", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "reset.py", + "import settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "external_aliased_write", + &[ + ("pkg/__init__.py", ""), + ( + "pkg/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "reset.py", + "import pkg.settings as cfg\n\ndef reset():\n cfg.conn = make_conn()\n", + ), + ( + "c.py", + "from pkg import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "external_relative_write", + &[ + ("pkg/__init__.py", ""), + ( + "pkg/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "pkg/reset.py", + "from . import settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n", + ), + ( + "c.py", + "from pkg import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "external_setattr", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "reset.py", + "import settings\nfrom decoy import Decoy\n\ndef reset():\n setattr(settings, \"conn\", Decoy())\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "external_subclass_write", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "sub.py", + "from store import Store\nclass SubStore(Store):\n def fetch(self, ids):\n return ids\n", + ), + ( + "reset.py", + "import settings\nfrom sub import SubStore\n\ndef reset():\n settings.conn = SubStore()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "test_double_write", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "tests/test_c.py", + "import settings\nfrom unittest.mock import MagicMock\n\ndef test_cb(pool):\n settings.conn = MagicMock()\n pool.submit(settings.conn.fetch)\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "test_monkeypatch", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "tests/test_c.py", + "import settings\n\ndef test_cb(monkeypatch, pool):\n monkeypatch.setattr(settings, \"conn\", object())\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "globals_literal_write", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef reset():\n globals()[\"conn\"] = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "globals_dynamic_write", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\nfor name, obj in [(\"conn\", Decoy())]:\n globals()[name] = obj\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "unrelated_attribute_write", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "other.py", + "from decoy import Decoy\n\nclass Holder:\n def __init__(self):\n self.conn = Decoy()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "write_path_is_exact", + &[ + ("x/__init__.py", ""), + ("y/__init__.py", ""), + ( + "x/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "y/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "reset.py", + "from x import settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n", + ), + ( + "cx.py", + "from x import settings\n\ndef cb_x(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ( + "cy.py", + "from y import settings\n\ndef cb_y(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb_y -> Store::fetch"], + ), + ( + "ambiguous_write_target", + &[ + ("x/__init__.py", ""), + ("y/__init__.py", ""), + ( + "x/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "y/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "reset.py", + "import settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n", + ), + ( + "cx.py", + "from x import settings\n\ndef cb_x(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ( + "cy.py", + "from y import settings\n\ndef cb_y(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "relative_write_is_exact", + &[ + ("pkg/__init__.py", ""), + ("pkg/sub/__init__.py", ""), + ( + "pkg/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ("other/__init__.py", ""), + ("other/pkg/__init__.py", ""), + ( + "other/pkg/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "pkg/sub/reset.py", + "from .. import settings\n\ndef reset():\n settings.conn = make_conn()\n", + ), + ( + "c2.py", + "from other.pkg import settings\n\ndef cb2(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb2 -> Store::fetch"], + ), + ( + "namespace_import_binds_root", + &[ + ("pkg/__init__.py", ""), + ( + "pkg/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "c.py", + "import pkg.settings\nfrom decoy import Decoy\n\nsettings = Bag()\nsettings.conn = Decoy()\n", + ), + ( + "d.py", + "from pkg import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "alias_of_longer_module_is_not_explicit", + &[ + ("pkg/__init__.py", ""), + ( + "pkg/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ("other/__init__.py", ""), + ("other/pkg/__init__.py", ""), + ("other/pkg/settings.py", "conn = None\n"), + ( + "reset.py", + "import pkg.settings\nimport other.pkg.settings as settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n", + ), + ( + "pkg/d.py", + "from . import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "alias_in_string_is_not_explicit", + &[ + ("pkg/__init__.py", ""), + ("pkg/settings.py", "conn = None\n"), + ( + "reset.py", + "import pkg.settings\nfrom decoy import Decoy\nnote = \"import pkg.settings as settings\"\n\ndef reset():\n settings.conn = Decoy()\n", + ), + ( + "d.py", + "from pkg import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "explicit_alias_equal_to_leaf", + &[ + ("pkg/__init__.py", ""), + ( + "pkg/settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "reset.py", + "import pkg.settings as settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n", + ), + ( + "d.py", + "from pkg import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "one_line_main_write", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "run.py", + "import settings\nfrom decoy import Decoy\n\nif __name__ == \"__main__\": settings.conn = Decoy()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "globals_in_main_block", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\nif __name__ == \"__main__\":\n globals()[\"conn\"] = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &["cb -> Store::fetch"], + ), + ( + "vars_in_function_is_locals", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef dump():\n return vars()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &["cb -> Store::fetch"], + ), + ( + "example_write_is_production", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "examples/wire.py", + "import settings\nfrom decoy import Decoy\n\ndef wire():\n settings.conn = Decoy()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "conftest_write_is_test", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "pkg/conftest.py", + "import settings\nfrom decoy import Decoy\n\ndef fixture():\n settings.conn = Decoy()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "main_block_write", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "run.py", + "import settings\nfrom decoy import Decoy\n\nif __name__ == \"__main__\":\n settings.conn = Decoy()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "own_main_block_write", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\nif __name__ == \"__main__\":\n conn = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &["cb -> Store::fetch"], + ), + ( + "globals_continued_key", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef reset():\n globals()[\n \"conn\"] = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "globals_alias", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\ndef reset():\n g = globals()\n g[\"conn\"] = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &[], + ), + ( + "globals_in_string", + &[( + "m.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\nNOTE = \"globals().update(mapping)\"\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + )], + &["cb -> Store::fetch"], + ), + ( + "external_dict_write", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "reset.py", + "import settings\nfrom decoy import Decoy\n\ndef reset():\n settings.__dict__[\"conn\"] = Decoy()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &[], + ), + ( + "external_annotated_write", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "reset.py", + "import settings\nfrom store import Store\n\ndef reset():\n settings.conn: Store = Store()\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "external_parenthesized_write", + &[ + ( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\ndef init():\n global conn\n conn = Store()\n", + ), + ( + "reset.py", + "import settings\nfrom store import Store\n\ndef reset():\n settings.conn = (Store())\n", + ), + ( + "c.py", + "import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ), + ], + &["cb -> Store::fetch"], + ), + ( + "dotted_import_collision", + &[ + ("alpha/__init__.py", ""), + ("beta/__init__.py", ""), + ( + "alpha/foo.py", + "class Client:\n def fetch(self):\n return 1\n", + ), + ( + "beta/foo.py", + "class Client:\n def fetch(self):\n return 2\n", + ), + ( + "m.py", + "import beta.foo\nimport alpha.foo\nconn = None\n\ndef init():\n global conn\n conn = alpha.foo.Client()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n", + ), + ], + &[], + ), + ( + "dotted_base_collision", + &[ + ("alpha/__init__.py", ""), + ("beta/__init__.py", ""), + ( + "alpha/foo.py", + "class Client:\n def fetch(self):\n return 1\n", + ), + ( + "beta/foo.py", + "class Client:\n def fetch(self):\n return 2\n", + ), + ( + "m.py", + "import beta.foo\nimport alpha.foo\n\nclass Sub(alpha.foo.Client):\n pass\n\ndef go(pool, s: Sub):\n pool.submit(s.fetch)\n", + ), + ], + &[], + ), + ]; + let mut mismatches = Vec::new(); + for (name, files, want) in cases { + let mut project = Project::named(name) + .file( + "store.py", + "class Store:\n def fetch(self, ids):\n return ids\n", + ) + .file( + "decoy.py", + "class Decoy:\n def fetch(self, ids):\n return []\n", + ); + for (path, contents) in *files { + project = project.file(path, contents); + } + let indexed = project.index(); + let got = fn_refs_into(indexed.graph(), "fetch"); + if got != *want { + mismatches.push(format!("{name}: got {got:?}, want {want:?}")); + } + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); + } + + /// Upstream #2228 (`92f141f0`): a pytest test's parameter is the class + /// its in-reach fixture returns (`return Cls(…)`, or a local assigned + /// `Cls(…)`), so a call on it is that class's method, and none of the + /// project's when the class comes from outside. A fixture is in reach + /// from its own module, a `conftest.py` above the test, or a module such + /// a conftest star-imports or lists in `pytest_plugins`. + #[test] + fn pr2228_a_pytest_parameter_is_the_class_its_fixture_returns() { + let project = Project::new() + .file( + "app/cli.py", + "class AlembicGroup:\n def invoke(self, ctx):\n return ctx\n", + ) + .file( + "app/models.py", + "class Forum:\n def save(self):\n return self\n\n\nclass Topic:\n def save(self):\n return self\n\n\nclass Board:\n def pin(self):\n return self\n\n\nclass Stray:\n def pin(self):\n return self\n", + ) + .file( + "tests/conftest.py", + "from tests.fixtures.cli import * # noqa\nfrom tests.fixtures.forum import * # noqa\n\npytest_plugins = [\"tests.fixtures.plugged\"]\n", + ) + .file( + "tests/fixtures/cli.py", + "import pytest\nfrom click.testing import CliRunner\n\n\n@pytest.fixture\ndef runner():\n return CliRunner()\n", + ) + .file( + "tests/fixtures/forum.py", + "import pytest\nfrom app.models import Forum\n\n\n@pytest.fixture\ndef account():\n forum = Forum()\n return forum\n", + ) + .file( + "tests/fixtures/plugged.py", + "import pytest\nfrom app.models import Board\n\n\n@pytest.fixture\ndef board():\n return Board()\n", + ) + .file( + "tests/fixtures/stray.py", + "import pytest\nfrom app.models import Stray\n\n\n@pytest.fixture\ndef loose():\n return Stray()\n", + ) + .file( + "tests/test_cli.py", + "def test_routes(runner):\n result = runner.invoke(cli, [\"routes\"])\n return result\n\n\ndef test_forum(account):\n account.save()\n\n\ndef test_board(board):\n board.pin()\n\n\ndef test_loose(loose):\n loose.pin()\n", + ) + .file( + "tests/test_local.py", + "import pytest\nfrom app.models import Topic\n\n\n@pytest.fixture\ndef thread():\n return Topic()\n\n\ndef test_thread(thread):\n thread.save()\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_of(graph, "test_routes"), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + calls_of(graph, "test_forum"), + ["app/models.py: Forum::save"], + "{}", + graph.report() + ); + assert_eq!( + calls_of(graph, "test_board"), + ["app/models.py: Board::pin"], + "{}", + graph.report() + ); + // A module no conftest pulls in is out of reach. + assert_eq!( + calls_of(graph, "test_loose"), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + calls_of(graph, "test_thread"), + ["app/models.py: Topic::save"], + "{}", + graph.report() + ); + } + + /// Upstream #2175 (`c0a38a87`), its Python part: `x = + /// Model.objects.create|get|first|last|latest|earliest(…)` makes `x` a + /// `Model`, a Django manager call returning one instance; `filter` and + /// `all` return a QuerySet and type nothing. + #[test] + fn pr2175_a_django_manager_call_types_its_result() { + let project = Project::new() + .file( + "app/models.py", + "from django.db import models\n\n\nclass Site(models.Model):\n def save(self, *args, **kwargs):\n super().save(*args, **kwargs)\n\n\nclass Rack(models.Model):\n def save(self, *args, **kwargs):\n super().save(*args, **kwargs)\n", + ) + .file( + "tests/test_sites.py", + "from app.models import Site\n\n\ndef test_site():\n site = Site.objects.create(name=\"a\")\n site.save()\n\n\ndef test_record():\n record = Site.objects.get(pk=1)\n record.save()\n\n\ndef test_many():\n rows = Site.objects.filter(name=\"a\")\n rows.save()\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_of(graph, "test_site"), + ["app/models.py: Site::save"], + "{}", + graph.report() + ); + assert_eq!( + calls_of(graph, "test_record"), + ["app/models.py: Site::save"], + "{}", + graph.report() + ); + assert_eq!( + calls_of(graph, "test_many"), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2169 (`791ae397`), its Python part: a keyword argument + /// inside a call (`prefix=IPNetwork(…),`) is not a binding of `prefix`; + /// the statement above it is. + #[test] + fn pr2169_a_python_keyword_argument_is_no_binding() { + let project = Project::new() + .file( + "app/models.py", + "class Prefix:\n def get_available_ip_count(self):\n return 0\n", + ) + .file( + "app/tests.py", + "from app.models import Prefix\nfrom netaddr import IPNetwork\n\n\ndef test_count():\n prefix = Prefix(\n prefix=IPNetwork('192.0.2.0/24'),\n )\n return prefix.get_available_ip_count()\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_of(graph, "test_count"), + ["app/models.py: Prefix::get_available_ip_count"], + "{}", + graph.report() + ); + } +} + +mod cpp { + use super::support::{Graph, Project, assert_edge, codegraph}; + + /// The qualified names of `file`'s nodes of `kind`, sorted. + fn qualified(graph: &Graph, file: &str, kind: &str) -> Vec { + let mut out: Vec = graph + .nodes + .iter() + .filter(|node| node.file_path == file && node.kind == kind) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// The qualified names of `file`'s nodes that contain one of `parts`. + fn named_after(graph: &Graph, file: &str, parts: &[&str]) -> Vec { + graph + .nodes + .iter() + .filter(|node| node.file_path == file) + .filter(|node| parts.iter().any(|part| node.qualified_name.contains(part))) + .map(|node| node.qualified_name.clone()) + .collect() + } + + const MESSAGE_H: &str = r#"namespace google { +namespace protobuf { + +class Message { + public: + virtual ~Message(); +}; + +} // namespace protobuf +} // namespace google +"#; + const PB_H: &str = r#"// Generated by the protocol buffer compiler. DO NOT EDIT! +#ifndef GOOGLE_PROTOBUF_INCLUDED_any_2eproto +#define GOOGLE_PROTOBUF_INCLUDED_any_2eproto + +#include "google/protobuf/message.h" + +namespace google { +namespace protobuf { + +class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final : public ::google::protobuf::Message +/* @@protoc_insertion_point(class_definition:google.protobuf.Any) */ { + public: + Any() : Any(nullptr) {} + ~Any() PROTOBUF_FINAL; + + [[nodiscard]] const ::google::protobuf::UnknownFieldSet& unknown_fields() const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + return _internal_metadata_.unknown_fields(); + } + [[nodiscard]] ::google::protobuf::UnknownFieldSet* PROTOBUF_NONNULL mutable_unknown_fields() + ABSL_ATTRIBUTE_LIFETIME_BOUND { + return _internal_metadata_.mutable_unknown_fields(); + } + + [[nodiscard]] static const ::google::protobuf::Descriptor* PROTOBUF_NONNULL descriptor() { + return GetDescriptor(); + } + [[nodiscard]] static const ::google::protobuf::Descriptor* PROTOBUF_NONNULL + GetDescriptor() { + return default_instance().GetMetadata().descriptor; + } + [[nodiscard]] static const ::google::protobuf::Reflection* PROTOBUF_NONNULL GetReflection() { + return default_instance().GetMetadata().reflection; + } + void Swap(Any* PROTOBUF_NONNULL other) { + InternalSwap(other); + } + [[nodiscard]] ::std::string* PROTOBUF_NONNULL mutable_type_url(); + + private: + void InternalSwap(Any* PROTOBUF_NONNULL other); +}; + +inline ::std::string* PROTOBUF_NONNULL Any::mutable_type_url() { + return _impl_.type_url_.Mutable(); +} + +} // namespace protobuf +} // namespace google + +#endif // GOOGLE_PROTOBUF_INCLUDED_any_2eproto +"#; + const PB_CC: &str = r#"#include "google/protobuf/any.pb.h" + +namespace google { +namespace protobuf { + +PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT PROTOBUF_EXPORT + PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 const AnyGlobalsTypeInternal Any_globals_ + PROTOBUF_MESSAGE_GLOBALS_SECTION(.data.rel.ro); + +void Any::InternalSwap(Any* PROTOBUF_RESTRICT PROTOBUF_NONNULL other) { + _impl_.type_url_.InternalSwap(&other->_impl_.type_url_); +} + +} // namespace protobuf +} // namespace google +"#; + const REPEATED_H: &str = r#"#include + +namespace google { +namespace protobuf { + +template +class ABSL_ATTRIBUTE_WARN_UNUSED PROTOBUF_DECLSPEC_EMPTY_BASES + RepeatedField final + : private internal::RepeatedFieldBase { + public: + int size() const { return size_; } + + private: + int size_; +}; + +} // namespace protobuf +} // namespace google +"#; + const DESCRIPTOR_H: &str = r#"#include + +namespace google { +namespace protobuf { + +class PROTOBUF_EXPORT Descriptor : private internal::SymbolBase { + public: + PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const { + return all_names_[0]; + } + PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view full_name() const { + return all_names_[1]; + } + PROTOBUF_FUTURE_ADD_EARLY_NODISCARD const FileDescriptor* file() const; +}; + +} // namespace protobuf +} // namespace google +"#; + const FMT_H: &str = r#"#include + +#define FMT_BEGIN_EXPORT +FMT_BEGIN_NAMESPACE + +FMT_BEGIN_EXPORT + +// A generic formatting context with custom output iterator and character +// (code unit) support. +template class generic_context { + private: + OutputIt out_; + + public: + constexpr auto out() -> OutputIt { return out_; } +}; + +FMT_END_EXPORT +FMT_END_NAMESPACE +"#; + const LEVELDB_CC: &str = r#"#include "port/port.h" + +namespace leveldb { + +class Counter { + public: + void IncrementBy(int count) LOCKS_EXCLUDED(mu_) { + MutexLock l(&mu_); + count_ += count; + } + void Lock() EXCLUSIVE_LOCK_FUNCTION() { mu_.Lock(); } + + private: + port::Mutex mu_; + int count_ GUARDED_BY(mu_); +}; + +} // namespace leveldb +"#; + + /// Upstream #2449 (`cdb994b4`), with the passes it builds on (#1070, + /// #1100, #1346): classes written around attribute macros keep their + /// members. Several macros, or one with arguments, between `class` and the + /// name; a macro between a pointer and the declared name, after a + /// parameter list, after a declared name, or opening a declaration; a lone + /// macro line with a comment under it. Before the C++ pre-parse blanked + /// them, protobuf's members were namespace-level functions named after + /// the macros, and leveldb's annotated methods phantoms named after the + /// annotation. + #[test] + fn pr2449_classes_written_around_attribute_macros_keep_their_members() { + let project = Project::new() + .file("src/google/protobuf/message.h", MESSAGE_H) + .file("src/google/protobuf/any.pb.h", PB_H) + .file("src/google/protobuf/any.pb.cc", PB_CC) + .file("src/google/protobuf/repeated_field.h", REPEATED_H) + .file("src/google/protobuf/descriptor.h", DESCRIPTOR_H) + .file("include/fmt/format.h", FMT_H) + .file("db/counter.cc", LEVELDB_CC) + .index(); + let graph = project.graph(); + let pb_h = "src/google/protobuf/any.pb.h"; + assert_eq!( + qualified(graph, pb_h, "class"), + ["google::protobuf::Any"], + "{}", + graph.report() + ); + let methods = qualified(graph, pb_h, "method"); + for method in [ + "google::protobuf::Any::Any", + "google::protobuf::Any::GetDescriptor", + "google::protobuf::Any::GetReflection", + "google::protobuf::Any::Swap", + "google::protobuf::Any::descriptor", + "google::protobuf::Any::mutable_type_url", + "google::protobuf::Any::mutable_unknown_fields", + "google::protobuf::Any::unknown_fields", + ] { + assert!( + methods.iter().any(|name| name == method), + "{method}: {methods:?}" + ); + } + // No member is left at namespace level, and no node is named after a macro. + assert_eq!(qualified(graph, pb_h, "function"), Vec::::new()); + assert_eq!( + named_after(graph, pb_h, &["PROTOBUF_", "ABSL_"]), + Vec::::new() + ); + // The base clause is read, and `::google::protobuf::Message` is found + // from the global scope (upstream #2407, W4-CPP-05). + assert_edge!(project, "class google::protobuf::Any" => extends => "class google::protobuf::Message"); + assert_eq!( + qualified(graph, "src/google/protobuf/any.pb.cc", "method"), + ["google::protobuf::Any::InternalSwap"] + ); + let repeated = "src/google/protobuf/repeated_field.h"; + assert_eq!( + qualified(graph, repeated, "class"), + ["google::protobuf::RepeatedField"] + ); + assert_eq!( + qualified(graph, repeated, "method"), + ["google::protobuf::RepeatedField::size"] + ); + assert_eq!( + qualified(graph, "src/google/protobuf/descriptor.h", "method"), + [ + "google::protobuf::Descriptor::full_name", + "google::protobuf::Descriptor::name" + ] + ); + assert_eq!( + qualified(graph, "include/fmt/format.h", "method"), + ["generic_context::out"] + ); + assert_eq!( + qualified(graph, "include/fmt/format.h", "function"), + Vec::::new() + ); + assert_eq!( + qualified(graph, "db/counter.cc", "method"), + ["leveldb::Counter::IncrementBy", "leveldb::Counter::Lock"] + ); + assert_eq!( + named_after(graph, "db/counter.cc", &["LOCKS", "GUARDED"]), + Vec::::new() + ); + } + + /// Upstream #2449 / OD-5 seed: rocksdb's `struct ALIGN_AS(64U) HandleImpl + /// : public ClockHandle` is the struct `HandleImpl` with its base and + /// members; no node named after the macro's arguments (`(64U) + /// HandleImpl`) is minted. + #[test] + fn pr2449_a_class_head_macro_with_arguments_mints_no_pseudo_node() { + let project = Project::new() + .file( + "cache/clock_cache.h", + "namespace rocksdb {\n\nstruct ClockHandle {\n int refs;\n};\n\nstruct ALIGN_AS(64U) HandleImpl : public ClockHandle {\n void Reset() { refs = 0; }\n};\n\n} // namespace rocksdb\n", + ) + .index(); + let graph = project.graph(); + let file = "cache/clock_cache.h"; + let mut types = qualified(graph, file, "struct"); + types.extend(qualified(graph, file, "class")); + types.sort(); + assert_eq!( + types, + ["rocksdb::ClockHandle", "rocksdb::HandleImpl"], + "{}", + graph.report() + ); + assert_eq!( + qualified(graph, file, "method"), + ["rocksdb::HandleImpl::Reset"] + ); + assert_eq!( + named_after(graph, file, &["(64U)", "ALIGN_AS"]), + Vec::::new() + ); + } + + const JSON_HPP: &str = r#"#pragma once +#define JSON_PRIVATE_UNLESS_TESTED private +#define JSON_HEDLEY_RETURNS_NON_NULL +#define JSON_HEDLEY_WARN_UNUSED_RESULT + +template +class basic_json +{ + JSON_PRIVATE_UNLESS_TESTED: + using lexer = int; + + template + JSON_HEDLEY_RETURNS_NON_NULL + static U* create(Args&& ... args) + { + return nullptr; + } + + class nesting_depth_guard + { + public: + nesting_depth_guard() noexcept +#ifdef JSON_NO_THREAD_LOCAL + : m_okay(false) +#else + : m_okay(depth() < 10) +#endif + { + } + private: + bool m_okay; + }; + + public: + /// @brief returns version information on the library + /// @sa https://json.nlohmann.me/api/basic_json/meta/ + JSON_HEDLEY_WARN_UNUSED_RESULT + static basic_json meta() + { + return basic_json(); + } + + template < typename ValueType > +#if defined(JSON_HAS_CPP_14) + constexpr +#endif + auto get() const -> ValueType + { + return ValueType(); + } + + void dump() const {} +}; +"#; + + /// Upstream #2186 (`5c3aa92f`): nlohmann/json's `basic_json` keeps its + /// members although it spells an access specifier as a macro + /// (`JSON_PRIVATE_UNLESS_TESTED:`), puts lone attribute macros after a + /// template head and after a `/// @sa https://…/` comment, and opens a + /// preprocessor conditional inside a member-initializer list and between + /// a template head and its function. + #[test] + fn pr2186_a_class_around_macros_and_conditionals_keeps_its_members() { + let project = Project::new().file("include/json.hpp", JSON_HPP).index(); + let graph = project.graph(); + let members: Vec<&str> = graph + .nodes + .iter() + .filter(|node| { + node.file_path == "include/json.hpp" + && node.qualified_name.starts_with("basic_json::") + && (node.kind == "method" || node.kind == "class") + }) + .map(|node| node.name.as_str()) + .collect(); + for member in ["create", "dump", "get", "meta", "nesting_depth_guard"] { + assert!( + members.contains(&member), + "{member}: {members:?}\n{}", + graph.report() + ); + } + } + + /// Upstream #2446 (`235387ec`): a struct or class defined in the type of + /// a variable declaration is a node, so calls on its methods link and a + /// base class defined this way has a node to resolve to; an unnamed + /// struct is found by its variable's name, documented by the comment + /// above the declaration. Before, each declaration was handed whole to + /// the variable extractor and the type never became a node. + #[test] + fn pr2446_types_defined_in_declarations_link_and_are_found() { + let project = Project::new() + .file( + "src/registry.cc", + "namespace app {\nstruct Registry { int size() const { return 2; } } registry;\nclass Base { public: virtual int run() { return 1; } } base_instance;\nclass Worker : public Base { public: int run() override { return registry.size(); } };\nint tick() { Worker w; return w.run() + registry.size(); }\n}\n", + ) + .file( + "src/setproctitle.c", + "/* Saved argv. */\nstatic struct { int argc; char **argv; } SPT;\nint spt_init(int argc) { SPT.argc = argc; return SPT.argc; }\n", + ) + .index(); + assert_edge!(project, "method app::Worker::run" => calls => "method app::Registry::size"); + assert_edge!(project, "function app::tick" => calls => "method app::Registry::size"); + assert_edge!(project, "class app::Worker" => extends => "class app::Base"); + + let output = codegraph( + project.root(), + ["search", "SPT", "-p", ".", "--limit", "5", "--json"], + ); + let results: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("search --json output"); + let found: Vec<(String, String, Option)> = results + .as_array() + .expect("search results") + .iter() + .map(|result| { + let node = &result["node"]; + ( + node["kind"].as_str().unwrap_or_default().to_string(), + node["name"].as_str().unwrap_or_default().to_string(), + node["docstring"].as_str().map(str::to_string), + ) + }) + .collect(); + assert!( + found.contains(&( + "struct".to_string(), + "SPT".to_string(), + Some("Saved argv.".to_string()) + )), + "{found:?}" + ); + } + + /// `calls` edges of every method or function qualified `caller` (a + /// prototype and its definition alike), as `qualified (file)`, sorted. + fn callees(graph: &Graph, caller: &str) -> Vec { + calls_touching(graph, caller, false) + } + + /// `calls` edges into every method or function qualified `callee`, as + /// `qualified (file)` of the caller, sorted. + fn callers(graph: &Graph, callee: &str) -> Vec { + calls_touching(graph, callee, true) + } + + fn calls_touching(graph: &Graph, qualified: &str, incoming: bool) -> Vec { + let ids: Vec<&str> = graph + .nodes + .iter() + .filter(|node| { + node.qualified_name == qualified + && (node.kind == "method" || node.kind == "function") + }) + .map(|node| node.id.as_str()) + .collect(); + assert!(!ids.is_empty(), "no function or method {qualified}"); + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| { + edge.kind == "calls" + && ids.contains(&if incoming { + edge.target.as_str() + } else { + edge.source.as_str() + }) + }) + .filter_map(|edge| graph.node(if incoming { &edge.source } else { &edge.target })) + .map(|node| format!("{} ({})", node.qualified_name, node.file_path)) + .collect(); + out.sort(); + out + } + + /// google/leveldb's shapes, trimmed (upstream #2399's fixture): the skip + /// list, the unrelated `Table`, and the decoy `Insert`. + const LEVELDB_ALIAS: &[(&str, &str)] = &[ + ( + "include/leveldb/table.h", + "namespace leveldb {\nclass Table {\n public:\n explicit Table(int* rep);\n int ApproximateOffsetOf(int key) const;\n};\n} // namespace leveldb\n", + ), + ( + "util/cache.cc", + "namespace leveldb {\nclass HandleTable {\n public:\n void Insert(int h) { last_ = h; }\n private:\n int last_;\n};\n} // namespace leveldb\n", + ), + ( + "db/skiplist.h", + "namespace leveldb {\ntemplate \nclass SkipList {\n public:\n explicit SkipList(Comparator cmp, int* arena);\n void Insert(const Key& key);\n class Iterator {\n public:\n explicit Iterator(const SkipList* list);\n void Seek(const Key& target);\n };\n};\ntemplate \nSkipList::SkipList(Comparator cmp, int* arena) {}\ntemplate \nvoid SkipList::Insert(const Key& key) {}\ntemplate \nSkipList::Iterator::Iterator(const SkipList* list) {}\ntemplate \nvoid SkipList::Iterator::Seek(const Key& target) {}\n} // namespace leveldb\n", + ), + ( + "db/memtable.h", + "#include \"db/skiplist.h\"\nnamespace leveldb {\nstruct KeyComparator {\n int operator()(const char* a, const char* b) const;\n};\nclass MemTable {\n public:\n void Add(const char* buf);\n bool Get(const char* key);\n void Rebuild(int* arena);\n void Index(const char* key);\n private:\n typedef SkipList Table;\n using Lookup = Table;\n Table table_;\n Lookup lookup_;\n};\n} // namespace leveldb\n", + ), + ( + "db/memtable.cc", + "#include \"db/memtable.h\"\nnamespace leveldb {\nvoid MemTable::Add(const char* buf) {\n table_.Insert(buf);\n}\nbool MemTable::Get(const char* key) {\n Table::Iterator iter(&table_);\n iter.Seek(key);\n return true;\n}\nvoid MemTable::Rebuild(int* arena) {\n KeyComparator cmp;\n Table fresh(cmp, &arena[0]);\n Table stale(&arena[1]);\n}\nvoid MemTable::Index(const char* key) {\n lookup_.Insert(key);\n}\n} // namespace leveldb\n", + ), + ]; + + fn leveldb_alias_project(extra: &[(&str, &str)]) -> Project { + LEVELDB_ALIAS + .iter() + .chain(extra) + .fold(Project::new(), |project, (path, text)| { + project.file(path, text) + }) + } + + /// Upstream #2399 (`02d22aeb`): a C++ receiver declared through a + /// `typedef` / `using` alias calls the type the alias names. leveldb's + /// `table_.Insert(buf)` on `Table table_;` beside `typedef SkipList Table;` reaches `SkipList::Insert`, not + /// `HandleTable::Insert` guessed by the receiver's name; `Table::Iterator` + /// constructs and calls `SkipList::Iterator`; `Table fresh(cmp, …)` + /// constructs `SkipList`, never the outer `leveldb::Table` the alias + /// hides; an alias of an alias is followed. + #[test] + fn pr2399_a_receiver_declared_through_an_alias_calls_the_aliased_type() { + let project = leveldb_alias_project(&[]).index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "leveldb::MemTable::Add"), + ["leveldb::SkipList::Insert (db/skiplist.h)"], + "{}", + graph.report() + ); + assert_eq!( + callers(graph, "leveldb::HandleTable::Insert"), + Vec::::new() + ); + assert_eq!( + callees(graph, "leveldb::MemTable::Get"), + [ + "leveldb::SkipList::Iterator::Iterator (db/skiplist.h)", + "leveldb::SkipList::Iterator::Seek (db/skiplist.h)", + ] + ); + // `Table stale(&arena[1])` fits no SkipList constructor, and + // `leveldb::Table(int*)` is not the `Table` this scope names. + assert_eq!( + callees(graph, "leveldb::MemTable::Rebuild"), + ["leveldb::SkipList::SkipList (db/skiplist.h)"] + ); + assert_eq!( + callers(graph, "leveldb::Table::Table"), + Vec::::new() + ); + assert_eq!( + callees(graph, "leveldb::MemTable::Index"), + ["leveldb::SkipList::Insert (db/skiplist.h)"] + ); + } + + /// Upstream #2399: an alias spelled from outside its class + /// (`MemTable::Table* t`), and one declared in the calling function. + #[test] + fn pr2399_an_alias_spelled_from_outside_its_class_or_declared_in_the_function() { + let project = leveldb_alias_project(&[( + "db/builder.cc", + "#include \"db/memtable.h\"\nnamespace leveldb {\nvoid Fill(MemTable::Table* t) {\n t->Insert(\"k\");\n}\nvoid Build(int* arena) {\n typedef SkipList List;\n KeyComparator cmp;\n List list(cmp, &arena[0]);\n list.Insert(\"k\");\n}\n} // namespace leveldb\n", + )]) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "leveldb::Fill"), + ["leveldb::SkipList::Insert (db/skiplist.h)"], + "{}", + graph.report() + ); + assert_eq!( + callees(graph, "leveldb::Build"), + [ + "leveldb::SkipList::Insert (db/skiplist.h)", + "leveldb::SkipList::SkipList (db/skiplist.h)", + ] + ); + } + + /// Upstream #2399: no method is guessed by the receiver's name when the + /// aliased type lacks it: `files_` is a `std::vector`, not `Slice::clear`, + /// the one project `clear`, also when the call continues on the next line + /// (protobuf's `symbols_by_parent_` / `.insert(…)`). + #[test] + fn pr2399_no_guess_when_the_aliased_type_lacks_the_method() { + let project = leveldb_alias_project(&[ + ( + "include/leveldb/slice.h", + "namespace leveldb {\nclass Slice {\n public:\n void clear() { size_ = 0; }\n private:\n int size_;\n};\n} // namespace leveldb\n", + ), + ( + "db/version_set.h", + "#include \nnamespace leveldb {\nclass VersionSet {\n public:\n void Clear();\n private:\n using Table = std::vector;\n Table files_;\n};\n} // namespace leveldb\n", + ), + ( + "db/version_set.cc", + "#include \"db/version_set.h\"\nnamespace leveldb {\nvoid VersionSet::Clear() {\n files_.clear();\n}\n} // namespace leveldb\n", + ), + ]) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "leveldb::VersionSet::Clear"), + Vec::::new(), + "{}", + graph.report() + ); + + let protobuf = Project::new() + .file( + "src/descriptor.cc", + "#include \"absl/container/flat_hash_set.h\"\nnamespace google { namespace protobuf {\nclass Arena {\n public:\n void insert(int block) {}\n};\nclass FileDescriptorTables {\n public:\n bool AddAliasUnderParent(int symbol);\n private:\n typedef absl::flat_hash_set SymbolsByParentSet;\n SymbolsByParentSet symbols_by_parent_;\n};\nbool FileDescriptorTables::AddAliasUnderParent(int symbol) {\n return symbols_by_parent_\n .insert(symbol)\n .second;\n}\n} }\n", + ) + .index(); + let graph = protobuf.graph(); + assert_eq!( + callees( + graph, + "google::protobuf::FileDescriptorTables::AddAliasUnderParent" + ), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2399: a class declared in an inner scope hides an outer + /// alias of the same name; outside it, the alias applies. + #[test] + fn pr2399_an_inner_class_hides_an_outer_alias_of_its_name() { + let project = Project::new() + .file( + "app/holder.h", + "namespace app {\nclass Gadget {\n public:\n void Spin();\n};\nusing Widget = Gadget;\nclass Holder {\n public:\n class Widget {\n public:\n void Spin();\n };\n void Use();\n private:\n Widget w_;\n};\n} // namespace app\n", + ) + .file( + "app/holder.cc", + "#include \"app/holder.h\"\nnamespace app {\nvoid Gadget::Spin() {}\nvoid Holder::Widget::Spin() {}\nvoid Holder::Use() {\n w_.Spin();\n}\nvoid Twirl(Widget& w) {\n w.Spin();\n}\n} // namespace app\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "app::Holder::Use"), + ["app::Holder::Widget::Spin (app/holder.cc)"], + "{}", + graph.report() + ); + assert_eq!( + callees(graph, "app::Twirl"), + ["app::Gadget::Spin (app/holder.cc)"] + ); + } + + /// Upstream #2399: an alias a class inherits is found in the base class + /// before an enclosing namespace's alias template, and a dependent alias + /// (`typename Traits::Field`) is not followed (protobuf's json traits). + #[test] + fn pr2399_an_inherited_alias_is_found_before_the_namespace() { + let project = Project::new() + .file( + "protobuf/descriptor.h", + "namespace google { namespace protobuf {\nclass FieldDescriptor {\n public:\n bool is_repeated() const;\n};\ninline bool FieldDescriptor::is_repeated() const { return false; }\nclass OneofDescriptor {\n public:\n bool is_repeated() const { return false; }\n};\n} }\n", + ) + .file( + "protobuf/json/traits.h", + "#include \"protobuf/descriptor.h\"\nnamespace google { namespace protobuf { namespace json_internal {\ntemplate \nusing Field = typename Traits::Field;\nstruct Proto2Descriptor {\n using Field = const FieldDescriptor*;\n};\nstruct ParseProto2Descriptor : Proto2Descriptor {\n static bool NewMsg(Field f) { return f->is_repeated(); }\n};\n} } }\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees( + graph, + "google::protobuf::json_internal::ParseProto2Descriptor::NewMsg" + ), + ["google::protobuf::FieldDescriptor::is_repeated (protobuf/descriptor.h)"], + "{}", + graph.report() + ); + } + + /// Upstream #2399: an alias of a template parameter names no type, so a + /// class that shares the alias's name is not it (protobuf's + /// `GenericTypeHandler` `using Type = GenericType;` beside the generated + /// `Type` message). + #[test] + fn pr2399_an_alias_of_a_template_parameter_names_no_type() { + let project = Project::new() + .file( + "protobuf/type.pb.h", + "namespace google { namespace protobuf {\nclass Type {\n public:\n void Clear();\n};\ninline void Type::Clear() {}\n} }\n", + ) + .file( + "protobuf/repeated_ptr_field.h", + "namespace google { namespace protobuf { namespace internal {\ntemplate \nclass GenericTypeHandler {\n public:\n using Type = GenericType;\n static void Reset(Type* value) { value->Clear(); }\n};\n} } }\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees( + graph, + "google::protobuf::internal::GenericTypeHandler::Reset" + ), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2399: `->` through an alias of an iterator reaches the + /// element type, which the alias does not name, so the alias rule leaves + /// the call to the guesses (gmock's `ExpectationSet::const_iterator`, a + /// `std::set` iterator). Upstream's receiver is `it`; here + /// it shares a word with the method's class, since a lone same-named + /// method is guessed only then (KEEP-RUST #226). + #[test] + fn pr2399_an_arrow_through_an_iterator_alias_still_reaches_a_guess() { + let project = Project::new() + .file( + "gmock/expectation.h", + "#include \nnamespace testing {\nclass Expectation {\n public:\n int expectation_base() const { return 0; }\n};\nclass ExpectationSet {\n public:\n typedef ::std::set Set;\n typedef Set::const_iterator const_iterator;\n};\nint Describe(const ExpectationSet& set) {\n ExpectationSet::const_iterator expectation_it;\n return expectation_it->expectation_base();\n}\n} // namespace testing\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "testing::Describe"), + ["testing::Expectation::expectation_base (gmock/expectation.h)"], + "{}", + graph.report() + ); + } + + /// Upstream #2399: a class template's specialization can declare a member + /// the template itself does not (rocksdb's `omt_node_templated` adds `get_marked`), so an alias of a project class template + /// does not rule a call out. The specialization is indexed as + /// ``, as upstream indexes it, so the call is a guess by the + /// method's name; upstream's receiver is `node`, and here it shares a word + /// with the method, since a lone same-named method is guessed only then + /// (KEEP-RUST #226). + #[test] + fn pr2399_an_alias_of_a_class_template_does_not_rule_a_call_out() { + let project = Project::new() + .file( + "util/omt.h", + "namespace toku {\nnamespace omt_internal {\ntemplate \nclass omt_node_templated {\n public:\n void clear_stolen_bits() {}\n};\ntemplate \nclass omt_node_templated {\n public:\n bool get_marked() const { return false; }\n};\n} // namespace omt_internal\ntemplate \nclass omt {\n public:\n bool has_marks() const;\n private:\n typedef omt_internal::omt_node_templated omt_node;\n omt_node root_;\n};\ntemplate \nbool omt::has_marks() const {\n const omt_node &marked_node = root_;\n return marked_node.get_marked();\n}\n} // namespace toku\n", + ) + .index(); + let graph = project.graph(); + let names: Vec = callees(graph, "toku::omt::has_marks") + .into_iter() + .map(|callee| { + callee + .split(" (") + .next() + .and_then(|qualified| qualified.rsplit("::").next()) + .unwrap_or_default() + .to_string() + }) + .collect(); + assert_eq!(names, ["get_marked"], "{}", graph.report()); + } + + /// Upstream #2399: a declaration read from another class is not known to + /// be the receiver's (fmt's scan.h: `auto data = scan_data()` + /// deduces nothing the reader can see, and the `data` it then finds is + /// another class's `const scan_arg* data;`). + #[test] + fn pr2399_a_declaration_read_from_another_class_is_not_the_receivers() { + let project = Project::new() + .file( + "test/scan.h", + "namespace fmt {\nclass basic_scan_arg {\n public:\n int type() const { return 0; }\n};\nusing scan_arg = basic_scan_arg;\nclass scan_args {\n const scan_arg* data;\n};\ntemplate class scan_data {\n public:\n int make_args() { return 0; }\n};\ntemplate int scan() {\n auto data = scan_data();\n return data.make_args();\n}\n} // namespace fmt\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "fmt::scan"), + ["fmt::scan_data::make_args (test/scan.h)"], + "{}", + graph.report() + ); + } + + /// Upstream #2399's "no guess" cases hold here before the port too, since + /// a lone same-named method is guessed only when the receiver shares a + /// word with it (KEEP-RUST #226). With a decoy that does share one + /// (`FilesIndex::clear` for `files_`, `ParentSymbols::insert` for + /// `symbols_by_parent_`), the base guessed it; the aliased type lacking + /// the method now rules the guess out. + #[test] + fn pr2399_no_guess_even_when_a_decoy_shares_the_receivers_word() { + let project = Project::new() + .file( + "include/leveldb/files_index.h", + "namespace leveldb {\nclass FilesIndex {\n public:\n void clear() { size_ = 0; }\n private:\n int size_;\n};\n} // namespace leveldb\n", + ) + .file( + "db/version_set.h", + "#include \nnamespace leveldb {\nclass VersionSet {\n public:\n void Clear();\n private:\n using Table = std::vector;\n Table files_;\n};\n} // namespace leveldb\n", + ) + .file( + "db/version_set.cc", + "#include \"db/version_set.h\"\nnamespace leveldb {\nvoid VersionSet::Clear() {\n files_.clear();\n}\n} // namespace leveldb\n", + ) + .file( + "src/descriptor.cc", + "#include \"absl/container/flat_hash_set.h\"\nnamespace google { namespace protobuf {\nclass ParentSymbols {\n public:\n void insert(int block) {}\n};\nclass FileDescriptorTables {\n public:\n bool AddAliasUnderParent(int symbol);\n private:\n typedef absl::flat_hash_set SymbolsByParentSet;\n SymbolsByParentSet symbols_by_parent_;\n};\nbool FileDescriptorTables::AddAliasUnderParent(int symbol) {\n return symbols_by_parent_\n .insert(symbol)\n .second;\n}\n} }\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "leveldb::VersionSet::Clear"), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + callees( + graph, + "google::protobuf::FileDescriptorTables::AddAliasUnderParent" + ), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2399's alias choice: two headers declaring the same alias in + /// the same scope for different types, neither the calling file's nor its + /// header's, name no type to follow (an `#if` arm each, as rocksdb + /// writes); the calling file's own declaration of it is the one its + /// translation unit sees. + #[test] + fn pr2399_an_alias_two_headers_declare_differently_is_not_followed() { + let ambiguous = Project::new() + .file( + "store/kinds.h", + "namespace app {\nclass MemStore {\n public:\n void Save() {}\n};\nclass DiskStore {\n public:\n void Save() {}\n};\n} // namespace app\n", + ) + .file( + "config/fast.h", + "#include \"store/kinds.h\"\nnamespace app {\nusing Store = MemStore;\n} // namespace app\n", + ) + .file( + "config/safe.h", + "#include \"store/kinds.h\"\nnamespace app {\nusing Store = DiskStore;\n} // namespace app\n", + ) + .file( + "src/use.cc", + "#include \"config/fast.h\"\nnamespace app {\nvoid Run() {\n Store store;\n store.Save();\n}\n} // namespace app\n", + ) + .index(); + let graph = ambiguous.graph(); + assert_eq!( + callees(graph, "app::Run"), + Vec::::new(), + "{}", + graph.report() + ); + + let own = Project::new() + .file( + "store/kinds.h", + "namespace app {\nclass MemStore {\n public:\n void Save() {}\n};\nclass DiskStore {\n public:\n void Save() {}\n};\n} // namespace app\n", + ) + .file( + "config/fast.h", + "#include \"store/kinds.h\"\nnamespace app {\nusing Store = MemStore;\n} // namespace app\n", + ) + .file( + "src/use.cc", + "#include \"store/kinds.h\"\nnamespace app {\nusing Store = DiskStore;\nvoid Run() {\n Store store;\n store.Save();\n}\n} // namespace app\n", + ) + .index(); + let graph = own.graph(); + assert_eq!( + callees(graph, "app::Run"), + ["app::DiskStore::Save (store/kinds.h)"], + "{}", + graph.report() + ); + } + + /// Upstream #2407's fixture (`cpp-base-class-lookup.test.ts`): protobuf's + /// `Message` beside a nested namesake and the PHP extension's C struct, + /// leveldb's iterators and test comparator, rocksdb's nested bases, + /// aliases and using-directive, googletest's global `::testing::Test`, a + /// template parameter, and a using-directive only a header writes. + const BASE_LOOKUP: &[(&str, &str)] = &[ + ( + "src/google/protobuf/message_lite.h", + "namespace google {\nnamespace protobuf {\nnamespace internal {\nclass MessageGlobalsBase {\n public:\n int default_instance() const { return 0; }\n};\n} // namespace internal\n} // namespace protobuf\n} // namespace google\n", + ), + ( + "src/google/protobuf/message.h", + "#include \"google/protobuf/message_lite.h\"\nnamespace google {\nnamespace protobuf {\nclass Message {\n public:\n virtual ~Message();\n virtual int ByteSizeLong() const = 0;\n};\n} // namespace protobuf\n} // namespace google\n", + ), + ( + "src/google/protobuf/json/internal/untyped_message.h", + "namespace google {\nnamespace protobuf {\nnamespace json_internal {\nclass ResolverPool {\n public:\n class Message {\n public:\n int ByteSizeLong() const { return 0; }\n };\n};\n} // namespace json_internal\n} // namespace protobuf\n} // namespace google\n", + ), + ( + "php/ext/google/protobuf/message.c", + "typedef struct {\n int std;\n} Message;\n", + ), + ( + "src/google/protobuf/dynamic_message.cc", + "#include \"google/protobuf/message.h\"\nnamespace google {\nnamespace protobuf {\nclass DynamicMessage final : public Message {\n public:\n int ByteSizeLong() const override { return 1; }\n};\n} // namespace protobuf\n} // namespace google\n", + ), + ( + "src/google/protobuf/generated_message_bases.h", + "#include \"google/protobuf/message.h\"\nnamespace google {\nnamespace protobuf {\nnamespace internal {\nclass ZeroFieldsBase : public Message {\n public:\n int ByteSizeLong() const override { return 0; }\n};\n} // namespace internal\n} // namespace protobuf\n} // namespace google\n", + ), + ( + "src/google/protobuf/any.pb.cc", + "#include \"google/protobuf/message_lite.h\"\nnamespace _pbi = ::google::protobuf::internal;\nnamespace google {\nnamespace protobuf {\nstruct AnyGlobalsTypeInternal : ::_pbi::MessageGlobalsBase {\n int x;\n};\n} // namespace protobuf\n} // namespace google\n", + ), + ( + "include/leveldb/iterator.h", + "namespace leveldb {\nclass Iterator {\n public:\n virtual ~Iterator();\n virtual bool Valid() const = 0;\n virtual void Next() = 0;\n};\n} // namespace leveldb\n", + ), + ( + "db/skiplist.h", + "namespace leveldb {\ntemplate \nclass SkipList {\n public:\n class Iterator {\n public:\n bool Valid() const { return node_ != nullptr; }\n void Next() { node_ = nullptr; }\n void SeekToFirst() { node_ = nullptr; }\n private:\n const char* node_;\n };\n};\n} // namespace leveldb\n", + ), + ( + "db/db_iter.cc", + "#include \"include/leveldb/iterator.h\"\nnamespace leveldb {\nclass DBIter : public Iterator {\n public:\n bool Valid() const override { return valid_; }\n void Next() override { valid_ = false; }\n private:\n bool valid_;\n};\n} // namespace leveldb\n", + ), + ( + "include/leveldb/comparator.h", + "namespace leveldb {\nclass Comparator {\n public:\n virtual int Compare(const char* a, const char* b) const = 0;\n};\n} // namespace leveldb\n", + ), + ( + "db/skiplist_test.cc", + "namespace leveldb {\nstruct Comparator {\n int operator()(const int& a, const int& b) const { return a - b; }\n};\n} // namespace leveldb\n", + ), + ( + "db/db_test.cc", + "#include \"include/leveldb/comparator.h\"\nnamespace leveldb {\nclass NumberComparator : public Comparator {\n public:\n int Compare(const char* a, const char* b) const override { return 0; }\n};\n} // namespace leveldb\n", + ), + ( + "include/leveldb/filter_policy.h", + "namespace leveldb {\nclass FilterPolicy {\n public:\n virtual const char* Name() const = 0;\n};\n} // namespace leveldb\n", + ), + ( + "db/c.cc", + "#include \"include/leveldb/filter_policy.h\"\nusing leveldb::FilterPolicy;\nstruct leveldb_filterpolicy_t : public FilterPolicy {\n const char* Name() const override { return \"c\"; }\n};\n", + ), + ( + "cache/clock_cache.h", + "namespace rocksdb {\nnamespace clock_cache {\nclass BaseClockTable {\n public:\n struct BaseOpts {\n int eviction_effort_cap;\n };\n};\nclass AutoHyperClockTable : public BaseClockTable {\n public:\n struct Opts : public BaseOpts {\n int min_avg_value_size;\n };\n};\n} // namespace clock_cache\n} // namespace rocksdb\n", + ), + ( + "cache/lru_cache.h", + "namespace rocksdb {\nnamespace lru_cache {\nstruct BaseOpts {\n int capacity;\n};\n} // namespace lru_cache\n} // namespace rocksdb\n", + ), + ( + "table/internal_iterator.h", + "namespace rocksdb {\nclass Slice {};\ntemplate \nclass InternalIteratorBase {\n public:\n virtual void Next() = 0;\n void SeekToFirst() {}\n};\nusing InternalIterator = InternalIteratorBase;\ntemplate using bool_constant = std::integral_constant;\n} // namespace rocksdb\n", + ), + ( + "table/merging_iterator.cc", + "#include \"table/internal_iterator.h\"\nnamespace rocksdb {\nclass MergingIterator : public InternalIterator {\n public:\n void Next() override {}\n};\nvoid Rewind(MergingIterator* iter) {\n iter->SeekToFirst();\n}\ntemplate \nstruct is_fast_float : bool_constant {};\n} // namespace rocksdb\n", + ), + ( + "include/rocksdb/listener.h", + "namespace rocksdb {\nclass EventListener {\n public:\n virtual void OnFlushCompleted() {}\n};\n} // namespace rocksdb\n", + ), + ( + "examples/compact_files_example.cc", + "#include \"include/rocksdb/listener.h\"\nusing namespace rocksdb;\nclass Compactor : public EventListener {\n public:\n void OnFlushCompleted() override {}\n};\n", + ), + ( + "test/gtest/gtest.h", + "namespace testing {\nclass Test {\n public:\n virtual void TestBody() = 0;\n};\nnamespace internal {\nclass SuiteApiResolver {\n public:\n typedef int Test;\n};\n} // namespace internal\n} // namespace testing\n", + ), + ( + "test/gtest-extra-test.cc", + "#include \"test/gtest/gtest.h\"\nclass single_evaluation_test : public ::testing::Test {\n protected:\n void TestBody() override {}\n};\n", + ), + ( + "util/mixin.h", + "class Base {\n public:\n void Run() {}\n};\ntemplate \nclass Logged : public Base {\n public:\n void Log() {}\n};\n", + ), + ( + "util/testharness.h", + "#include \"include/leveldb/env.h\"\nusing namespace leveldb;\n", + ), + ( + "include/leveldb/env.h", + "namespace leveldb {\nclass EnvWrapper {\n public:\n virtual int NowMicros() { return 0; }\n};\n} // namespace leveldb\n", + ), + ( + "util/env_test.cc", + "#include \"util/testharness.h\"\nclass SleepingEnv : public EnvWrapper {\n public:\n int NowMicros() override { return 1; }\n};\n", + ), + ]; + + fn base_lookup_project() -> super::support::Indexed { + BASE_LOOKUP + .iter() + .fold(Project::new(), |project, (path, text)| { + project.file(path, text) + }) + .index() + } + + /// The one node qualified `qualified` in `file` (no import). + #[track_caller] + fn the_class<'g>(graph: &'g Graph, qualified: &str, file: &str) -> &'g super::support::NodeRow { + let found: Vec<&super::support::NodeRow> = graph + .nodes + .iter() + .filter(|node| { + node.qualified_name == qualified && node.file_path == file && node.kind != "import" + }) + .collect(); + assert_eq!(found.len(), 1, "{qualified} in {file}: {found:?}"); + found[0] + } + + /// The supertypes of a class, as `kind qualified (file) resolvedBy`. + #[track_caller] + fn supertypes(graph: &Graph, qualified: &str, file: &str) -> Vec { + let id = &the_class(graph, qualified, file).id; + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| { + &edge.source == id && (edge.kind == "extends" || edge.kind == "implements") + }) + .map(|edge| { + let target = graph.node(&edge.target); + format!( + "{} {} ({}) {}", + edge.kind, + target.map_or("?", |node| node.qualified_name.as_str()), + target.map_or("?", |node| node.file_path.as_str()), + edge.resolved_by().unwrap_or("?") + ) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2407 (`4040eab9`): a C++ base class is the one name lookup + /// finds from the deriving class: in the namespaces around it, never a + /// class another source file defines for itself, a nested class's base in + /// its outer class and what that one inherits, through the namespace + /// aliases, using-declarations and using-directives the file writes, + /// `::testing::Test` from the global scope, an alias followed to the class + /// it names (and an alias of an outside type linked itself), a template + /// parameter left unresolved, and the only class of that name the file can + /// see when the lookup finds none. No C/C++ base reaches another language. + #[test] + fn pr2407_a_base_class_is_the_class_name_lookup_finds() { + let project = base_lookup_project(); + let graph = project.graph(); + let report = || graph.report(); + assert_eq!( + supertypes( + graph, + "google::protobuf::DynamicMessage", + "src/google/protobuf/dynamic_message.cc" + ), + ["extends google::protobuf::Message (src/google/protobuf/message.h) qualified-name"], + "{}", + report() + ); + assert_eq!( + supertypes( + graph, + "google::protobuf::internal::ZeroFieldsBase", + "src/google/protobuf/generated_message_bases.h" + ), + ["extends google::protobuf::Message (src/google/protobuf/message.h) qualified-name"] + ); + assert_eq!( + supertypes(graph, "leveldb::DBIter", "db/db_iter.cc"), + ["extends leveldb::Iterator (include/leveldb/iterator.h) qualified-name"] + ); + assert_eq!( + supertypes(graph, "leveldb::NumberComparator", "db/db_test.cc"), + ["extends leveldb::Comparator (include/leveldb/comparator.h) qualified-name"] + ); + assert_eq!( + supertypes( + graph, + "rocksdb::clock_cache::AutoHyperClockTable::Opts", + "cache/clock_cache.h" + ), + [ + "extends rocksdb::clock_cache::BaseClockTable::BaseOpts (cache/clock_cache.h) qualified-name" + ] + ); + assert_eq!( + supertypes( + graph, + "google::protobuf::AnyGlobalsTypeInternal", + "src/google/protobuf/any.pb.cc" + ), + [ + "extends google::protobuf::internal::MessageGlobalsBase (src/google/protobuf/message_lite.h) qualified-name" + ] + ); + assert_eq!( + supertypes(graph, "leveldb_filterpolicy_t", "db/c.cc"), + ["extends leveldb::FilterPolicy (include/leveldb/filter_policy.h) qualified-name"] + ); + assert_eq!( + supertypes(graph, "Compactor", "examples/compact_files_example.cc"), + ["extends rocksdb::EventListener (include/rocksdb/listener.h) qualified-name"] + ); + assert_eq!( + supertypes(graph, "single_evaluation_test", "test/gtest-extra-test.cc"), + ["extends testing::Test (test/gtest/gtest.h) qualified-name"] + ); + assert_eq!( + supertypes( + graph, + "rocksdb::MergingIterator", + "table/merging_iterator.cc" + ), + ["extends rocksdb::InternalIteratorBase (table/internal_iterator.h) qualified-name"] + ); + // `bool_constant`: the comparison is no + // template bracket. + assert_eq!( + supertypes(graph, "rocksdb::is_fast_float", "table/merging_iterator.cc"), + ["extends rocksdb::bool_constant (table/internal_iterator.h) qualified-name"] + ); + assert_eq!( + supertypes(graph, "Logged", "util/mixin.h"), + Vec::::new() + ); + let logged = &the_class(graph, "Logged", "util/mixin.h").id; + assert_eq!( + graph + .unresolved + .iter() + .filter(|reference| &reference.from_node_id == logged + && reference.reference_kind == "extends") + .map(|reference| reference.reference_name.as_str()) + .collect::>(), + ["Base"] + ); + // `using namespace leveldb;` is in a header the file includes. + assert_eq!( + supertypes(graph, "SleepingEnv", "util/env_test.cc"), + ["extends leveldb::EnvWrapper (include/leveldb/env.h) exact-match"] + ); + // No C or C++ base reaches a framework resolver or another language. + let mut bases = 0; + for edge in graph + .edges + .iter() + .filter(|edge| edge.kind == "extends" || edge.kind == "implements") + { + let (Some(source), Some(target)) = (graph.node(&edge.source), graph.node(&edge.target)) + else { + continue; + }; + if source.language != "cpp" && source.language != "c" { + continue; + } + bases += 1; + assert_ne!( + edge.resolved_by(), + Some("framework"), + "{} -> {}", + source.qualified_name, + target.qualified_name + ); + assert!( + target.language == "cpp" || target.language == "c", + "{} -> {} ({})", + source.qualified_name, + target.qualified_name, + target.language + ); + } + assert!(bases > 0); + let c_message = &the_class(graph, "Message", "php/ext/google/protobuf/message.c").id; + assert!( + !graph + .edges + .iter() + .any(|edge| &edge.target == c_message && edge.kind == "extends"), + "{}", + report() + ); + } + + /// Upstream #2407: a call to a method a class inherits from the base an + /// alias names reaches that base's method (rocksdb's `MergingIterator : + /// public InternalIterator`, `using InternalIterator = + /// InternalIteratorBase;`). + #[test] + fn pr2407_an_inherited_method_is_found_on_the_base_an_alias_names() { + let project = base_lookup_project(); + let graph = project.graph(); + assert_eq!( + callees(graph, "rocksdb::Rewind"), + ["rocksdb::InternalIteratorBase::SeekToFirst (table/internal_iterator.h)"], + "{}", + graph.report() + ); + } + + /// Upstream #2426's last case, which needs #2407's lookup: protobuf's + /// `FieldDescriptor`, walked in brace scopes after an unknown attribute + /// macro broke the tree, keeps its namespaces, and its base + /// `internal::SymbolBase` is `google::protobuf::internal::SymbolBase`, not + /// the `other::internal::SymbolBase` namesake (W4-CPP-02 carried it here). + #[test] + fn pr2426_a_class_that_lost_its_namespace_finds_its_base_by_scope_lookup() { + let project = Project::new() + .file( + "src/descriptor.h", + "namespace google {\nnamespace protobuf {\n\nclass Names {\n public:\n NODISCARD absl::string_view name() const {\n return absl::string_view(payload_);\n }\n\n private:\n const char* payload_;\n};\n\nclass FieldDescriptor : private internal::SymbolBase {\n public:\n int index() const;\n};\n\n} // namespace protobuf\n} // namespace google\n", + ) + .file( + "src/symbol_base.h", + "namespace google {\nnamespace protobuf {\nnamespace internal {\nclass SymbolBase {\n public:\n int kind() const;\n};\n} // namespace internal\n} // namespace protobuf\n} // namespace google\n", + ) + .file( + "src/other.h", + "namespace other {\nnamespace internal {\nclass SymbolBase {\n public:\n int kind() const;\n};\n} // namespace internal\n} // namespace other\n", + ) + .index(); + let graph = project.graph(); + let derived = &the_class( + graph, + "google::protobuf::FieldDescriptor", + "src/descriptor.h", + ) + .id; + let bases: Vec<&str> = graph + .edges + .iter() + .filter(|edge| &edge.source == derived && edge.kind == "extends") + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.as_str()) + .collect(); + assert_eq!( + bases, + ["google::protobuf::internal::SymbolBase"], + "{}", + graph.report() + ); + } + + /// Source lines joined as a file, with its last newline (upstream's + /// `lines(…)`). + fn lines(text: &[&str]) -> String { + let mut out = text.join("\n"); + out.push('\n'); + out + } + + /// The qualified names of the `calls` callees of `caller`, sorted. + fn callee_names(graph: &Graph, caller: &str) -> Vec { + callees(graph, caller) + .into_iter() + .map(|callee| { + callee + .rsplit_once(" (") + .map_or(callee.clone(), |(name, _)| name.to_string()) + }) + .collect() + } + + /// The `calls` callees of `caller` with how each was resolved, as + /// `qualified resolvedBy@confidence`, sorted. + fn resolved_callees(graph: &Graph, caller: &str) -> Vec { + let ids: Vec<&str> = graph + .nodes + .iter() + .filter(|node| { + node.qualified_name == caller && (node.kind == "method" || node.kind == "function") + }) + .map(|node| node.id.as_str()) + .collect(); + assert!(!ids.is_empty(), "no function or method {caller}"); + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls" && ids.contains(&edge.source.as_str())) + .filter_map(|edge| { + graph.node(&edge.target).map(|node| { + format!( + "{} {}@{}", + node.qualified_name, + edge.resolved_by().unwrap_or("?"), + edge.confidence() + .map_or_else(|| "?".to_string(), |c| c.to_string()) + ) + }) + }) + .collect(); + out.sort(); + out + } + + /// leveldb's Slice and its namesakes elsewhere (upstream #2413's + /// `PROJECT`): every method a library type's call could be mistaken for. + fn library_type_project(extra: &[(&str, String)]) -> super::support::Indexed { + let project = Project::new() + .file( + "include/leveldb/slice.h", + &lines(&[ + "namespace leveldb {", + "class Slice {", + " public:", + " const char* data() const { return data_; }", + " unsigned size() const { return size_; }", + " void clear() { size_ = 0; }", + " const char* begin() const { return data_; }", + " void remove_prefix(unsigned n) { size_ -= n; }", + " private:", + " const char* data_;", + " unsigned size_;", + "};", + "} // namespace leveldb", + ]), + ) + .file( + "util/buffers.h", + &lines(&[ + "namespace leveldb {", + "class LeftoverBuffer {", + " public:", + " void append(const char* s, unsigned n) {}", + "};", + "class Arena {", + " public:", + " void insert(int block) {}", + "};", + "class Cache {", + " public:", + " void reset() {}", + "};", + "class Iterator {", + " public:", + " void SeekToFirst() {}", + "};", + "} // namespace leveldb", + ]), + ); + extra + .iter() + .fold(project, |project, (path, text)| project.file(path, text)) + .index() + } + + /// Upstream #2413 (`61f8b09c`): a C++ receiver the calling function or + /// its class declares as a type from outside the project with a + /// lowercase name calls that type's own member. leveldb's `std::string + /// saved_key_; saved_key_.clear()` reached `Slice::clear`, the one + /// project `clear`. The receiver here is `saved_slice_`: Rust's guess of a + /// lone candidate needs a word the receiver shares with it (#226, + /// KEEP-RUST), and this one would reach `Slice::clear` without the stop. + #[test] + fn pr2413_a_std_string_member_calls_its_own_member() { + let project = library_type_project(&[( + "db/db_iter.cc", + lines(&[ + "#include ", + "#include \"include/leveldb/slice.h\"", + "namespace leveldb {", + "class DBIter {", + " public:", + " void Next();", + " private:", + " std::string saved_slice_; // == current key when direction_==kReverse", + "};", + "void DBIter::Next() {", + " saved_slice_.clear();", + "}", + "} // namespace leveldb", + ]), + )]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::DBIter::Next"), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + callers(graph, "leveldb::Slice::clear"), + Vec::::new() + ); + } + + /// Upstream #2413: a local, a parameter, a raw pointer, nested template + /// arguments and an abseil container; `value` is a `Slice`, the others + /// are not. The receivers are `buffer`, `proto_buffer`, `arena` and + /// `slice_files` (upstream's `dst`, `proto`, `seen`, `files`), sharing a + /// word with a decoy Rust's lone-candidate guess (#226, KEEP-RUST) would + /// take without the stop. + #[test] + fn pr2413_locals_parameters_and_raw_pointers_of_library_types() { + let project = library_type_project(&[( + "db/encode.cc", + lines(&[ + "#include ", + "#include ", + "#include \"absl/container/flat_hash_set.h\"", + "#include \"include/leveldb/slice.h\"", + "#include \"util/buffers.h\"", + "namespace leveldb {", + "void Encode(std::string* buffer, const Slice& value) {", + " buffer->clear();", + " buffer->append(value.data(), value.size());", + "}", + "std::string Render(const std::vector>& slice_files) {", + " std::string proto_buffer;", + " proto_buffer.append(\"x\", 1);", + " absl::flat_hash_set arena;", + " arena.insert(1);", + " const char* first = proto_buffer.data();", + " return slice_files.begin() == slice_files.end() ? proto_buffer : std::string(first);", + "}", + "} // namespace leveldb", + ]), + )]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::Encode"), + ["leveldb::Slice::data", "leveldb::Slice::size"], + "{}", + graph.report() + ); + assert_eq!(callee_names(graph, "leveldb::Render"), Vec::::new()); + } + + /// Upstream #2413: a `std::` name beside a project type of that name in + /// another namespace (rocksdb vendors googletest, whose `typedef + /// ::std::string string;` in `testing::internal` is a project `string`). + /// The receiver is `slice` (upstream's `text`), which Rust's lone-candidate + /// guess (#226, KEEP-RUST) would take to `Slice::clear` without the stop. + #[test] + fn pr2413_a_std_name_beside_a_project_namesake_elsewhere() { + let project = library_type_project(&[ + ( + "third_party/gtest.h", + lines(&[ + "#include ", + "namespace testing {", + "namespace internal {", + "typedef ::std::string string;", + "} // namespace internal", + "} // namespace testing", + ]), + ), + ( + "tools/trim.cc", + lines(&[ + "#include ", + "#include \"include/leveldb/slice.h\"", + "void Trim(std::string& slice) {", + " slice.clear();", + "}", + ]), + ), + ]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "Trim"), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2413: on a smart pointer, `.` is its own member and `->` + /// reaches the element type: `reset()` is `unique_ptr`'s, not + /// `Cache::reset`. The receiver is `cache_iterator` (upstream's `iter`), + /// which shares a word with both `Iterator` and the decoy `Cache` for + /// Rust's lone-candidate guess (#226, KEEP-RUST). + #[test] + fn pr2413_a_smart_pointer_dot_call_is_its_own_member() { + let project = library_type_project(&[( + "db/scan.cc", + lines(&[ + "#include ", + "#include \"util/buffers.h\"", + "namespace leveldb {", + "void Scan(Iterator* raw) {", + " std::unique_ptr cache_iterator(raw);", + " cache_iterator->SeekToFirst();", + " cache_iterator.reset();", + "}", + "} // namespace leveldb", + ]), + )]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::Scan"), + ["leveldb::Iterator::SeekToFirst"], + "{}", + graph.report() + ); + } + + /// Upstream #2413: a std trait or member type names another type, maybe + /// the project's (rocksdb's `std::conditional_t dict` is a `Slice` when the flag is off). The receivers are + /// `dict_slice` and `first_slice` (upstream's `dict`, `first`), which + /// Rust's lone-candidate guess (#226, KEEP-RUST) takes to `Slice`. + #[test] + fn pr2413_a_std_trait_or_member_type_may_name_a_project_type() { + let project = library_type_project(&[( + "util/compression.cc", + lines(&[ + "#include ", + "#include ", + "#include \"include/leveldb/slice.h\"", + "namespace leveldb {", + "template ", + "unsigned DictSize(std::conditional_t dict_slice) {", + " return dict_slice.size();", + "}", + "void Drop(const std::vector& slices) {", + " std::vector::value_type first_slice = slices[0];", + " first_slice.remove_prefix(1);", + "}", + "} // namespace leveldb", + ]), + )]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::DictSize"), + ["leveldb::Slice::size"], + "{}", + graph.report() + ); + assert_eq!( + callee_names(graph, "leveldb::Drop"), + ["leveldb::Slice::remove_prefix"] + ); + } + + /// Upstream #2413: a project type with a lowercase name is still the + /// project's. + #[test] + fn pr2413_a_lowercase_project_type_keeps_its_method() { + let project = library_type_project(&[ + ( + "util/arena.h", + lines(&[ + "namespace util {", + "class arena {", + " public:", + " void* allocate(unsigned n) { return 0; }", + "};", + "} // namespace util", + ]), + ), + ( + "util/use.cc", + lines(&[ + "#include \"util/arena.h\"", + "void Use(util::arena& a) {", + " a.allocate(8);", + "}", + ]), + ), + ]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "Use"), + ["util::arena::allocate"], + "{}", + graph.report() + ); + } + + /// Upstream #2413: a range-for variable, or an `auto` one, hides an + /// earlier library-typed one of the same name, so that declaration says + /// nothing about the call; the block's `clear()` is the string's. The + /// receivers are `slice` and `cache` (upstream's `key`, `buf`), which + /// Rust's lone-candidate guess (#226, KEEP-RUST) takes to `Slice` and + /// `Cache`. + #[test] + fn pr2413_a_range_for_or_auto_variable_hides_a_library_typed_one() { + let project = library_type_project(&[( + "db/shadow.cc", + lines(&[ + "#include ", + "#include ", + "#include \"include/leveldb/slice.h\"", + "#include \"util/buffers.h\"", + "namespace leveldb {", + "void Trim(const std::vector& slices) {", + " std::string slice;", + " for (const Slice& slice : slices) {", + " slice.remove_prefix(1);", + " }", + "}", + "void Flush(Cache* caches) {", + " { std::string cache; cache.clear(); }", + " auto cache = caches[0];", + " cache.reset();", + "}", + "} // namespace leveldb", + ]), + )]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::Trim"), + ["leveldb::Slice::remove_prefix"], + "{}", + graph.report() + ); + assert_eq!( + callee_names(graph, "leveldb::Flush"), + ["leveldb::Cache::reset"] + ); + } + + /// Upstream #2413: a call that contradicts the declaration the scan + /// found (`.` on what reads as a pointer, `absl::kWeightScale * key[0]`) + /// keeps its guess: that declaration is another variable's. The receiver + /// is `slice_key` (upstream's `key`), which Rust's lone-candidate guess + /// (#226, KEEP-RUST) takes to `Slice::size`. + #[test] + fn pr2413_a_call_contradicting_its_declaration_keeps_its_guess() { + let project = library_type_project(&[( + "util/weight.cc", + lines(&[ + "#include \"include/leveldb/slice.h\"", + "namespace leveldb {", + "unsigned Weight(const Slice& slice_key) {", + " unsigned w = absl::kWeightScale * slice_key[0];", + " return w + slice_key.size();", + "}", + "} // namespace leveldb", + ]), + )]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::Weight"), + ["leveldb::Slice::size"], + "{}", + graph.report() + ); + } + + /// Upstream #2413: a constant of a project class read as a type + /// (`Slice::kMask & key[0]`), and a `_t` scalar, are no library types. + /// The receiver is `slice_key` (upstream's `key`), which Rust's + /// lone-candidate guess (#226, KEEP-RUST) takes to `Slice`. + #[test] + fn pr2413_a_project_class_constant_and_a_scalar_are_no_library_types() { + let project = library_type_project(&[( + "util/mask.cc", + lines(&[ + "#include ", + "#include \"include/leveldb/slice.h\"", + "#define SLICE_AT(var, list, i) const Slice& var = list[i]", + "namespace leveldb {", + "unsigned Mask(const Slice& slice_key) {", + " unsigned m = Slice::kMask & slice_key[0];", + " return m + slice_key.size();", + "}", + "void Visit(const Slice* slices) {", + " std::size_t slice_key = 0;", + " {", + " SLICE_AT(slice_key, slices, 0);", + " slice_key.remove_prefix(1);", + " }", + "}", + "} // namespace leveldb", + ]), + )]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::Mask"), + ["leveldb::Slice::size"], + "{}", + graph.report() + ); + assert_eq!( + callee_names(graph, "leveldb::Visit"), + ["leveldb::Slice::remove_prefix"] + ); + } + + /// Upstream #2413: a member of another class in the file (leveldb's + /// `db/log_test.cc`: `StringDest`'s `std::string contents_` sits above + /// `StringSource`, whose own `Slice contents_` follows its methods). The + /// members are `slice_` here (upstream's `contents_`), which Rust's + /// lone-candidate guess (#226, KEEP-RUST) takes to `Slice`. + #[test] + fn pr2413_a_member_of_another_class_in_the_file() { + let project = library_type_project(&[( + "db/log_test.cc", + lines(&[ + "#include ", + "#include \"include/leveldb/slice.h\"", + "namespace leveldb {", + "class StringDest {", + " public:", + " std::string slice_;", + "};", + "class StringSource {", + " public:", + " void Skip(unsigned n) { slice_.remove_prefix(n); }", + " private:", + " Slice slice_;", + "};", + "} // namespace leveldb", + ]), + )]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::StringSource::Skip"), + ["leveldb::Slice::remove_prefix"], + "{}", + graph.report() + ); + } + + /// Upstream #2413: the last line of a declaration that starts on an + /// earlier one (rocksdb's `BlobFileReader`: `autovector>>& blob_reqs` starts the next). + /// The parameter is `autovector_reqs` (upstream's `blob_reqs`), which + /// Rust's lone-candidate guess (#226, KEEP-RUST) takes to `autovector`. + #[test] + fn pr2413_the_last_line_of_a_declaration_begun_above() { + let project = Project::new() + .file( + "util/autovector.h", + &lines(&[ + "namespace rocksdb {", + "template ", + "class autovector {", + " public:", + " unsigned size() const { return 0; }", + "};", + "} // namespace rocksdb", + ]), + ) + .file( + "db/blob/blob_file_reader.cc", + &lines(&[ + "#include ", + "#include \"util/autovector.h\"", + "namespace rocksdb {", + "class BlobFileReader {", + " public:", + " unsigned MultiGetBlob(autovector>>& autovector_reqs);", + "};", + "unsigned BlobFileReader::MultiGetBlob(autovector>>& autovector_reqs) {", + " return autovector_reqs.size();", + "}", + "} // namespace rocksdb", + ]), + ) + .index(); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "rocksdb::BlobFileReader::MultiGetBlob"), + ["rocksdb::autovector::size"], + "{}", + graph.report() + ); + } + + /// Upstream #2413: a member of a class nested in the caller's (rocksdb's + /// `MultiScan::MultiScanIterator` declares a `scan_opts_` of its own, + /// above `MultiScan`'s). + #[test] + fn pr2413_a_member_of_a_class_nested_in_the_callers() { + let project = Project::new() + .file( + "include/rocksdb/multi_scan.h", + &lines(&[ + "#include ", + "namespace rocksdb {", + "struct ScanOptions {};", + "class MultiScanArgs {", + " public:", + " int GetScanRanges() const { return 0; }", + "};", + "class MultiScan {", + " public:", + " class MultiScanIterator {", + " private:", + " const std::vector& scan_opts_;", + " };", + " int begin() { return scan_opts_.GetScanRanges(); }", + " private:", + " const MultiScanArgs scan_opts_;", + "};", + "} // namespace rocksdb", + ]), + ) + .index(); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "rocksdb::MultiScan::begin"), + ["rocksdb::MultiScanArgs::GetScanRanges"], + "{}", + graph.report() + ); + } + + /// Upstream #2413: a comment that mentions the receiver is not its + /// declaration (leveldb's `// … since there is a non-null imm_` above + /// `imm_->…` read as `imm_`'s declaration, with the type `null`). + #[test] + fn pr2413_a_comment_mentioning_the_receiver_is_no_declaration() { + let project = Project::new() + .file( + "db/memtable.h", + &lines(&[ + "namespace leveldb {", + "class MemTable {", + " public:", + " unsigned ApproximateMemoryUsage() { return 0; }", + "};", + "class Arena {", + " public:", + " unsigned ApproximateMemoryUsage() { return 0; }", + "};", + "} // namespace leveldb", + ]), + ) + .file( + "db/db_impl.h", + &lines(&[ + "#include \"db/memtable.h\"", + "namespace leveldb {", + "class DBImpl {", + " public:", + " unsigned MemoryUsage();", + " private:", + " MemTable* imm_;", + "};", + "} // namespace leveldb", + ]), + ) + .file( + "db/db_impl.cc", + &lines(&[ + "#include \"db/db_impl.h\"", + "namespace leveldb {", + "unsigned DBImpl::MemoryUsage() {", + " // Count the memtable being compacted, since there is a non-null imm_", + " return imm_->ApproximateMemoryUsage();", + "}", + "} // namespace leveldb", + ]), + ) + .index(); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::DBImpl::MemoryUsage"), + ["leveldb::MemTable::ApproximateMemoryUsage"], + "{}", + graph.report() + ); + } + + /// Upstream #2413: a parameter after a leading `/* static */` is the + /// caller's own. The parameter is `slice_msg` (upstream's `msg`), which + /// Rust's lone-candidate guess (#226, KEEP-RUST) would take to + /// `Slice::begin` without the stop. + #[test] + fn pr2413_a_parameter_after_a_leading_block_comment_is_the_callers() { + let project = library_type_project(&[( + "util/status.cc", + lines(&[ + "#include ", + "#include \"include/leveldb/slice.h\"", + "namespace leveldb {", + "class Status {", + " public:", + " static bool Check(const std::string& slice_msg);", + "};", + "/* static */ bool Status::Check(const std::string& slice_msg) {", + " return slice_msg.begin() != nullptr;", + "}", + "} // namespace leveldb", + ]), + )]); + let graph = project.graph(); + assert_eq!( + callee_names(graph, "leveldb::Status::Check"), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// The declaration scan reads a receiver's own namespace qualifier as no + /// type (kept from before upstream #2413's port; upstream reads `leveldb` + /// as the type): leveldb's `db_bench.cc` assigns `leveldb::g_env = + /// leveldb::Env::Default();` above `leveldb::g_env->GetTestDirectory(…)`, + /// and the call is `Env`'s, the type `leveldb::Env* g_env` declares, not + /// `InMemoryEnv::GetTestDirectory` guessed by name. + #[test] + fn pr2413_a_qualified_receivers_namespace_is_no_declaration() { + let project = Project::new() + .file( + "include/leveldb/env.h", + &lines(&[ + "namespace leveldb {", + "class Env {", + " public:", + " static Env* Default();", + " virtual int GetTestDirectory(int* path) = 0;", + "};", + "class EnvWrapper : public Env {", + " public:", + " int GetTestDirectory(int* path) override { return 0; }", + "};", + "} // namespace leveldb", + ]), + ) + .file( + "helpers/memenv/memenv.cc", + &lines(&[ + "#include \"include/leveldb/env.h\"", + "namespace leveldb {", + "class InMemoryEnv : public EnvWrapper {", + " public:", + " int GetTestDirectory(int* path) override { return 1; }", + "};", + "} // namespace leveldb", + ]), + ) + .file( + "benchmarks/db_bench.cc", + &lines(&[ + "#include \"include/leveldb/env.h\"", + "static const char* FLAGS_db = nullptr;", + "namespace leveldb {", + "namespace {", + "leveldb::Env* g_env = nullptr;", + "} // namespace", + "} // namespace leveldb", + "int main(int argc, char** argv) {", + " int default_db_path = 0;", + " leveldb::g_env = leveldb::Env::Default();", + " // Choose a location for the test database if none given with --db=", + " if (FLAGS_db == nullptr) {", + " leveldb::g_env->GetTestDirectory(&default_db_path);", + " }", + " return 0;", + "}", + ]), + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "main"), + ["leveldb::Env::GetTestDirectory (include/leveldb/env.h)"], + "{}", + graph.report() + ); + } + + /// facebook/rocksdb's shapes, trimmed (upstream #2443's `ROCKSDB`): the + /// small vector, and the class whose words the receivers share. + fn nested_template_files() -> Vec<(&'static str, String)> { + vec![ + ( + "util/autovector.h", + lines(&[ + "namespace rocksdb {", + "template ", + "class autovector {", + " public:", + " size_t size() const { return 0; }", + " void clear() {}", + " void push_back(const T& item) {}", + "};", + "} // namespace rocksdb", + ]), + ), + ( + "db/compaction/compaction.h", + lines(&[ + "namespace rocksdb {", + "struct FileMetaData {};", + "struct CompactionInputFiles {", + " size_t size() const { return 0; }", + " void clear() {}", + "};", + "} // namespace rocksdb", + ]), + ), + ( + "db/version_set.h", + lines(&[ + "#include \"util/autovector.h\"", + "#include \"db/compaction/compaction.h\"", + "namespace rocksdb {", + "class VersionEdit {};", + "class VersionStorageInfo {", + " public:", + " void ComputeFilesMarkedForCompaction();", + " size_t CountEditLists(VersionEdit* edit);", + " private:", + " autovector> files_marked_for_compaction_;", + "};", + "} // namespace rocksdb", + ]), + ), + ( + "db/version_set.cc", + lines(&[ + "#include \"db/version_set.h\"", + "namespace rocksdb {", + "void VersionStorageInfo::ComputeFilesMarkedForCompaction() {", + " files_marked_for_compaction_.clear();", + "}", + "size_t VersionStorageInfo::CountEditLists(VersionEdit* edit) {", + " autovector> edit_lists;", + " edit_lists.push_back(autovector());", + " return edit_lists.size();", + "}", + "} // namespace rocksdb", + ]), + ), + ] + } + + fn nested_template_project(extra: &[(&'static str, String)]) -> super::support::Indexed { + nested_template_files() + .iter() + .chain(extra) + .fold(Project::new(), |project, (path, text)| { + project.file(path, text) + }) + .index() + } + + /// Upstream #2443 (`cdf06812`): a C++ receiver declared with template + /// arguments nested in its template arguments calls its own class's + /// method. rocksdb's `files_marked_for_compaction_.clear()` on an + /// `autovector>` was guessed by the + /// receiver's words to be `CompactionInputFiles::clear`. + #[test] + fn pr2443_a_member_with_nested_template_arguments_calls_its_class() { + let project = nested_template_project(&[]); + let graph = project.graph(); + assert_eq!( + callees( + graph, + "rocksdb::VersionStorageInfo::ComputeFilesMarkedForCompaction" + ), + ["rocksdb::autovector::clear (util/autovector.h)"], + "{}", + graph.report() + ); + assert_eq!( + callers(graph, "rocksdb::CompactionInputFiles::clear"), + Vec::::new() + ); + } + + /// Upstream #2443: a local of a class template nested in its own + /// template arguments; both calls are the declared type's. + #[test] + fn pr2443_a_local_nested_in_its_own_template_arguments_is_typed() { + let project = nested_template_project(&[]); + let graph = project.graph(); + assert_eq!( + resolved_callees(graph, "rocksdb::VersionStorageInfo::CountEditLists"), + [ + "rocksdb::autovector::push_back instance-method@0.9", + "rocksdb::autovector::size instance-method@0.9", + ], + "{}", + graph.report() + ); + } + + /// Upstream #2443: a capitalized class template is the project's own, + /// not a type from outside it (`Striped> + /// mutex_; mutex_.Get(key)` got no edge). + #[test] + fn pr2443_a_capitalized_class_template_is_the_projects() { + let project = Project::new() + .file( + "util/mutexlock.h", + &lines(&[ + "namespace rocksdb {", + "namespace port {", + "class Mutex {", + " public:", + " void Lock() {}", + "};", + "} // namespace port", + "template ", + "struct CacheAlignedWrapper {", + " T obj_;", + "};", + "template ", + "class Striped {", + " public:", + " T& Get(const Key& key) { return *data_; }", + " private:", + " T* data_;", + "};", + "} // namespace rocksdb", + ]), + ) + .file( + "db/blob/blob_file_cache.h", + &lines(&[ + "#include \"util/mutexlock.h\"", + "namespace rocksdb {", + "class BlobFileCache {", + " public:", + " void Evict(int key);", + " private:", + " Striped> mutex_;", + "};", + "} // namespace rocksdb", + ]), + ) + .file( + "db/blob/blob_file_cache.cc", + &lines(&[ + "#include \"db/blob/blob_file_cache.h\"", + "namespace rocksdb {", + "void BlobFileCache::Evict(int key) {", + " mutex_.Get(key);", + "}", + "} // namespace rocksdb", + ]), + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "rocksdb::BlobFileCache::Evict"), + ["rocksdb::Striped::Get (util/mutexlock.h)"], + "{}", + graph.report() + ); + } + + /// Upstream #2443: the end of a declaration begun on the line above + /// names no type, not even its template argument's + /// (`CachableEntry>>& blob_reqs` under + /// `autovector", + "class CachableEntry {", + " public:", + " size_t size() const { return 0; }", + "};", + "} // namespace rocksdb", + ]), + ), + ( + "db/blob/blob_file_reader.cc", + lines(&[ + "#include \"util/autovector.h\"", + "#include \"table/block_based/cachable_entry.h\"", + "namespace rocksdb {", + "struct BlobContents {};", + "struct BlobReadRequest {};", + "size_t MultiGetBlob(", + " autovector>>& blob_reqs) {", + " return blob_reqs.size();", + "}", + "} // namespace rocksdb", + ]), + ), + ]); + let graph = project.graph(); + assert_eq!( + callees(graph, "rocksdb::MultiGetBlob"), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2443: a declaration begun on the line above still hides a + /// member of the same name: the class's `std::string` member says nothing + /// about the call on the parameter. Both are `autovector_reqs` (upstream's + /// `blob_reqs`), which Rust's lone-candidate guess (#226, KEEP-RUST) + /// takes to `autovector`. + #[test] + fn pr2443_a_declaration_begun_above_still_hides_a_member() { + let project = Project::new() + .file("util/autovector.h", &nested_template_files()[0].1) + .file( + "db/blob/blob_file_reader.cc", + &lines(&[ + "#include ", + "#include \"util/autovector.h\"", + "namespace rocksdb {", + "struct BlobContents {};", + "class BlobFileReader {", + " public:", + " size_t MultiGetBlob(autovector>>& autovector_reqs) const;", + " private:", + " std::string autovector_reqs;", + "};", + "size_t BlobFileReader::MultiGetBlob(autovector>>& autovector_reqs) const {", + " return autovector_reqs.size();", + "}", + "} // namespace rocksdb", + ]), + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "rocksdb::BlobFileReader::MultiGetBlob"), + ["rocksdb::autovector::size (util/autovector.h)"], + "{}", + graph.report() + ); + } + + /// `kind target-qualified (target file)` of every edge leaving a + /// function or method named `name` in `file`, sorted. + fn edges_from_named(graph: &Graph, file: &str, name: &str) -> Vec { + let ids: Vec<&str> = graph + .nodes + .iter() + .filter(|node| { + node.file_path == file + && node.name == name + && (node.kind == "function" || node.kind == "method") + }) + .map(|node| node.id.as_str()) + .collect(); + assert!(!ids.is_empty(), "no function or method {name} in {file}"); + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind != "contains" && ids.contains(&edge.source.as_str())) + .filter_map(|edge| { + graph.node(&edge.target).map(|target| { + format!( + "{} {} ({})", + edge.kind, target.qualified_name, target.file_path + ) + }) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2176 (`5844eb89`): a library that opens its namespace with a + /// macro (fmt's `FMT_BEGIN_NAMESPACE`, pybind11's + /// `PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)`) has its declarations + /// indexed without that namespace; a qualified name now reaches the + /// declaration the project's own namespace macros put there, through a + /// namespace alias (`namespace py = pybind11;`) too. + #[test] + fn pr2176_a_qualified_name_reaches_a_declaration_in_a_macro_opened_namespace() { + let project = Project::new() + .file( + "include/fmt/base.h", + "#pragma once\n#define FMT_BEGIN_NAMESPACE \\\n namespace fmt { \\\n inline namespace v12 {\n#define FMT_END_NAMESPACE \\\n } \\\n }\n\nFMT_BEGIN_NAMESPACE\nnamespace detail {\ninline int to_unsigned(int value) { return value; }\n}\n\ninline int format(const char* spec) { return detail::to_unsigned(0); }\nFMT_END_NAMESPACE\n", + ) + .file( + "include/pybind11/detail/macros.h", + "#pragma once\n#define PYBIND11_NAMESPACE pybind11\n#define PYBIND11_NAMESPACE_BEGIN(name) \\\n namespace name { \\\n PYBIND11_WARNING_PUSH\n#define PYBIND11_NAMESPACE_END(name) \\\n PYBIND11_WARNING_POP \\\n }\n\nPYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)\ninline void print(const char* text) {}\nPYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE)\n", + ) + .file( + "test/format-test.cc", + "#include \"fmt/base.h\"\n#include \"pybind11/detail/macros.h\"\n\nnamespace py = pybind11;\n\nint main() {\n fmt::format(\"{}\");\n fmt::detail::to_unsigned(1);\n py::print(\"hi\");\n return 0;\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + edges_from_named(graph, "test/format-test.cc", "main"), + [ + "calls detail::to_unsigned (include/fmt/base.h)", + "calls format (include/fmt/base.h)", + "calls print (include/pybind11/detail/macros.h)", + ], + "{}", + graph.report() + ); + } + + /// Upstream #2201 (`116feb46`): `lib::format("{}", x)` among overloads a + /// namespace macro opens picks the one the arguments fit: a narrow + /// literal the `format_string` overload, a wide one (`L"{}"`) the + /// `wformat_string` one, a style argument the style one. + #[test] + fn pr2201_a_macro_namespaced_call_picks_the_overload_its_arguments_fit() { + let project = Project::new() + .file( + "include/lib/base.h", + "#pragma once\n#define LIB_BEGIN_NAMESPACE namespace lib {\n#define LIB_END_NAMESPACE }\n\nLIB_BEGIN_NAMESPACE\ntemplate struct format_string {};\ntemplate struct wformat_string {};\nLIB_END_NAMESPACE\n", + ) + .file( + "include/lib/color.h", + "#pragma once\n#include \"base.h\"\n\nLIB_BEGIN_NAMESPACE\nstruct text_style {};\n\ntemplate \ninline auto format(const text_style& ts, format_string fmt, T&&... args) -> int {\n return 0;\n}\nLIB_END_NAMESPACE\n", + ) + .file( + "include/lib/format.h", + "#pragma once\n#include \"base.h\"\n\nLIB_BEGIN_NAMESPACE\ntemplate \ninline auto format(format_string fmt, T&&... args) -> int {\n return 1;\n}\nLIB_END_NAMESPACE\n", + ) + .file( + "include/lib/xchar.h", + "#pragma once\n#include \"base.h\"\n\nLIB_BEGIN_NAMESPACE\ntemplate \ninline auto format(wformat_string fmt, T&&... args) -> int {\n return 2;\n}\nLIB_END_NAMESPACE\n", + ) + .file( + "test/use.cc", + "#include \"lib/color.h\"\n#include \"lib/format.h\"\n#include \"lib/xchar.h\"\n\nint narrow() { return lib::format(\"{}\", 1); }\nint wide() { return lib::format(L\"{}\", 2); }\nint styled() { return lib::format(lib::text_style{}, \"{}\", 3); }\n", + ) + .index(); + let graph = project.graph(); + let format_from = |caller: &str| -> Vec { + edges_from_named(graph, "test/use.cc", caller) + .into_iter() + .filter(|edge| edge.starts_with("calls format ")) + .collect() + }; + assert_eq!( + format_from("narrow"), + ["calls format (include/lib/format.h)"], + "{}", + graph.report() + ); + assert_eq!(format_from("wide"), ["calls format (include/lib/xchar.h)"]); + assert_eq!( + format_from("styled"), + ["calls format (include/lib/color.h)"] + ); + } + + const PB_INTERNAL: &str = "#pragma once\nnamespace pb {\nnamespace internal {\ninline int Prefetch(const void* ptr) { return ptr != nullptr; }\n} // namespace internal\n} // namespace pb\n\nnamespace _pbi = ::pb::internal;\n"; + + /// Upstream #2445 (`7a796541`): a C or C++ name written from the global + /// scope (`::store::Repair(…)`, `::_pbi::Prefetch(…)` through `namespace + /// _pbi = ::pb::internal;`) gets past the name pre-filter and is the + /// declaration of exactly that qualified name: never one nested in + /// another namespace or class (the wrapper `int close(int fd) { return + /// ::close(fd); }`), nor one a namespace macro opens (`struct pipe` in + /// `LIB_BEGIN_NAMESPACE`), nor an outside `::testing::` name. + #[test] + fn pr2445_a_global_scope_name_is_the_declaration_of_exactly_that_name() { + let project = Project::new() + .file( + "src/store/repair.cc", + "namespace store {\nint Repair(const char* name) { return name != nullptr; }\nint move(int value) { return value; }\n\nnamespace testing {\nconst char* TempDir() { return \"/tmp/store\"; }\n} // namespace testing\n} // namespace store\n", + ) + .file("src/pb/internal.h", PB_INTERNAL) + .file( + "src/store/posix.cc", + "#include \n\nnamespace store {\nnamespace posix {\nint close(int fd) { return ::close(fd); }\n} // namespace posix\n} // namespace store\n", + ) + .file( + "tools/repair_tool.cc", + "#include \n#include \"pb/internal.h\"\n\nnamespace tools {\nnamespace store {\nint Repair(const char* name) { return 0; }\n} // namespace store\n\nint Run(const char* name) {\n int fixed = ::store::Repair(name);\n int warmed = ::_pbi::Prefetch(name);\n return ::std::move(fixed) + warmed;\n}\n} // namespace tools\n", + ) + .file( + "include/lib/os.h", + "#pragma once\n#define LIB_BEGIN_NAMESPACE \\\n namespace lib { \\\n inline namespace v1 {\n#define LIB_END_NAMESPACE \\\n } \\\n }\n\nLIB_BEGIN_NAMESPACE\nstruct pipe {\n int read_end;\n int write_end;\n};\nLIB_END_NAMESPACE\n", + ) + .file( + "tests/posix-mock.cc", + "#include \n#include \"lib/os.h\"\n\nnamespace test {\nint pipe(int fds[2]) { return ::pipe(fds); }\n} // namespace test\n", + ) + .file( + "tests/repair_test.cc", + "#include \n\nclass RepairTest : public ::testing::Test {};\n\nint RunAll(int argc, char** argv) {\n ::testing::InitGoogleTest(&argc, argv);\n return ::testing::TempDir() != nullptr;\n}\n", + ) + .index(); + let graph = project.graph(); + // Inside `namespace tools`, `store::Repair` would be tools' own; + // `::store::Repair` is the global one. + assert_eq!( + edges_from_named(graph, "tools/repair_tool.cc", "Run"), + [ + "calls pb::internal::Prefetch (src/pb/internal.h)", + "calls store::Repair (src/store/repair.cc)", + ], + "{}", + graph.report() + ); + assert_eq!( + edges_from_named(graph, "src/store/posix.cc", "close"), + Vec::::new() + ); + assert_eq!( + edges_from_named(graph, "tests/posix-mock.cc", "pipe"), + Vec::::new() + ); + assert_eq!( + edges_from_named(graph, "tests/repair_test.cc", "RunAll"), + Vec::::new() + ); + assert_eq!( + supertypes(graph, "RepairTest", "tests/repair_test.cc"), + Vec::::new() + ); + } + + /// Upstream #2445: protobuf's C++ `::_pbi::` call beside Swift and + /// Objective-C files still links (upstream's Swift ↔ Objective-C bridge + /// no longer claims it; no framework claims C++ names here). + #[test] + fn pr2445_a_global_scope_call_beside_swift_and_objective_c() { + let project = Project::new() + .file( + "objectivec/GPBLegacy.m", + "#import \n\n@interface GPBLegacy : NSObject\n- (void)start;\n@end\n\n@implementation GPBLegacy\n- (void)start {}\n@end\n", + ) + .file( + "swift/Bridge.swift", + "import Foundation\n\nfinal class Bridge: NSObject {\n func run() {}\n}\n", + ) + .file("src/pb/internal.h", PB_INTERNAL) + .file( + "src/message.cc", + "#include \"pb/internal.h\"\n\nint Parse(const void* data) { return ::_pbi::Prefetch(data); }\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + edges_from_named(graph, "src/message.cc", "Parse"), + ["calls pb::internal::Prefetch (src/pb/internal.h)"], + "{}", + graph.report() + ); + } + + /// Upstream #2407's final shape with #2176's tables (W4-CPP-07): a base + /// named through a namespace alias another header writes, and a base a + /// namespace macro puts in its namespace (`fmt::detail::buffer`, indexed + /// as `detail::buffer` inside `FMT_BEGIN_NAMESPACE`). + #[test] + fn pr2407_a_base_through_a_project_alias_or_a_macro_opened_namespace() { + let project = Project::new() + .file( + "include/fmt/base.h", + "#pragma once\n#define FMT_BEGIN_NAMESPACE namespace fmt {\n#define FMT_END_NAMESPACE }\nFMT_BEGIN_NAMESPACE\nnamespace detail {\ntemplate class buffer {\n public:\n void push_back(const T& value) {}\n};\n} // namespace detail\nFMT_END_NAMESPACE\n", + ) + .file( + "include/other/buffer.h", + "namespace other {\nnamespace detail {\nclass buffer {};\n} // namespace detail\n} // namespace other\n", + ) + .file( + "src/memory.cc", + "#include \"fmt/base.h\"\nclass memory_buffer : public fmt::detail::buffer {};\n", + ) + .file( + "src/google/protobuf/message.h", + "namespace google {\nnamespace protobuf {\nclass Message {};\n} // namespace protobuf\n} // namespace google\nnamespace pb = ::google::protobuf;\n", + ) + .file( + "src/generated.cc", + "#include \"google/protobuf/message.h\"\nclass Generated : public pb::Message {};\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + supertypes(graph, "memory_buffer", "src/memory.cc"), + ["extends detail::buffer (include/fmt/base.h) qualified-name"], + "{}", + graph.report() + ); + assert_eq!( + supertypes(graph, "Generated", "src/generated.cc"), + ["extends google::protobuf::Message (src/google/protobuf/message.h) qualified-name"] + ); + } + + /// Upstream #2234 (`d9d457d5`): C has no methods and cannot call a C++ + /// one: redis' POSIX `read(fd, buf, n)` calls and hiredis' function + /// pointer `c->funcs->read(c, buf, …)` all went to the Qt adapter's + /// `RedisQtAdapter::read` in a C++ header. + #[test] + fn pr2234_a_c_call_never_reaches_a_cpp_method() { + let project = Project::new() + .file( + "deps/hiredis/adapters/qt.hpp", + "class RedisQtAdapter {\npublic:\n void read() { }\n};\n", + ) + .file( + "src/util.c", + "#include \n\nlong slurp(int fd, char *buf, long n) {\n return read(fd, buf, n);\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + callers(graph, "RedisQtAdapter::read"), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2150 (`b238e240`), its C-call half: Objective-C C call + /// syntax (`completionBlock()`) calls a block, a function or a function + /// pointer, never a method or property (SDWebImage's block parameter + /// reached `SDPrefetchToken`'s `completionBlock` property). + #[test] + fn pr2150_objective_c_c_call_syntax_never_reaches_a_method_or_property() { + let project = Project::new() + .file( + "Core/SDPrefetchToken.h", + "#import \n@interface SDPrefetchToken : NSObject\n@property (nonatomic, copy) void (^completionBlock)(void);\n@end\n", + ) + .file( + "Core/SDOperation.h", + "#import \n@interface SDOperation : NSObject\n- (void)completionBlock;\n@end\n", + ) + .file( + "Core/SDOperation.m", + "#import \"SDOperation.h\"\n@implementation SDOperation\n- (void)completionBlock {\n}\n@end\n", + ) + .file( + "Core/SDCache.h", + "#import \n@interface SDCache : NSObject\n- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image;\n@end\n", + ) + .file( + "Core/SDCache.m", + "#import \"SDCache.h\"\n@implementation SDCache\n- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image {\n completionBlock();\n}\n@end\n", + ) + .index(); + let graph = project.graph(); + let caller: Vec<&str> = graph + .nodes + .iter() + .filter(|node| { + node.file_path == "Core/SDCache.m" && node.name.starts_with("storeWithCompletion") + }) + .map(|node| node.id.as_str()) + .collect(); + assert!(!caller.is_empty(), "{}", graph.report()); + let targets: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls" && caller.contains(&edge.source.as_str())) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{} {}", node.kind, node.qualified_name)) + .collect(); + assert_eq!(targets, Vec::::new(), "{}", graph.report()); + } +} + +mod java { + use super::support::{Graph, Project}; + + /// `qualified` of every method of `file`, sorted. + fn methods_in(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .nodes + .iter() + .filter(|node| node.kind == "method" && node.file_path == file) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// `source -> target` of every `calls` edge out of `file`, by qualified + /// name, sorted. + fn calls_in(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter_map(|edge| { + let source = graph.node(&edge.source)?; + let target = graph.node(&edge.target)?; + (source.file_path == file) + .then(|| format!("{} -> {}", source.qualified_name, target.qualified_name)) + }) + .collect(); + out.sort(); + out + } + + /// The qualified name of what every `calls` edge out of `file` reaches, + /// sorted (upstream's `callsFrom`). + fn call_targets(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + /// Upstream #2171 (`f9530d7d`), its Java half: a receiver's declared + /// type is read through generics and for-each loops (`final Pair + /// pair`, `for (Element el : this)`), from a field of the class (`private + /// DateFormat dateTimeParser;`, outside the project, so no edge), or as + /// the bound of a type parameter (`T extends ExceptionContext`); a + /// constant's receiver is its type (`Phase.CONTROLLERS.getPhase()`). + /// Upstream's fixture. + #[test] + fn pr2171_java_calls_through_declared_receivers_use_their_type() { + let project = Project::new() + .file( + "src/main/java/app/Pair.java", + "package app;\n\npublic abstract class Pair {\n public abstract L getKey();\n}\n", + ) + .file( + "src/main/java/app/MutablePair.java", + "package app;\n\npublic class MutablePair extends Pair {\n private L left;\n\n public L getKey() { return left; }\n\n /**\n * Copies the key of each element into this pair.\n */\n public void setValue(final Pair pair) {\n left = pair.getKey();\n }\n}\n", + ) + .file( + "src/main/java/app/DateParser.java", + "package app;\n\npublic interface DateParser {\n Object parse(String source);\n}\n", + ) + .file( + "src/main/java/app/Element.java", + "package app;\n\npublic class Element {\n public void val(String value) { }\n}\n", + ) + .file( + "src/main/java/app/Elements.java", + "package app;\n\nimport java.util.ArrayList;\n\npublic class Elements extends ArrayList {\n public Elements val(String value) {\n for (Element element : this)\n element.val(value);\n return this;\n }\n}\n", + ) + .file( + "src/main/java/app/Phase.java", + "package app;\n\npublic enum Phase {\n CONTROLLERS;\n\n public int getPhase() { return 0; }\n}\n", + ) + .file( + "src/main/java/app/ExceptionContext.java", + "package app;\n\npublic interface ExceptionContext {\n int getContextEntries();\n}\n", + ) + .file( + "src/test/java/app/DateUtilsTest.java", + "package app;\n\nimport java.text.DateFormat;\n\npublic abstract class DateUtilsTest {\n private DateFormat dateTimeParser;\n protected T exceptionContext;\n\n void rounds() throws Exception {\n dateTimeParser.parse(\"January 1, 2008\");\n exceptionContext.getContextEntries();\n Phase.CONTROLLERS.getPhase();\n }\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + call_targets(graph, "src/main/java/app/MutablePair.java"), + ["app::Pair::getKey"], + "{}", + graph.report() + ); + assert_eq!( + call_targets(graph, "src/main/java/app/Elements.java"), + ["app::Element::val"], + "{}", + graph.report() + ); + assert_eq!( + call_targets(graph, "src/test/java/app/DateUtilsTest.java"), + [ + "app::ExceptionContext::getContextEntries", + "app::Phase::getPhase" + ], + "{}", + graph.report() + ); + } + + /// Upstream #2194 (`ad77e5fc`): a Java enum constant or Kotlin enum entry + /// with a body of its own declares members, and the calls in them are + /// calls; jsoup's tokenizer (an enum whose every state implements `read`) + /// was absent from the graph. + #[test] + fn pr2194_enum_constant_bodies_declare_methods_and_calls() { + let project = Project::new() + .file( + "src/main/java/app/Op.java", + "package app;\n\npublic enum Op {\n PLUS {\n @Override\n int apply(int a, int b) {\n return helper(a) + b;\n }\n },\n MINUS {\n @Override\n int apply(int a, int b) { return a - b; }\n };\n\n abstract int apply(int a, int b);\n\n static int helper(int x) { return x; }\n}\n", + ) + .file( + "src/main/kotlin/app/Factory.kt", + "package app\n\nenum class Factory {\n NewBuffer {\n override val isOneByteAtATime: Boolean\n get() = false\n\n override fun pipe(): Int {\n val buffer = 1\n return helper(buffer)\n }\n },\n Other {\n override val isOneByteAtATime: Boolean = true\n override fun pipe(): Int = 2\n };\n\n abstract val isOneByteAtATime: Boolean\n abstract fun pipe(): Int\n\n fun helper(x: Int): Int = x\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + methods_in(graph, "src/main/java/app/Op.java"), + [ + "app::Op::MINUS::apply", + "app::Op::PLUS::apply", + "app::Op::apply", + "app::Op::helper", + ], + "{}", + graph.report() + ); + assert_eq!( + calls_in(graph, "src/main/java/app/Op.java"), + ["app::Op::PLUS::apply -> app::Op::helper"], + "{}", + graph.report() + ); + assert_eq!( + methods_in(graph, "src/main/kotlin/app/Factory.kt"), + [ + "app::Factory::NewBuffer::pipe", + "app::Factory::Other::pipe", + "app::Factory::helper", + "app::Factory::pipe", + ], + "{}", + graph.report() + ); + assert_eq!( + calls_in(graph, "src/main/kotlin/app/Factory.kt"), + ["app::Factory::NewBuffer::pipe -> app::Factory::helper"], + "{}", + graph.report() + ); + } + + /// `kind qualified-name` of every edge but `contains` and `imports` out + /// of `file`'s nodes, sorted. + fn edges_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind != "contains" && edge.kind != "imports") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| { + graph + .node(&edge.target) + .map(|target| format!("{} {}", edge.kind, target.qualified_name)) + }) + .collect(); + out.sort(); + out + } + + /// The names of what the method `qualified` calls, sorted. + fn calls_of(graph: &Graph, qualified: &str) -> Vec { + let ids: Vec<&str> = graph + .nodes + .iter() + .filter(|node| node.kind == "method" && node.qualified_name == qualified) + .map(|node| node.id.as_str()) + .collect(); + assert!(!ids.is_empty(), "no method {qualified}"); + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls" && ids.contains(&edge.source.as_str())) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2126 (`8987be42`): a bare Java call reaches a method of a + /// class around it, of one of that class's supertypes (read from the + /// declarations' heads), or one the file imports statically; not another + /// class's method of that name (halo's Mockito `verify(…)` bound 1,038 + /// calls to an `EmailVerificationService.verify`). + #[test] + fn pr2126_a_bare_java_call_reaches_its_class_supertypes_or_static_imports() { + let project = Project::new() + .file( + "src/main/java/app/EmailVerificationService.java", + "package app;\npublic class EmailVerificationService {\n public boolean verify(String token) { return true; }\n}\n", + ) + .file( + "src/main/java/app/Util.java", + "package app;\npublic class Util {\n public static String format(String s) { return s; }\n}\n", + ) + .file( + "src/main/java/app/Base.java", + "package app;\npublic class Base {\n protected void helper() {}\n}\n", + ) + .file( + "src/main/java/app/Child.java", + "package app;\nimport static app.Util.format;\npublic class Child extends Base {\n public void run() {\n helper();\n format(\"x\");\n own();\n }\n private void own() {}\n}\n", + ) + .file( + "src/test/java/app/ChildTest.java", + "package app;\nimport static org.mockito.Mockito.verify;\npublic class ChildTest {\n public void sends(Object mock) {\n verify(mock);\n }\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_of(graph, "app::ChildTest::sends"), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + calls_of(graph, "app::Child::run"), + ["app::Base::helper", "app::Child::own", "app::Util::format"], + "{}", + graph.report() + ); + } + + /// #2126 beyond upstream's fixture: the walk follows supertypes through + /// a type that only names another (two levels up, an interface in an + /// `implements` clause, a qualified and generic base), and an on-demand + /// static import opens its owner's methods; a same-named method of a + /// class nothing reaches stays out, and so does a lone one (the edge + /// would be the only candidate's). + #[test] + fn pr2126_the_scope_walk_follows_heads_and_on_demand_static_imports() { + let project = Project::new() + .file( + "src/app/Root.java", + "package app;\npublic class Root {\n protected void rootHelper() {}\n}\n", + ) + .file( + "src/app/Mid.java", + "package app;\npublic class Mid extends app.Root {\n}\n", + ) + .file( + "src/app/Greeter.java", + "package app;\npublic interface Greeter {\n default void greet() {}\n}\n", + ) + .file( + "src/app/Asserts.java", + "package app;\npublic class Asserts {\n public static void check(boolean b) {}\n}\n", + ) + .file( + "src/app/Other.java", + "package app;\npublic class Other {\n public void greet() {}\n public void rootHelper() {}\n public void lonely() {}\n}\n", + ) + .file( + "src/app/Leaf.java", + "package app;\nimport static app.Asserts.*;\npublic class Leaf extends Mid\n implements Greeter {\n void go() {\n rootHelper();\n greet();\n check(true);\n lonely();\n }\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_of(graph, "app::Leaf::go"), + [ + "app::Asserts::check", + "app::Greeter::greet", + "app::Root::rootHelper" + ], + "{}", + graph.report() + ); + } + + /// #2126 with an anonymous class (KEEP-RUST): its bare calls reach the + /// methods of what it instantiates and of that type's supertypes, as in + /// gson's `new LinkedTreeMapIterator<…>() { … nextNode() … }`; upstream + /// reads only the anonymous class's own head, which names nothing. + #[test] + fn pr2126_an_anonymous_class_reaches_what_it_instantiates() { + let project = Project::new() + .file( + "src/app/Root.java", + "package app;\npublic class Root {\n protected Object nextNode() { return null; }\n}\n", + ) + .file( + "src/app/Walker.java", + "package app;\npublic abstract class Walker extends Root {\n public abstract T next();\n}\n", + ) + .file( + "src/app/Other.java", + "package app;\npublic class Other {\n public Object nextNode() { return null; }\n}\n", + ) + .file( + "src/app/Tree.java", + "package app;\npublic class Tree {\n Walker walk() {\n return new Walker() {\n public String next() {\n return (String) nextNode();\n }\n };\n }\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_of(graph, "app::Tree::walk::::next"), + ["app::Root::nextNode"], + "{}", + graph.report() + ); + } + + /// Upstream #2195 (`81ce7e56`): a single-type or static import whose + /// package prefix is none of the project's packages makes the name mean + /// the outside class: gson's production code bound + /// `java.lang.reflect.Field` to a test's nested + /// `ParameterizedTypesTest.Field`. The project's own imports, nested + /// classes included, resolve as before. + #[test] + fn pr2195_an_import_from_outside_the_project_owns_its_name() { + let project = Project::new() + .file( + "src/test/java/app/functional/TypesTest.java", + "package app.functional;\n\npublic class TypesTest {\n static class Field {\n String name;\n }\n\n static void assertEquals(Object a, Object b) { }\n}\n", + ) + .file( + "src/main/java/app/model/Outer.java", + "package app.model;\n\npublic class Outer {\n public static class Inner {\n public static Inner create() { return new Inner(); }\n }\n}\n", + ) + .file( + "src/main/java/app/Reflect.java", + "package app;\n\nimport java.lang.reflect.Field;\nimport app.model.Outer;\nimport app.model.Outer.Inner;\n\npublic class Reflect {\n String nameOf(Field field) {\n return field.getName();\n }\n\n Outer.Inner make() {\n return Inner.create();\n }\n}\n", + ) + .file( + "src/test/java/app/ReflectTest.java", + "package app;\n\nimport static org.junit.Assert.assertEquals;\n\npublic class ReflectTest {\n void names() {\n assertEquals(\"a\", \"a\");\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let reflect = edges_from(graph, "src/main/java/app/Reflect.java"); + assert!( + !reflect.contains(&"references app.functional::TypesTest::Field".to_string()), + "{}", + graph.report() + ); + assert!( + reflect.contains(&"calls app.model::Outer::Inner::create".to_string()), + "{}", + graph.report() + ); + assert!( + !edges_from(graph, "src/test/java/app/ReflectTest.java") + .contains(&"calls app.functional::TypesTest::assertEquals".to_string()), + "{}", + graph.report() + ); + } + + /// #2195 beyond upstream's fixture: a package a Kotlin file declares is + /// the project's, so a Java import from it still resolves; a class + /// imported from outside owns its name through a qualified call too + /// (`Mockito.verify(…)`). + #[test] + fn pr2195_a_kotlin_package_is_the_projects_and_a_qualified_use_is_owned() { + let project = Project::new() + .file( + "src/kt/Shapes.kt", + "package app.shapes\n\nclass Circle {\n fun area(): Double = 1.0\n}\n", + ) + .file( + "src/app/Mockito.java", + "package app;\n\npublic class Mockito {\n public static void verify(Object o) {}\n}\n", + ) + .file( + "src/app/Area.java", + "package app;\n\nimport app.shapes.Circle;\nimport org.mockito.Mockito;\n\npublic class Area {\n double of(Circle c) {\n Mockito.verify(c);\n return c.area();\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let area = edges_from(graph, "src/app/Area.java"); + assert!( + area.contains(&"references app.shapes::Circle".to_string()), + "{}", + graph.report() + ); + assert!( + !area.contains(&"calls app::Mockito::verify".to_string()), + "{}", + graph.report() + ); + } + + /// #2195 with a package only Scala files declare: Scala files index no + /// namespace node (neither upstream nor here), so the package is no + /// project package and the import owns its name, as upstream decides it. + #[test] + fn pr2195_a_package_only_scala_declares_is_outside() { + let project = Project::new() + .file( + "src/scala/Geometry.scala", + "package app.geometry\n\nclass Circle {\n def area(): Double = 1.0\n}\n", + ) + .file( + "src/app/shapes/Circle.java", + "package app.shapes;\n\npublic class Circle { }\n", + ) + .file( + "src/app/Area.java", + "package app;\n\nimport app.geometry.Circle;\n\npublic class Area {\n double of(Circle c) {\n return 0;\n }\n}\n", + ) + .index(); + let graph = project.graph(); + assert!( + !graph.nodes.iter().any( + |node| node.kind == "namespace" && node.file_path == "src/scala/Geometry.scala" + ), + "{}", + graph.report() + ); + assert_eq!( + edges_from(graph, "src/app/Area.java"), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2236 (`ef62f023`): a bare Java type name is a type in reach + /// by package, import or nesting. retrofit's `new Retrofit.Builder()` + /// went to `RequestFactory.Builder`, halo's micrometer `Counter` to its + /// own `core.extension.Counter`; a constructor never stands in for its + /// type, and a nested type of what an anonymous class implements is in + /// reach inside it. + #[test] + fn pr2236_a_bare_java_type_name_is_a_type_in_reach() { + let project = Project::new() + .file( + "src/main/java/retrofit2/RequestFactory.java", + "package retrofit2;\n\nfinal class RequestFactory {\n static final class Builder {\n Builder() { }\n }\n}\n", + ) + .file( + "src/main/java/retrofit2/Retrofit.java", + "package retrofit2;\n\npublic final class Retrofit {\n public static final class Builder {\n public Builder() { }\n }\n}\n", + ) + .file( + "src/test/java/retrofit2/CallTest.java", + "package retrofit2;\n\nclass CallTest {\n Object make() {\n return new Retrofit.Builder();\n }\n}\n", + ) + .file( + "src/main/java/run/halo/core/extension/Counter.java", + "package run.halo.core.extension;\n\npublic class Counter { }\n", + ) + .file( + "src/main/java/run/halo/core/counter/MeterUtils.java", + "package run.halo.core.counter;\n\nimport io.micrometer.core.instrument.Counter;\n\nclass MeterUtils {\n static Counter counter() { return null; }\n}\n", + ) + .file( + "src/main/java/org/jsoup/select/NodeFilter.java", + "package org.jsoup.select;\n\npublic interface NodeFilter {\n enum FilterResult { CONTINUE }\n FilterResult head(Object node);\n}\n", + ) + .file( + "src/test/java/org/jsoup/select/TraversorTest.java", + "package org.jsoup.select;\n\nclass TraversorTest {\n void filter() {\n run(new NodeFilter() {\n public FilterResult head(Object node) { return null; }\n });\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let call_test = edges_from(graph, "src/test/java/retrofit2/CallTest.java"); + assert!( + call_test.contains(&"instantiates retrofit2::Retrofit::Builder".to_string()), + "{}", + graph.report() + ); + assert!( + !call_test + .iter() + .any(|edge| edge.contains("retrofit2::RequestFactory") + || edge.ends_with("::Builder::Builder")), + "{}", + graph.report() + ); + assert!( + !edges_from(graph, "src/main/java/run/halo/core/counter/MeterUtils.java") + .iter() + .any(|edge| edge.ends_with(" run.halo.core.extension::Counter")), + "{}", + graph.report() + ); + assert!( + edges_from(graph, "src/test/java/org/jsoup/select/TraversorTest.java") + .contains(&"references org.jsoup.select::NodeFilter::FilterResult".to_string()), + "{}", + graph.report() + ); + } + + /// #2236 beyond upstream's fixture: a top-level type of another package + /// is in reach through a single-type or on-demand import and not + /// without one; a nested type from a subclass of its owner (two levels + /// down, the heads read from source) and through its owner's import; a + /// nested type of an unrelated class is not, whichever ranks first. + #[test] + fn pr2236_packages_imports_and_subtypes_put_a_type_in_reach() { + let project = Project::new() + .file( + "src/lib/Widget.java", + "package lib;\n\npublic class Widget {\n public static class Part { }\n}\n", + ) + .file( + "src/lib/Gadget.java", + "package lib;\n\npublic class Gadget { }\n", + ) + .file( + "src/other/Holder.java", + "package other;\n\npublic class Holder {\n public static class Part { }\n}\n", + ) + .file( + "src/app/Base.java", + "package app;\n\npublic class Base extends lib.Widget { }\n", + ) + .file( + "src/app/Sub.java", + "package app;\n\npublic class Sub extends Base {\n Part part;\n}\n", + ) + .file( + "src/app/Single.java", + "package app;\n\nimport lib.Gadget;\n\npublic class Single {\n Gadget gadget;\n}\n", + ) + .file( + "src/app/Demand.java", + "package app;\n\nimport lib.*;\n\npublic class Demand {\n Gadget gadget;\n}\n", + ) + .file( + "src/app/Nothing.java", + "package app;\n\npublic class Nothing {\n Gadget gadget;\n Part part;\n}\n", + ) + .file( + "src/app/Members.java", + "package app;\n\nimport lib.Widget.*;\n\npublic class Members {\n Part part;\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + edges_from(graph, "src/app/Sub.java"), + ["extends app::Base", "references lib::Widget::Part"], + "{}", + graph.report() + ); + assert_eq!( + edges_from(graph, "src/app/Single.java"), + ["references lib::Gadget"], + "{}", + graph.report() + ); + assert_eq!( + edges_from(graph, "src/app/Demand.java"), + ["references lib::Gadget"], + "{}", + graph.report() + ); + assert_eq!( + edges_from(graph, "src/app/Nothing.java"), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + edges_from(graph, "src/app/Members.java"), + ["references lib::Widget::Part"], + "{}", + graph.report() + ); + } + + /// Whether `node` is one Lombok generates here: it carries the `lombok` + /// decorator. + fn is_lombok(node: &super::support::NodeRow) -> bool { + node.decorators + .as_array() + .is_some_and(|decorators| decorators.iter().any(|d| d == "lombok")) + } + + /// Upstream #912 (`826810f1`): the members Lombok generates are nodes, + /// marked with a `lombok` decorator and a docstring naming the + /// annotation, and calls to them resolve; a static field gets no + /// accessor. Upstream's fixture, verbatim. + #[test] + fn pr912_lombok_members_are_synthesized_and_resolve() { + let project = Project::new() + .file( + "model/User.java", + "package model;\nimport lombok.Data;\nimport lombok.Builder;\nimport lombok.extern.slf4j.Slf4j;\n\n@Data\n@Builder\n@Slf4j\npublic class User {\n private String name;\n private boolean active;\n private static final int MAX = 10;\n}\n", + ) + .file( + "svc/UserService.java", + "package svc;\nimport model.User;\n\nclass UserService {\n String describe(User user) {\n user.setActive(true);\n return user.getName();\n }\n User make() {\n return User.builder();\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let synthesized = |name: &str| { + graph + .nodes + .iter() + .find(|node| node.name == name && is_lombok(node)) + .unwrap_or_else(|| panic!("no synthesized {name}\n{}", graph.report())) + }; + for name in [ + "getName", + "setName", + "isActive", + "setActive", + "builder", + "equals", + "hashCode", + "toString", + ] { + let node = synthesized(name); + assert_eq!(node.kind, "method", "{name}"); + assert_eq!(node.file_path, "model/User.java", "{name}"); + assert_eq!(node.qualified_name, format!("model::User::{name}")); + } + let get_name = synthesized("getName"); + assert_eq!( + get_name.docstring.as_deref(), + Some("Lombok-generated (@Data)") + ); + assert_eq!(get_name.signature.as_deref(), Some("String getName()")); + assert_eq!(get_name.return_type.as_deref(), Some("String")); + assert_eq!(get_name.visibility.as_deref(), Some("public")); + assert_eq!(get_name.start_line, 10); + assert_eq!( + synthesized("isActive").signature.as_deref(), + Some("boolean isActive()") + ); + assert_eq!( + synthesized("setName").signature.as_deref(), + Some("void setName(String name)") + ); + let builder = synthesized("builder"); + assert_eq!( + builder.signature.as_deref(), + Some("static User.UserBuilder builder()") + ); + assert!(builder.is_static); + assert_eq!(builder.return_type.as_deref(), Some("UserBuilder")); + assert_eq!( + builder.docstring.as_deref(), + Some("Lombok-generated (@Builder)") + ); + let log = synthesized("log"); + assert_eq!(log.kind, "field"); + assert!(log.is_static); + assert_eq!(log.visibility.as_deref(), Some("private")); + assert_eq!(log.signature.as_deref(), Some("Logger log")); + assert_eq!(log.docstring.as_deref(), Some("Lombok-generated (@Slf4j)")); + assert!( + !graph + .nodes + .iter() + .any(|node| node.name == "getMAX" || node.name == "getMax"), + "{}", + graph.report() + ); + let calls = calls_in(graph, "svc/UserService.java"); + for call in [ + "svc::UserService::describe -> model::User::getName", + "svc::UserService::describe -> model::User::setActive", + "svc::UserService::make -> model::User::builder", + ] { + assert!( + calls.contains(&call.to_string()), + "{call}\n{}", + graph.report() + ); + } + } + + /// Upstream #912: a member the source declares is never generated + /// again; the other fields still get theirs. + #[test] + fn pr912_lombok_never_overrides_a_hand_written_accessor() { + let project = Project::new() + .file( + "model/Account.java", + "package model;\nimport lombok.Getter;\n\n@Getter\npublic class Account {\n private int balance;\n private String owner;\n\n // explicit getter — Lombok skips it, so must we\n public int getBalance() { return balance < 0 ? 0 : balance; }\n}\n", + ) + .index(); + let graph = project.graph(); + let balance: Vec<_> = graph + .nodes + .iter() + .filter(|node| node.name == "getBalance") + .collect(); + assert_eq!(balance.len(), 1, "{}", graph.report()); + assert!(!is_lombok(balance[0]), "{}", graph.report()); + assert!( + graph + .nodes + .iter() + .any(|node| node.name == "getOwner" && is_lombok(node)), + "{}", + graph.report() + ); + } + + /// Upstream #912: field-level `@Getter` / `@Setter`, a `final` field + /// gets no setter, and a field without either gets nothing. + #[test] + fn pr912_lombok_field_annotations_and_final_fields() { + let project = Project::new() + .file( + "model/Box.java", + "package model;\nimport lombok.Getter;\nimport lombok.Setter;\n\npublic class Box {\n @Getter @Setter private String label;\n @Getter private final long id; // final → getter only, no setter\n private int hidden; // no annotation → nothing\n}\n", + ) + .index(); + let graph = project.graph(); + let lombok = |name: &str| { + graph + .nodes + .iter() + .any(|node| node.name == name && is_lombok(node)) + }; + assert!(lombok("getLabel"), "{}", graph.report()); + assert!(lombok("setLabel"), "{}", graph.report()); + assert!(lombok("getId"), "{}", graph.report()); + assert!( + !graph.nodes.iter().any(|node| node.name == "setId"), + "{}", + graph.report() + ); + assert!( + !graph.nodes.iter().any(|node| node.name == "getHidden"), + "{}", + graph.report() + ); + } + + /// Upstream #912: a plain Java class gets nothing. + #[test] + fn pr912_a_plain_java_class_gets_no_synthesized_members() { + let project = Project::new() + .file( + "model/Plain.java", + "package model;\n\npublic class Plain {\n private int value;\n public int getValue() { return value; }\n public void setValue(int v) { this.value = v; }\n}\n", + ) + .index(); + let graph = project.graph(); + assert!(!graph.nodes.iter().any(is_lombok), "{}", graph.report()); + } +} + +mod kotlin { + use super::support::{Graph, Project}; + + /// `target qualified-name` of every `calls` edge out of `file`'s nodes, + /// sorted. + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// The files the `calls` edges out of `file` named `name` land in. + fn call_targets_named(graph: &Graph, file: &str, name: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .filter(|node| node.name == name) + .map(|node| node.file_path.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2146 (`75976f99`): a top-level Kotlin function, an extension + /// function included, is visible from its own package and from a file + /// that imports it by name or imports its package with `*`; a file + /// importing kotlin.test's `assertEquals` reaches neither project helper + /// (Exposed's 3,075 calls). A function type with a qualified receiver + /// keeps the class around it: tree-sitter-kotlin-ng parses it, so + /// upstream's pre-parse is not needed (N/A). + #[test] + fn pr2146_kotlin_top_level_functions_are_visible_where_imported() { + let project = Project::new() + .file( + "jdbc/src/main/kotlin/app/jdbc/tests/Assert.kt", + "package app.jdbc.tests\n\nclass JdbcTransaction\n\nfun withTables(statement: JdbcTransaction.() -> Unit) {}\n\nfun JdbcTransaction.assertEquals(exp: T, act: T) {}\n", + ) + .file( + "r2dbc/src/main/kotlin/app/r2dbc/tests/Assert.kt", + "package app.r2dbc.tests\n\nclass R2dbcTransaction\n\nfun withTables(statement: R2dbcTransaction.() -> Unit) {}\n\nfun R2dbcTransaction.assertEquals(exp: T, act: T) {}\n", + ) + .file( + "jdbc/src/test/kotlin/app/jdbc/JdbcTest.kt", + "package app.jdbc\n\nimport app.jdbc.tests.assertEquals\nimport app.jdbc.tests.withTables\n\nclass JdbcTest {\n fun works() {\n withTables { assertEquals(1, 1) }\n }\n}\n", + ) + .file( + "r2dbc/src/test/kotlin/app/r2dbc/R2dbcTest.kt", + "package app.r2dbc\n\nimport app.r2dbc.tests.*\n\nclass R2dbcTest {\n fun works() {\n withTables { assertEquals(1, 1) }\n }\n}\n", + ) + .file( + "core/src/test/kotlin/app/core/PlainTest.kt", + "package app.core\n\nimport kotlin.test.assertEquals\n\nclass PlainTest {\n fun works() {\n assertEquals(1, 1)\n }\n}\n", + ) + .file( + "jdbc/src/main/kotlin/app/jdbc/tests/Local.kt", + "package app.jdbc.tests\n\nfun check() {\n withTables { assertEquals(1, 1) }\n}\n", + ) + .file( + "jdbc/src/main/kotlin/app/jdbc/tests/DatabaseTestsBase.kt", + "package app.jdbc.tests\n\nclass DatabaseConfig {\n class Builder\n}\n\n@MethodSource(\"data\")\nabstract class DatabaseTestsBase {\n fun withTables(configure: (DatabaseConfig.Builder.() -> Unit)? = null, statement: () -> Unit) {\n statement()\n }\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + call_targets_named( + graph, + "jdbc/src/test/kotlin/app/jdbc/JdbcTest.kt", + "assertEquals" + ), + ["jdbc/src/main/kotlin/app/jdbc/tests/Assert.kt"], + "{}", + graph.report() + ); + assert_eq!( + call_targets_named( + graph, + "r2dbc/src/test/kotlin/app/r2dbc/R2dbcTest.kt", + "assertEquals" + ), + ["r2dbc/src/main/kotlin/app/r2dbc/tests/Assert.kt"], + "{}", + graph.report() + ); + assert_eq!( + call_targets_named( + graph, + "core/src/test/kotlin/app/core/PlainTest.kt", + "assertEquals" + ), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + call_targets_named( + graph, + "jdbc/src/main/kotlin/app/jdbc/tests/Local.kt", + "assertEquals" + ), + ["jdbc/src/main/kotlin/app/jdbc/tests/Assert.kt"], + "{}", + graph.report() + ); + let with_tables: Vec<_> = graph + .nodes + .iter() + .filter(|node| { + node.file_path == "jdbc/src/main/kotlin/app/jdbc/tests/DatabaseTestsBase.kt" + && node.name == "withTables" + }) + .map(|node| (node.kind.as_str(), node.qualified_name.as_str())) + .collect(); + assert_eq!( + with_tables, + [("method", "app.jdbc.tests::DatabaseTestsBase::withTables")], + "{}", + graph.report() + ); + } + + /// Upstream #2196 (`da1312e8`): Kotlin imports (no `;`, an optional + /// `as`) are read, an outside import owns its name, a member the file + /// declares itself comes first, and a renaming import binds its alias by + /// the imported FQN. + #[test] + fn pr2196_kotlin_imports_outside_names_and_aliases() { + let project = Project::new() + .file( + "src/test/java/app/functional/TypesTest.java", + "package app.functional;\n\npublic class TypesTest {\n static class Field {\n String name;\n }\n\n static void assertEquals(Object a, Object b) { }\n}\n", + ) + .file( + "src/main/java/app/model/Outer.java", + "package app.model;\n\npublic class Outer {\n public static class Inner {\n public static Inner create() { return new Inner(); }\n }\n}\n", + ) + .file( + "src/main/kotlin/app/Dates.kt", + "package app\n\nimport java.lang.reflect.Field\nimport kotlinx.datetime.toLocalDateTime\nimport app.model.Outer.Inner as Made\n\nclass Dates {\n private fun toLocalDateTime(value: Long): Long = value\n\n fun parse(value: Long, field: Field): Long {\n field.getName()\n Made.create()\n return toLocalDateTime(value)\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind != "contains" && edge.kind != "imports") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == "src/main/kotlin/app/Dates.kt") + }) + .filter_map(|edge| { + graph + .node(&edge.target) + .map(|target| format!("{} {}", edge.kind, target.qualified_name)) + }) + .collect(); + assert!( + !out.contains(&"references app.functional::TypesTest::Field".to_string()), + "{}", + graph.report() + ); + assert!( + out.contains(&"calls app::Dates::toLocalDateTime".to_string()), + "{}", + graph.report() + ); + assert!( + out.contains(&"calls app.model::Outer::Inner::create".to_string()), + "{}", + graph.report() + ); + } + + /// Upstream #2172 (`2e2b98c9`): a bare Kotlin call reaches a member of a + /// type around it (an anonymous `object : Table()` included), of a type + /// the project's function types take as a lambda receiver, or of an + /// object member the file imports; never another class's member by name + /// (koin's `error("…")` went to a Logger's `error`). `require(n >= 0) { + /// … }` is Kotlin's precondition, not a member `require`. + #[test] + fn pr2172_bare_kotlin_calls_reach_only_members_in_reach() { + let project = Project::new() + .file( + "src/main/kotlin/app/dsl/Module.kt", + "package app.dsl\n\nclass Scope {\n fun get(): Any = 1\n}\n\nclass Koin {\n fun get(): Any = 2\n}\n\nclass Module {\n fun single(definition: Scope.() -> Any) { }\n}\n\nfun module(declaration: Module.() -> Unit): Module = Module().apply(declaration)\n", + ) + .file( + "src/main/kotlin/app/log/Logger.kt", + "package app.log\n\nclass Logger {\n fun error(message: String) { }\n}\n", + ) + .file( + "src/main/kotlin/app/db/Table.kt", + "package app.db\n\nabstract class Table {\n fun integer(name: String): Int = 0\n}\n", + ) + .file( + "src/main/kotlin/app/io/Source.kt", + "package app.io\n\ninterface Source {\n fun require(byteCount: Long)\n}\n\nclass RealSource : Source {\n override fun require(byteCount: Long) { }\n\n fun read(byteCount: Long) {\n require(byteCount >= 0L) { \"byteCount < 0: $byteCount\" }\n require(byteCount)\n }\n}\n", + ) + .file( + "src/test/kotlin/app/TestUtil.kt", + "package app\n\nobject TestUtil {\n fun deepCopy(value: Int): Int = value\n}\n", + ) + .file( + "src/test/kotlin/app/AppTest.kt", + "package app\n\nimport app.TestUtil.deepCopy\nimport app.db.Table\nimport app.dsl.module\n\nclass AppTest {\n fun wires() {\n val m = module {\n single { get() }\n }\n val table = object : Table() {\n val id = integer(\"id\")\n }\n val copy = deepCopy(1)\n if (copy < 0) error(\"negative\")\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let calls = calls_from(graph, "src/test/kotlin/app/AppTest.kt"); + for wanted in [ + "app.dsl::Module::single", + "app.dsl::Scope::get", + "app.db::Table::integer", + "app::TestUtil::deepCopy", + ] { + assert!( + calls.contains(&wanted.to_string()), + "missing {wanted}: {calls:?}\n{}", + graph.report() + ); + } + for unwanted in ["app.dsl::Koin::get", "app.log::Logger::error"] { + assert!( + !calls.contains(&unwanted.to_string()), + "unexpected {unwanted}: {calls:?}\n{}", + graph.report() + ); + } + let read: Vec<&str> = graph + .nodes + .iter() + .filter(|node| { + node.file_path == "src/main/kotlin/app/io/Source.kt" && node.name == "read" + }) + .map(|node| node.id.as_str()) + .collect(); + let mut lines: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls" && read.contains(&edge.source.as_str())) + .filter_map(|edge| edge.line) + .collect(); + lines.sort(); + assert_eq!(lines, [12], "{}", graph.report()); + } + + /// Upstream #2168 (`b7b2a44c`): a primary constructor on the line after + /// its class name keeps the class (tree-sitter-kotlin-ng parses it, so + /// the pre-parse is N/A), and a standard-library name on an untyped chain + /// link needs a receiver named after the owner: `.apply { }` never goes to + /// `ConnectionSpec.apply`. + #[test] + fn pr2168_split_constructors_keep_their_class_and_std_chain_names_stay_std() { + let project = Project::new() + .file( + "src/commonMain/kotlin/okio/ByteString.kt", + "package okio\n\nexpect open class ByteString\n// Trusted internal constructor doesn't clone data.\ninternal constructor(data: ByteArray) : Comparable {\n fun hex(): String\n fun toByteArray(): ByteArray\n}\n", + ) + .file( + "src/main/kotlin/app/ConnectionSpec.kt", + "package app\n\nclass ConnectionSpec {\n fun apply(sslSocket: Any, isFallback: Boolean) {}\n}\n", + ) + .file( + "src/main/kotlin/app/Builder.kt", + "package app\n\nclass Builder {\n var name = \"\"\n fun build(): String = StringBuilder().apply { append(name) }.toString()\n}\n", + ) + .file( + "src/main/kotlin/app/Chains.kt", + "package app\n\nclass Chains {\n fun names(items: List): List =\n items.filter { it.isNotEmpty() }.map { it.trim() }\n}\n", + ) + .file( + "src/main/kotlin/app/Mapper.kt", + "package app\n\nclass Mapper {\n fun map(value: String): String = value\n}\n", + ) + .index(); + let graph = project.graph(); + assert!( + graph + .nodes + .iter() + .any(|node| node.name == "hex" && node.qualified_name == "okio::ByteString::hex"), + "{}", + graph.report() + ); + assert!( + !calls_from(graph, "src/main/kotlin/app/Builder.kt") + .iter() + .any(|target| target.ends_with("ConnectionSpec::apply")), + "{}", + graph.report() + ); + assert!( + !calls_from(graph, "src/main/kotlin/app/Chains.kt") + .contains(&"app::Mapper::map".to_string()), + "{}", + graph.report() + ); + } + + /// Upstream #2206 (`fc73204f`): a lambda runs on the receiver its + /// function's parameter type names, directly or through a typealias, and + /// the members the code around a bare call reaches come before those + /// only another lambda type in the project could: koin's `single { + /// C51(get(), get()) }` and `Scope.new(…)`'s `get()` are Scope's. + #[test] + fn pr2206_a_kotlin_lambda_runs_on_its_own_receiver() { + let project = Project::new() + .file( + "src/main/kotlin/org/koin/core/Koin.kt", + "package org.koin.core\n\nclass Koin {\n fun get(): T = TODO()\n}\n\nfun koinTest(block: Koin.() -> Unit) { }\n", + ) + .file( + "src/main/kotlin/org/koin/core/scope/Scope.kt", + "package org.koin.core.scope\n\nclass Scope {\n fun get(): T = TODO()\n}\n", + ) + .file( + "src/main/kotlin/org/koin/core/definition/Definition.kt", + "package org.koin.core.definition\n\nimport org.koin.core.scope.Scope\n\ntypealias Definition = Scope.(Int) -> T\n", + ) + .file( + "src/main/kotlin/org/koin/core/module/Module.kt", + "package org.koin.core.module\n\nimport org.koin.core.definition.Definition\n\nclass Module {\n fun single(definition: Definition) { }\n}\n", + ) + .file( + "src/main/kotlin/org/koin/dsl/New.kt", + "package org.koin.dsl\n\nimport org.koin.core.scope.Scope\n\ninline fun Scope.new(\n constructor: (T1, T2) -> R,\n): R = constructor(get(), get())\n", + ) + .file( + "src/test/kotlin/app/Perfs.kt", + "package app\n\nimport org.koin.core.module.Module\n\nclass C51(a: Int, b: Int)\n\nfun perfModule(m: Module) {\n m.single { C51(get(), get()) }\n}\n", + ) + .index(); + let graph = project.graph(); + let gets = |file: &str| { + let mut out: Vec = calls_from(graph, file) + .into_iter() + .filter(|target| target.ends_with("::get")) + .collect(); + out.dedup(); + out + }; + assert_eq!( + gets("src/test/kotlin/app/Perfs.kt"), + ["org.koin.core.scope::Scope::get"], + "{}", + graph.report() + ); + assert_eq!( + gets("src/main/kotlin/org/koin/dsl/New.kt"), + ["org.koin.core.scope::Scope::get"], + "{}", + graph.report() + ); + } + + /// Upstream #2232 (`27da2563`): a Gradle script's bare calls run on the + /// build tool's own types, never on a class the project declares + /// (nowinandroid's `Graph.plugins()`); build logic's extension functions + /// on Gradle's types stay. + #[test] + fn pr2232_a_gradle_script_bare_call_is_never_a_project_class_member() { + let project = Project::new() + .file( + "build-logic/src/main/kotlin/com/example/Graph.kt", + "package com.example\n\nclass Graph {\n fun plugins(): List = emptyList()\n fun dependencies(): List = emptyList()\n}\n", + ) + .file( + "build-support/src/main/kotlin/SourceSets.kt", + "import org.gradle.api.NamedDomainObjectContainer\n\nfun NamedDomainObjectContainer.createSourceSet(name: String) {\n}\n", + ) + .file( + "app/build.gradle.kts", + "plugins {\n id(\"com.android.application\")\n}\n\ndependencies {\n implementation(libs.core)\n}\n\nkotlin {\n sourceSets {\n createSourceSet(\"nonJvm\")\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let names: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == "app/build.gradle.kts") + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.name.clone()) + .collect(); + assert!( + !names.contains(&"plugins".to_string()), + "{}", + graph.report() + ); + assert!( + !names.contains(&"dependencies".to_string()), + "{}", + graph.report() + ); + assert!( + names.contains(&"createSourceSet".to_string()), + "{}", + graph.report() + ); + } + + /// Upstream #2174 (`c6491ff6`): a Kotlin infix call, `id eq id1` or + /// koin's `single { … } bind Ops::class`, is a call of the infix + /// function (`id.eq` when the left operand is a plain name, else bare + /// `bind`); a standard infix name on an expression receiver, + /// `alias(…) apply false` in a build script, is not a project method's. + /// Upstream's fixture, verbatim: `id.eq` reaches `Column::eq` through the + /// type the class declares `id` with (`class Ops(private val id: + /// Column)`, upstream #2171, W4-CS-03). + #[test] + fn pr2174_kotlin_infix_calls_call_the_infix_function() { + let project = Project::new() + .file( + "src/main/kotlin/app/Ops.kt", + "package app\n\nclass Column {\n infix fun eq(other: Int): Boolean = true\n}\n\nclass Definition {\n infix fun bind(type: Any): Definition = this\n}\n\nfun single(block: () -> Any): Definition = Definition()\n\nclass Ops(private val id: Column) {\n fun same(id1: Int): Boolean = id eq id1\n\n fun wire() {\n single { Ops(Column()) } bind Ops::class\n }\n}\n\nclass Spec {\n fun apply(block: Spec.() -> Unit): Spec = this\n}\n", + ) + .file( + "build.gradle.kts", + "plugins {\n alias(libs.plugins.kotlin.jvm) apply false\n}\n", + ) + .index(); + let graph = project.graph(); + let calls = calls_from(graph, "src/main/kotlin/app/Ops.kt"); + assert!( + calls.contains(&"app::Column::eq".to_string()), + "{}", + graph.report() + ); + assert!( + calls.contains(&"app::Definition::bind".to_string()), + "{}", + graph.report() + ); + let script = calls_from(graph, "build.gradle.kts"); + assert!( + !script.contains(&"app::Spec::apply".to_string()), + "{}", + graph.report() + ); + assert!( + graph + .unresolved + .iter() + .any(|r| r.file_path == "build.gradle.kts" && r.reference_name == "apply"), + "{}", + graph.report() + ); + } + + /// Upstream #2174: a project's bitwise extension on a number type, + /// okio's `infix fun Byte.and(mask: Int)`, is never chosen, by name, on a + /// typed receiver (`b: Int`) or guessed from the receiver's name + /// (`byte shr 4`): the standard library's `Int.and` / `Long.shr` members + /// take `x and 0xff`. The calls are still recorded. + #[test] + fn pr2174_a_project_bitwise_extension_on_a_number_type_is_never_chosen() { + let project = Project::new() + .file( + "src/main/kotlin/okio/Util.kt", + "package okio\n\ninfix fun Byte.and(mask: Int): Int = toInt() and mask\n\ninfix fun Int.and(mask: Long): Long = toLong() and mask\n\ninfix fun Byte.shr(bitCount: Int): Int = toInt() shr bitCount\n\nclass Buffer {\n fun readInt(): Int {\n val b: Int = 5\n val medium = 7\n return (b and 0xff) or (medium shr 8)\n }\n\n fun high(bytes: List): List = bytes.map { byte -> byte shr 4 }\n}\n", + ) + .index(); + let graph = project.graph(); + let bitwise: Vec<&str> = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter_map(|edge| graph.node(&edge.target)) + .filter(|node| node.name == "and" || node.name == "shr") + .map(|node| node.qualified_name.as_str()) + .collect(); + assert!(bitwise.is_empty(), "{}", graph.report()); + let mut recorded: Vec<&str> = graph + .unresolved + .iter() + .filter(|r| r.reference_kind == "calls") + .map(|r| r.reference_name.as_str()) + .filter(|name| name.ends_with("and") || name.ends_with("shr")) + .collect(); + recorded.sort_unstable(); + assert_eq!( + recorded, + ["and", "and", "b.and", "byte.shr", "medium.shr", "shr"], + "{}", + graph.report() + ); + } + + /// Upstream #2182 (`fcea0222`), the part tree-sitter-kotlin-ng 1.1.0 + /// still lacks: a `when` guard (`is String if mode == 2 ->`) and a + /// multi-dollar string (`$$"…"`) are rewritten, offsets intact, before + /// the parse; either one made the file contribute nothing. The grammar + /// parses `..<` and a nullable function-type receiver itself (N/A). + /// Upstream's fixture, verbatim. + #[test] + fn pr2182_newer_kotlin_syntax_keeps_the_class_around_it() { + let project = Project::new() + .file( + "src/Dialect.kt", + "package app\n\nclass Dialect(private val mode: Int) {\n fun sql(value: Any?): String = when (value) {\n is String if mode == 2 -> \"text\"\n is Int if (\n mode > 0\n ) -> \"int\"\n is Long -> if (mode == 1) \"long\" else \"big\"\n else -> \"other\"\n }\n\n fun ranges(n: Int) {\n for (i in 0..?.() -> Op): Dialect = this\n\n fun after(): Int = 1\n}\n\nclass Op\n", + ) + .index(); + let graph = project.graph(); + let mut methods: Vec<&str> = graph + .nodes + .iter() + .filter(|node| node.file_path == "src/Dialect.kt" && node.kind == "method") + .map(|node| node.qualified_name.as_str()) + .collect(); + methods.sort_unstable(); + assert_eq!( + methods, + [ + "app::Dialect::adjust", + "app::Dialect::after", + "app::Dialect::path", + "app::Dialect::ranges", + "app::Dialect::sql", + ], + "{}", + graph.report() + ); + } +} + +mod scala { + use super::support::{Graph, Project}; + + /// `target qualified-name:line` of every edge of `kinds` out of `file`'s + /// nodes, sorted. + fn edges_from(graph: &Graph, file: &str, kinds: &[&str]) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| kinds.contains(&edge.kind.as_str())) + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| { + let target = graph.node(&edge.target)?; + Some(format!( + "{}:{}", + target.qualified_name, + edge.line.unwrap_or_default() + )) + }) + .collect(); + out.sort(); + out + } + + /// The files the edges of `kinds` out of `file` land in, sorted. + fn target_files(graph: &Graph, file: &str, kinds: &[&str]) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| kinds.contains(&edge.kind.as_str())) + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.file_path.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2160 (`7aa124c0`): a bare Scala name reached by name alone + /// is in reach: a parameter binds `f`, a later chain link reaches a + /// member of what its receiver is named after, and otherwise a member of + /// the types around it and their supertypes. Upstream's fixture, with two + /// changes: its `val parser = new OptionParser[Int]("scopt") { head(…) }` + /// sits in a `def`, as the extractor does not walk a Scala `val`'s + /// initializer (pre-existing, upstream #1802, recorded for the owner), and + /// an `Other` class declares `head` and `map` too, so each assertion has a + /// namesake to reject. A `More` object adds a bare call and a chain link + /// whose only same-named candidates are an unrelated class's. + #[test] + fn pr2160_bare_scala_calls_stay_in_reach() { + let project = Project::new() + .file( + "src/main/scala/app/FreeT.scala", + "package app\n\nfinal case class FlatMapped[A](f: A => A)\n", + ) + .file( + "src/main/scala/app/LazyOps.scala", + "package app\n\nclass LazyOps[A](xs: List[A]) {\n def map[B](g: A => B): List[B] = xs.map(g)\n}\n", + ) + .file( + "src/main/scala/app/OptionParser.scala", + "package app\n\nabstract class OptionParser[C](name: String) {\n def head(xs: String*): Unit = ()\n}\n", + ) + .file( + "src/main/scala/app/Base.scala", + "package app\n\ntrait Base {\n def helper(): Int = 1\n}\n", + ) + .file( + "src/main/scala/app/Request.scala", + "package app\n\nclass Request {\n def send(backend: String): String = backend\n}\n", + ) + .file( + "src/main/scala/app/Use.scala", + "package app\n\nobject Use extends Base {\n def tabulate[A](f: Boolean => A): (A, A) = (f(true), f(false))\n def twice(fa: List[Int]): List[Int] = fa.iterator.map(_ + 1).toList\n def run(basicRequest: Request): String = basicRequest.send(\"b\")\n def local(): Int = helper()\n def parser(): OptionParser[Int] = new OptionParser[Int](\"scopt\") {\n head(\"scopt\", \"3.x\")\n }\n}\n", + ) + .file( + "src/main/scala/app/Other.scala", + "package app\n\nclass Other {\n def head(xs: String*): Unit = ()\n def map(i: Int): Int = i\n}\n", + ) + .file( + "src/main/scala/app/Unrelated.scala", + "package app\n\nclass Unrelated {\n def helper2(): Int = 1\n def size2(): Int = 2\n}\n", + ) + .file( + "src/main/scala/app/More.scala", + "package app\n\nobject More extends Base {\n def other(): Int = helper2()\n def chain(): Int = List(1).size2()\n}\n", + ) + .index(); + let graph = project.graph(); + // The one `helper2` and the one `size2` are an unrelated class's: out + // of reach of a bare call and of a chain link on `List(1)`. + let more = edges_from(graph, "src/main/scala/app/More.scala", &["calls"]); + assert!( + !more.iter().any(|call| call.starts_with("Unrelated::")), + "{}", + graph.report() + ); + let calls = edges_from(graph, "src/main/scala/app/Use.scala", &["calls"]); + let has = |suffix: &str| calls.iter().any(|call| call.contains(suffix)); + assert!(!has("FlatMapped::f:"), "{}", graph.report()); + assert!(!has("LazyOps::map:"), "{}", graph.report()); + assert!(!has("Other::map:"), "{}", graph.report()); + assert!(has("Request::send:"), "{}", graph.report()); + assert!(has("Base::helper:"), "{}", graph.report()); + assert!(has("OptionParser::head:"), "{}", graph.report()); + assert!(!has("Other::head:"), "{}", graph.report()); + } + + /// Upstream #2193 (`8410db42`): a `val` written in a block of a class + /// body is that block's, a class-body binder counts only in a block still + /// open at the site, and a package object's supertypes are in scope where + /// it is imported and in its own package. Upstream's fixture, verbatim. + #[test] + fn pr2193_block_local_vals_and_package_objects() { + let project = Project::new() + .file( + "src/test/scala/app/ASuite.scala", + "package app\n\nclass ASuite {\n def test(name: String)(body: => Unit): Unit = ()\n\n test(\"a\") {\n val f = (i: Int) => i\n f(1)\n }\n\n test(\"c\") {\n f(2)\n }\n}\n", + ) + .file( + "src/test/scala/app/BSuite.scala", + "package app\n\nimport app.syntax.all._\n\nclass BSuite {\n def test(name: String)(body: => Unit): Unit = ()\n def forAll(p: (Int, Int => Int) => Boolean): Unit = ()\n\n test(\"b\") {\n forAll { (e: Int, f: Int => Int) =>\n f(e) == 1\n }\n }\n}\n", + ) + .file( + "src/main/scala/app/syntax/all.scala", + "package app.syntax\n\nobject all\n", + ) + .file( + "src/main/scala/app/Api.scala", + "package app\n\ntrait Api {\n def multipart(s: String): String = s\n}\n", + ) + .file( + "src/main/scala/app/api/package.scala", + "package app\n\npackage object api extends Api\n", + ) + .file( + "src/main/scala/app/client/Client.scala", + "package app.client\n\nimport app.api._\n\nclass Client {\n def send(): String = multipart(\"x\")\n}\n", + ) + .file( + "src/main/scala/app/api/Inner.scala", + "package app.api\n\nclass Inner {\n def send(): String = multipart(\"y\")\n}\n", + ) + .index(); + let graph = project.graph(); + let a_suite: Vec = edges_from(graph, "src/test/scala/app/ASuite.scala", &["calls"]) + .into_iter() + .filter(|call| call.starts_with("ASuite::f")) + .collect(); + assert_eq!(a_suite, ["ASuite::f:8"], "{}", graph.report()); + assert!( + !edges_from(graph, "src/test/scala/app/BSuite.scala", &["calls"]) + .iter() + .any(|call| call.contains("::f:")), + "{}", + graph.report() + ); + assert_eq!( + edges_from(graph, "src/main/scala/app/client/Client.scala", &["calls"]), + ["Api::multipart:6"], + "{}", + graph.report() + ); + assert_eq!( + edges_from(graph, "src/main/scala/app/api/Inner.scala", &["calls"]), + ["Api::multipart:4"], + "{}", + graph.report() + ); + } + + /// Upstream #2199 (`7493fdef`): a chain link is never an object named + /// like the member, and reaches a type the receiver's package path names + /// (`app.client.Response(200)`, recorded as `client.Response` here, KEEP-RUST + /// G14). Upstream's fixture, verbatim. + #[test] + fn pr2199_a_chain_link_is_never_an_object_named_like_the_member() { + let project = Project::new() + .file( + "src/main/scala/alleycats/std/map.scala", + "package alleycats.std\n\nobject map {\n val instances: Int = 1\n}\n", + ) + .file( + "src/main/scala/app/client/Response.scala", + "package app.client\n\ncase class Response(code: Int)\n", + ) + .file( + "src/main/scala/app/Use.scala", + "package app\n\nclass Use {\n def gen[A](a: A): List[A] = List(a)\n def run(): List[Int] = gen[Int](1).map { x => x + 1 }\n def make(): Any = app.client.Response(200)\n}\n", + ) + .index(); + let graph = project.graph(); + let files = target_files( + graph, + "src/main/scala/app/Use.scala", + &["calls", "instantiates"], + ); + assert!( + !files.contains(&"src/main/scala/alleycats/std/map.scala".to_string()), + "{}", + graph.report() + ); + assert!( + files.contains(&"src/main/scala/app/client/Response.scala".to_string()), + "{}", + graph.report() + ); + } + + /// Upstream #2217 (`fa43e259`): a kind-projector placeholder in a type is + /// no reference to a `*` method. Upstream's fixture, verbatim. + #[test] + fn pr2217_a_symbolic_name_in_a_type_is_never_an_operator() { + let project = Project::new() + .file( + "src/main/scala/algebra/Signed.scala", + "package algebra\n\nobject Signed {\n sealed abstract class Sign(val toInt: Int) {\n def *(that: Sign): Sign = this\n }\n}\n", + ) + .file( + "src/main/scala/cats/Align.scala", + "package cats\n\ntrait Align[F[_]]\n\nobject Align {\n implicit def catsAlignForEither[A]: Align[Either[A, *]] = null\n}\n", + ) + .index(); + let graph = project.graph(); + let names: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "references") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == "src/main/scala/cats/Align.scala") + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.name.clone()) + .collect(); + assert!(!names.contains(&"*".to_string()), "{}", graph.report()); + } + + /// Upstream #2230 (`3878123f`): a package object's member is in scope in + /// its package and those under it, or through an import of its package. + /// Upstream's fixture, verbatim. + #[test] + fn pr2230_a_package_object_member_is_in_scope_in_its_package_or_by_import() { + let project = Project::new() + .file( + "algebra-core/src/main/scala/algebra/package.scala", + "package object algebra {\n type Eq[A] = cats.kernel.Eq[A]\n}\n", + ) + .file( + "core/src/main/scala/cats/package.scala", + "package object cats {\n type Eq[A] = cats.kernel.Eq[A]\n}\n", + ) + .file( + "laws/src/main/scala/cats/laws/FunctorLaws.scala", + "package cats\npackage laws\n\ntrait FunctorLaws {\n def identity[A](eq: Eq[A]): Boolean = true\n}\n", + ) + .file( + "algebra-laws/src/main/scala/algebra/laws/RingLaws.scala", + "package algebra.laws\n\nimport algebra._\n\ntrait RingLaws {\n def eqv[A](eq: Eq[A]): Boolean = true\n}\n", + ) + .index(); + let graph = project.graph(); + let eq_files = |file: &str| { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "references") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .filter(|node| node.name == "Eq") + .map(|node| node.file_path.clone()) + .collect(); + out.sort(); + out.dedup(); + out + }; + assert_eq!( + eq_files("laws/src/main/scala/cats/laws/FunctorLaws.scala"), + ["core/src/main/scala/cats/package.scala"], + "{}", + graph.report() + ); + assert_eq!( + eq_files("algebra-laws/src/main/scala/algebra/laws/RingLaws.scala"), + ["algebra-core/src/main/scala/algebra/package.scala"], + "{}", + graph.report() + ); + } +} + +mod csharp { + use super::support::{Graph, Project, assert_edge, assert_no_edge}; + + /// `kind qualified-name` of every node of `file` but its file node. + fn qualified_names(graph: &Graph, file: &str) -> Vec { + graph + .nodes + .iter() + .filter(|node| node.file_path == file && node.kind != "file") + .map(|node| format!("{} {}", node.kind, node.qualified_name)) + .collect() + } + + /// `kind file:qualified-name` of what the edges out of `file`'s nodes at + /// `line` (of `kind`, when given) reach, sorted. + fn targets_at(graph: &Graph, file: &str, line: i64, kind: Option<&str>) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.line == Some(line)) + .filter(|edge| kind.is_none_or(|kind| edge.kind == kind)) + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{} {}:{}", node.kind, node.file_path, node.qualified_name)) + .collect(); + out.sort(); + out + } + + /// Upstream #2121 (`e453e820`), its C# half: a type position names a + /// type. `Type sourceType`, `List` and `new TypeMap()` bound to + /// whichever same-named member was nearest: an attribute's `Type` + /// property, a test class's `List` property, the `TypeMap` property beside + /// the `TypeMap` class. Upstream's fixture. + #[test] + fn pr2121_a_csharp_type_position_names_a_type_never_a_member() { + let project = Project::new() + .file( + "src/TypeMap.cs", + "namespace App;\npublic class TypeMap {\n public string Name { get; set; }\n}\n\ + public class MemberMap {\n public MemberMap() { }\n}\n", + ) + .file( + "src/Attrs.cs", + "namespace App;\npublic class ValueConverterAttribute {\n public System.Type Type { get; set; }\n}\n\ + public class Source {\n public System.Collections.Generic.List List { get; set; }\n}\n", + ) + .file( + "src/Profile.cs", + "namespace App;\nusing System;\nusing System.Collections.Generic;\npublic class Profile {\n\ + \x20 public TypeMap TypeMap { get; set; }\n\ + \x20 public void Configure(TypeMap typeMap, MemberMap memberMap, Type sourceType) {\n\ + \x20 var items = new List();\n\ + \x20 var copy = new TypeMap();\n }\n}\n", + ) + .index(); + let graph = project.graph(); + + let configure = graph + .nodes + .iter() + .find(|node| node.kind == "method" && node.name == "Configure") + .expect("Configure is indexed"); + let targets: Vec = graph + .edges + .iter() + .filter(|edge| edge.source == configure.id) + .filter(|edge| ["references", "instantiates", "type_of"].contains(&edge.kind.as_str())) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{}:{}", node.kind, node.name)) + .collect(); + assert!( + targets.contains(&"class:TypeMap".to_owned()), + "{}", + graph.report() + ); + assert!( + targets.contains(&"class:MemberMap".to_owned()), + "{}", + graph.report() + ); + // A property, a constructor, or a type the project only has a member + // named after. + assert!( + !targets + .iter() + .any(|target| target.starts_with("property:") || target.starts_with("method:")), + "{targets:?}\n{}", + graph.report() + ); + } + + const PR2151_FILES: [(&str, &str); 5] = [ + ( + "tests/OrderProcessorTests.cs", + "namespace App.Tests;\n\npublic class OrderProcessorTests\n{\n public TestContext TestContext { get; set; } = null!;\n}\n", + ), + ( + "src/Animations/AnimationBase.cs", + "namespace App.Animations;\n\npublic abstract class AnimationBase\n{\n public EasingType Easing { get; set; }\n}\n", + ), + ( + "src/Execution/ExpressionBuilder.cs", + "namespace App.Execution;\n\npublic static class ExpressionBuilder\n{\n public static object Call(object target) => target;\n}\n", + ), + ( + "src/Specs/SpecBase.cs", + "namespace App.Specs;\n\npublic abstract class SpecBase\n{\n protected void AssertValid() { }\n}\n", + ), + ( + "src/Specs/CatalogSpec.cs", + "namespace App.Specs;\n\npublic class CatalogSpec : SpecBase\n{\n public void Runs()\n {\n var token = TestContext.Current.CancellationToken;\n var easing = Easing.Linear;\n AssertValid();\n Call(token);\n }\n}\n", + ), + ]; + + /// What the `calls` and `references` edges out of `CatalogSpec.cs` reach. + fn catalog_targets(graph: &Graph) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls" || edge.kind == "references") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == "src/Specs/CatalogSpec.cs") + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2151 (`6eb08d68`): a bare C# name (`TestContext`, `Easing`, + /// `Helper()`) is a member of the types around it, of their bases, or of + /// a type a static using brings in, never another class's property: + /// eShop's `TestContext.Current` reached another test class's + /// `TestContext` property. Upstream's fixture, where a project file's + /// `` brings `ExpressionBuilder` in; then the + /// same tree without it (`Call` is out of reach), with a `global using + /// static` in another file, and with the file's own `using static`. + #[test] + fn pr2151_a_bare_csharp_name_reaches_only_members_in_scope() { + let with = |extra: &[(&str, &str)], catalog: Option<&str>| { + let mut project = Project::new(); + for (path, contents) in PR2151_FILES { + let contents = match (path, catalog) { + ("src/Specs/CatalogSpec.cs", Some(catalog)) => catalog, + _ => contents, + }; + project = project.file(path, contents); + } + for (path, contents) in extra { + project = project.file(path, contents); + } + project.index() + }; + let csproj = ( + "src/App.csproj", + "\n \n \n \n\n", + ); + let upstream = with(&[csproj], None); + let targets = catalog_targets(upstream.graph()); + assert!( + !targets + .iter() + .any(|t| t == "App.Tests::OrderProcessorTests::TestContext"), + "{targets:?}" + ); + assert!( + !targets + .iter() + .any(|t| t == "App.Animations::AnimationBase::Easing"), + "{targets:?}" + ); + assert!( + targets + .iter() + .any(|t| t == "App.Specs::SpecBase::AssertValid"), + "{targets:?}" + ); + assert!( + targets + .iter() + .any(|t| t == "App.Execution::ExpressionBuilder::Call"), + "{targets:?}" + ); + + let call = "App.Execution::ExpressionBuilder::Call"; + let bare = with(&[], None); + assert!( + !catalog_targets(bare.graph()).iter().any(|t| t == call), + "{}", + bare.graph().report() + ); + let global = with( + &[( + "src/GlobalUsings.cs", + "global using static App.Execution.ExpressionBuilder;\n", + )], + None, + ); + assert!( + catalog_targets(global.graph()).iter().any(|t| t == call), + "{}", + global.graph().report() + ); + let own = with( + &[], + Some( + "using static App.Execution.ExpressionBuilder;\nnamespace App.Specs;\n\npublic class CatalogSpec : SpecBase\n{\n public void Runs()\n {\n var token = TestContext.Current.CancellationToken;\n var easing = Easing.Linear;\n AssertValid();\n Call(token);\n }\n}\n", + ), + ); + assert!( + catalog_targets(own.graph()).iter().any(|t| t == call), + "{}", + own.graph().report() + ); + } + + /// Upstream #2216 (`a8b578f7`): a bare C# type name means a type its file + /// can see: one in its own namespace or an enclosing one, one a `using` + /// imports, or the one an alias names. Newtonsoft's `async Task` tests + /// (`using System.Threading.Tasks;`) bound `Task` to a test class of that + /// name. Upstream's fixture. + #[test] + fn pr2216_a_bare_csharp_type_name_is_one_its_namespaces_see() { + let project = Project::new() + .file( + "src/App.Tests/Schema/Generator.cs", + "namespace App.Tests.Schema\n{\n public class Task\n {\n }\n}\n", + ) + .file( + "src/App.Tests/Reader/ReadTests.cs", + "using System.Threading.Tasks;\n\nnamespace App.Tests.Reader\n{\n public class ReadTests\n {\n\ + \x20 public async Task Read()\n {\n\ + \x20 await System.Threading.Tasks.Task.Yield();\n }\n }\n}\n", + ) + .file( + "src/App.Tests/Schema/Planner.cs", + "namespace App.Tests.Schema\n{\n public class Planner\n {\n\ + \x20 public Task Next() { return new Task(); }\n }\n}\n", + ) + .file( + "src/App.Tests/Other/Aliased.cs", + "using Task = App.Tests.Schema.Task;\n\nnamespace App.Tests.Other\n{\n public class Aliased\n {\n\ + \x20 public Task Make() { return new Task(); }\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let reaches_task = |file: &str| { + graph.edges.iter().any(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + && graph + .node(&edge.target) + .is_some_and(|node| node.qualified_name == "App.Tests.Schema::Task") + }) + }; + + assert!( + !reaches_task("src/App.Tests/Reader/ReadTests.cs"), + "{}", + graph.report() + ); + assert!( + reaches_task("src/App.Tests/Schema/Planner.cs"), + "{}", + graph.report() + ); + assert!( + reaches_task("src/App.Tests/Other/Aliased.cs"), + "{}", + graph.report() + ); + } + + /// Upstream #2220 (`ff75a6ca`): C# names follow the language's scopes. A + /// block `namespace X { … }` holds only its body, and a file's second + /// namespace, or one nested in another (`Beta.Inner`), is its own; `using + /// X;` names the project's namespace X; a `global using` is its own + /// project's (serilog's two test projects each `global using` their own + /// `Support`, and both define `Some`); a nested type is reached by bare + /// name only from inside its owner or a type deriving from it, through any + /// partial part. Upstream's fixture. + #[test] + fn pr2220_csharp_namespaces_usings_and_nested_types_follow_their_scopes() { + let project = Project::new() + .file("src/Lib/Lib.csproj", "\n") + .file( + "src/Lib/Guard.cs", + "using JetBrains.Annotations;\n\nnamespace JetBrains.Annotations\n{\n\ + \x20 sealed class NoEnumerationAttribute : System.Attribute { }\n}\n\n\ + static class Guard\n{\n\ + \x20 public static T AgainstNull(T value) where T : class => value;\n}\n", + ) + .file( + "src/Lib/Multi.cs", + "namespace Alpha\n{\n class X { }\n}\n\nnamespace Beta\n{\n class Y { }\n\n\ + \x20 namespace Inner\n {\n class Z { }\n }\n}\n", + ) + .file( + "src/Lib/Uses.cs", + "using Beta;\n\nnamespace Gamma\n{\n class Consumer { }\n}\n", + ) + .file( + "src/Lib/Reader.cs", + "namespace Lib\n{\n public abstract class Reader\n {\n\ + \x20 internal enum State { Start, Done }\n }\n\n\ + \x20 public partial class TextReader : Reader\n {\n }\n}\n", + ) + .file( + "src/Lib/TextReader.Async.cs", + "namespace Lib\n{\n public partial class TextReader\n {\n\ + \x20 object Peek() => State.Start;\n }\n}\n", + ) + .file( + "src/Lib/Writer.cs", + "namespace Lib\n{\n public abstract class Writer\n {\n\ + \x20 internal enum State { Start, Done }\n\n\ + \x20 object Current() => State.Done;\n }\n}\n", + ) + .file("test/Unit/Unit.csproj", "\n") + .file("test/Unit/GlobalUsings.cs", "global using Unit.Support;\n") + .file( + "test/Unit/Support/Some.cs", + "namespace Unit.Support;\n\nstatic class Some\n{\n\ + \x20 public static int InformationEvent() => 1;\n}\n", + ) + .file( + "test/Unit/LogTests.cs", + "namespace Unit.Context;\n\nclass LogTests\n{\n int Run() => Some.InformationEvent();\n}\n", + ) + .file( + "test/Unit/MappingTests.cs", + "namespace Unit.Mapping;\n\nclass FirstCase\n{\n class Source { }\n\ + \x20 object Make() => new Source();\n}\n\nclass SecondCase\n{\n class Source { }\n\ + \x20 object Make() => new Source();\n}\n", + ) + .file("test/Perf/Perf.csproj", "\n") + .file("test/Perf/GlobalUsings.cs", "global using Perf.Support;\n") + .file( + "test/Perf/Support/Some.cs", + "namespace Perf.Support;\n\nstatic class Some\n{\n\ + \x20 public static int InformationEvent() => 2;\n}\n", + ) + .index(); + let graph = project.graph(); + + // A block namespace qualifies only its own body. + let guard = qualified_names(graph, "src/Lib/Guard.cs"); + for expected in [ + "class JetBrains.Annotations::NoEnumerationAttribute", + "class Guard", + "method Guard::AgainstNull", + ] { + assert!( + guard.iter().any(|name| name == expected), + "{expected}: {guard:?}" + ); + } + let multi = qualified_names(graph, "src/Lib/Multi.cs"); + for expected in ["class Alpha::X", "class Beta::Y", "class Beta.Inner::Z"] { + assert!( + multi.iter().any(|name| name == expected), + "{expected}: {multi:?}" + ); + } + + // A using names the project's namespace. + assert_eq!( + targets_at(graph, "src/Lib/Uses.cs", 1, Some("imports")), + ["namespace src/Lib/Multi.cs:Beta"], + "{}", + graph.report() + ); + + // A global using is its own project's. + assert_eq!( + targets_at(graph, "test/Unit/LogTests.cs", 5, Some("calls")), + ["method test/Unit/Support/Some.cs:Unit.Support::Some::InformationEvent"], + "{}", + graph.report() + ); + + // A nested type is its owner's, or a derived type's through any + // partial part. + assert_eq!( + targets_at(graph, "test/Unit/MappingTests.cs", 12, Some("instantiates")), + ["class test/Unit/MappingTests.cs:Unit.Mapping::SecondCase::Source"], + "{}", + graph.report() + ); + let peek = targets_at(graph, "src/Lib/TextReader.Async.cs", 5, None); + assert!( + peek.iter() + .any(|target| target == "enum src/Lib/Reader.cs:Lib::Reader::State"), + "{peek:?}\n{}", + graph.report() + ); + let current = targets_at(graph, "src/Lib/Writer.cs", 7, None); + assert!( + current + .iter() + .any(|target| target == "enum src/Lib/Writer.cs:Lib::Writer::State"), + "{current:?}\n{}", + graph.report() + ); + } + + /// The qualified name of what every `calls` edge out of `file` reaches, + /// sorted (upstream's `callsFrom`). + fn call_targets(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2171 (`f9530d7d`): a C# call through a field, property or + /// parameter is a call on the type it is declared with, read from the + /// class's member declarations, those it inherits (with the type + /// arguments the subclass gives), or a using alias, never a guess at a + /// same-named method; a C# interface's members are public and a type in a + /// namespace is internal; a standard .NET name on an untyped receiver + /// (`table.Columns.Add(…)`) is no project method unless the receiver + /// names its owner. Upstream's fixture. + #[test] + fn pr2171_csharp_calls_through_declared_members_use_the_member_type() { + let project = Project::new() + .file( + "src/Json/JsonWriter.cs", + "namespace App.Json;\n\npublic abstract class JsonWriter\n{\n public virtual void WriteValue(string value) { }\n}\n", + ) + .file( + "src/Json/TraceJsonWriter.cs", + "using System.IO;\n\nnamespace App.Json;\n\ninternal class TraceJsonWriter : JsonWriter\n{\n private readonly JsonWriter _innerWriter;\n /* a block comment naming the _textWriter field */\n private readonly TextWriter _textWriter;\n\n public override void WriteValue(string value)\n {\n _innerWriter.WriteValue(value);\n _textWriter.Write(value);\n }\n}\n", + ) + .file( + "tests/ThrowingWriter.cs", + "using System.IO;\n\nnamespace App.Tests;\n\npublic class ThrowingWriter : TextWriter\n{\n public override void Write(string value) { }\n}\n", + ) + .file( + "tests/XUnitAssert.cs", + "namespace App.Tests;\n\npublic static class XUnitAssert\n{\n public static void AreEqual(object a, object b) { }\n}\n", + ) + .file( + "src/DefaultJsonNameTable.cs", + "namespace App;\n\npublic class DefaultJsonNameTable\n{\n public void Add(string key) { }\n}\n", + ) + .file( + "src/Services/IFixUriService.cs", + "namespace App.Services;\n\npublic interface IFixUriService\n{\n void FixBasketItemPictureUri(string uri);\n}\n", + ) + .file( + "src/Services/FixUriService.cs", + "namespace App.Services;\n\npublic class FixUriService : IFixUriService\n{\n public void FixBasketItemPictureUri(string uri) { }\n}\n", + ) + .file( + "src/Services/BasketService.cs", + "namespace App.Services;\n\npublic class BasketService\n{\n private readonly IFixUriService _fixUriService;\n\n public void Load(string uri)\n {\n _fixUriService.FixBasketItemPictureUri(uri);\n }\n}\n", + ) + .file( + "tests/Integration/IntegrationTest.cs", + "namespace App.Tests.Integration;\n\npublic abstract class IntegrationTest where TFixture : new()\n{\n protected TFixture Fixture { get; private set; }\n}\n\npublic class DropCreateDatabaseAlways\n{\n public object CreateContext() => null;\n}\n", + ) + .file( + "tests/Integration/QueryTests.cs", + "using Assert = App.Tests.XUnitAssert;\n\nnamespace App.Tests.Integration;\n\npublic class QueryTests : IntegrationTest\n{\n public class DatabaseInitializer : DropCreateDatabaseAlways { }\n\n public void Runs()\n {\n var context = Fixture.CreateContext();\n Assert.AreEqual(1, 1);\n var table = MakeTable();\n table.Columns.Add(\"price\");\n }\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + call_targets(graph, "src/Json/TraceJsonWriter.cs"), + ["App.Json::JsonWriter::WriteValue"], + "{}", + graph.report() + ); + assert_eq!( + call_targets(graph, "src/Services/BasketService.cs"), + ["App.Services::IFixUriService::FixBasketItemPictureUri"], + "{}", + graph.report() + ); + assert_eq!( + call_targets(graph, "tests/Integration/QueryTests.cs"), + [ + "App.Tests.Integration::DropCreateDatabaseAlways::CreateContext", + "App.Tests::XUnitAssert::AreEqual" + ], + "{}", + graph.report() + ); + let visibility = |file: &str, kind: &str| { + graph + .nodes + .iter() + .find(|node| node.file_path == file && node.kind == kind) + .and_then(|node| node.visibility.clone()) + }; + assert_eq!( + visibility("src/Services/IFixUriService.cs", "method").as_deref(), + Some("public") + ); + assert_eq!( + visibility("src/Json/TraceJsonWriter.cs", "class").as_deref(), + Some("internal") + ); + } + + /// KEEP-RUST (W3-05c, adapted to upstream #2171): a C# receiver declared + /// with a keyword type (`string message`) reaches the project's extension + /// method for it now that the receiver-word guess no longer reads the + /// method's own name. When several overloads of one type fit, C#'s rules + /// pick one, also across a `partial static class` split over files (an + /// array creation at a `params` position takes the `params` overload); a + /// standard .NET name (`message.StartsWith(".")`) stays the keyword + /// type's own method. + #[test] + fn keyword_typed_receivers_reach_extension_overloads_across_partial_parts() { + let project = Project::new() + .file( + "src/Utilities/StringUtils.cs", + "namespace App.Utilities\n{\n internal static partial class StringUtils\n {\n public static string FormatWith(this string format, IFormatProvider provider, object? arg0)\n {\n return format;\n }\n }\n}\n", + ) + .file( + "src/Utilities/StringUtils.Params.cs", + "namespace App.Utilities\n{\n internal static partial class StringUtils\n {\n public static string FormatWith(this string format, IFormatProvider provider, params object?[] args)\n {\n return format;\n }\n\n public static bool StartsWith(this string source, char value)\n {\n return false;\n }\n }\n}\n", + ) + .file( + "src/App/Report.cs", + "using App.Utilities;\n\nnamespace App\n{\n public class Report\n {\n public string Render(string message, object? a, object? b)\n {\n if (message.StartsWith(\".\")) { }\n return message.FormatWith(null, new object?[] { a, b });\n }\n }\n}\n", + ) + .index(); + assert_edge!( + project, + "method App::Report::Render" => calls => "method App.Utilities::StringUtils::FormatWith @src/Utilities/StringUtils.Params.cs:5" + ); + assert_no_edge!( + project, + "method App::Report::Render" => calls => "method App.Utilities::StringUtils::FormatWith @src/Utilities/StringUtils.cs:5" + ); + assert_no_edge!( + project, + "method App::Report::Render" => calls => "method App.Utilities::StringUtils::StartsWith" + ); + } + + /// The qualified name of what every `kind` edge out of the node of `file` + /// named `qualified_name` reaches, sorted (upstream's + /// `targets(member(…), kind)`). + fn member_targets(graph: &Graph, file: &str, qualified_name: &str, kind: &str) -> Vec { + let member = graph + .nodes + .iter() + .find(|node| node.file_path == file && node.qualified_name == qualified_name) + .unwrap_or_else(|| panic!("{qualified_name} is indexed\n{}", graph.report())); + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.source == member.id && edge.kind == kind) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2345 (`489b4749`), its C# half: a property's accessor bodies + /// and expression body are walked as the property, so the calls, + /// instantiations and static reads written there belong to it, and a + /// method passed as a value there is the property's reference, captured + /// once. Upstream's fixture. + #[test] + fn pr2345_csharp_accessor_bodies_are_walked_as_the_property() { + let project = Project::new() + .file( + "Helper.cs", + "public static class Helper {\n\ + \x20 public static int Compute(int n) => n;\n\ + \x20 public static void Store(int n) {}\n\ + \x20 public static int Arrow() => 1;\n\ + \x20 public static int GetA() => 1;\n\ + \x20 public static void SetA(int v) {}\n\ + }\n\ + public class Widget {}\n\ + public static class Defaults { public const string Name = \"x\"; }\n", + ) + .file( + "Box.cs", + "public class Box {\n\ + \x20 private int _x;\n\ + \x20 public int Value {\n\ + \x20 get { return Helper.Compute(_x); }\n\ + \x20 set { Helper.Store(value); _x = value; }\n\ + \x20 }\n\ + \x20 public int Arrow => Helper.Arrow();\n\ + \x20 public int Both { get => Helper.GetA(); set => Helper.SetA(value); }\n\ + \x20 public Widget Made { get { return new Widget(); } }\n\ + \x20 public string Label { get { return Defaults.Name; } }\n\ + \x20 private void Handle(int v) { }\n\ + \x20 public System.Action Handler { get { return Pick(Handle); } }\n\ + }\n", + ) + .index(); + let graph = project.graph(); + let targets = |name: &str, kind: &str| member_targets(graph, "Box.cs", name, kind); + + assert_eq!( + targets("Box::Value", "calls"), + ["Helper::Compute", "Helper::Store"] + ); + assert_eq!(targets("Box::Arrow", "calls"), ["Helper::Arrow"]); + assert_eq!( + targets("Box::Both", "calls"), + ["Helper::GetA", "Helper::SetA"] + ); + assert_eq!(targets("Box::Made", "instantiates"), ["Widget"]); + assert!(targets("Box::Label", "references").contains(&"Defaults".to_owned())); + // A method passed as a value inside an accessor is the property's + // reference, captured once, not the class's as well. + assert!(targets("Box::Handler", "references").contains(&"Box::Handle".to_owned())); + assert!(!targets("Box", "references").contains(&"Box::Handle".to_owned())); + assert!(targets("Box", "calls").is_empty(), "{}", graph.report()); + } + + /// Upstream #2348 (`355afc70`): a C# field declarator and a property's + /// `= initializer` are walked as the member they initialize, so `private + /// readonly ILogger _log = LogManager.GetLogger(typeof(Box));` calls + /// `GetLogger` from `_log`; a target-typed `new()` there instantiates the + /// declared type; lambdas and function values belong to the member; + /// attributes are not walked. Upstream's fixture. + #[test] + fn pr2348_csharp_initializers_are_walked_as_their_member() { + let project = Project::new() + .file( + "Lib.cs", + "namespace Lib\n\ + {\n\ + \x20 public class Crate { }\n\ + }\n\ + public interface ILogger { }\n\ + public static class LogManager\n\ + {\n\ + \x20 public static ILogger GetLogger(System.Type t) => null;\n\ + }\n\ + public static class Helper\n\ + {\n\ + \x20 public static int Compute(int n) => n;\n\ + \x20 public static int Seed() => 1;\n\ + \x20 public static void Register(System.Action a) { }\n\ + \x20 public static System.Action Wrap(System.Action a) => a;\n\ + }\n\ + public class Widget\n\ + {\n\ + \x20 public Widget() { }\n\ + \x20 public Widget(int n) { }\n\ + \x20 public int Size { get; set; }\n\ + }\n\ + public class Bag { }\n\ + public static class Defaults\n\ + {\n\ + \x20 public const string Name = \"x\";\n\ + \x20 public static readonly Widget Empty = new Widget();\n\ + }\n", + ) + .file( + "Box.cs", + "using Lib;\n\ + \n\ + public class Box\n\ + {\n\ + \x20 private readonly ILogger _log = LogManager.GetLogger(typeof(Box));\n\ + \x20 private int _a = 1, _b = Helper.Compute(2);\n\ + \x20 private static readonly int Max = Helper.Seed();\n\ + \x20 private readonly Widget _made = new Widget(3);\n\ + \x20 private readonly Widget _typed = new() { Size = Helper.Seed() };\n\ + \x20 private Widget? _maybe = new();\n\ + \x20 private readonly Bag _bag = new();\n\ + \x20 private readonly Lib.Crate _crate = new();\n\ + \x20 private readonly string _label = Defaults.Name;\n\ + \x20 private readonly System.Func _square = x => Helper.Compute(x * x);\n\ + \x20 private readonly System.Action _later = () => Helper.Register(Handle);\n\ + \x20 private readonly System.Action _direct = Handle;\n\ + \x20 [System.Obsolete(\"use Helper.Seed()\")] private int _flagged;\n\ + \x20 public Bag Items { get; } = new Bag();\n\ + \x20 public Widget Made { get; set; } = new();\n\ + \x20 public Widget Copy { get; } = Defaults.Empty;\n\ + \x20 public System.Action Wrapped { get; } = Helper.Wrap(Handle);\n\ + \x20 public int Seeded { get; } = Helper.Seed();\n\ + \x20 private static void Handle() { }\n\ + }\n", + ) + .index(); + let graph = project.graph(); + let targets = |name: &str, kind: &str| member_targets(graph, "Box.cs", name, kind); + let kind_of = |name: &str| { + graph + .nodes + .iter() + .find(|node| node.file_path == "Box.cs" && node.qualified_name == name) + .map(|node| node.kind.clone()) + }; + + // Calls, per declarator. + assert_eq!(targets("Box::_log", "calls"), ["LogManager::GetLogger"]); + assert!(targets("Box::_a", "calls").is_empty()); + assert_eq!(targets("Box::_b", "calls"), ["Helper::Compute"]); + assert_eq!(kind_of("Box::Max").as_deref(), Some("constant")); + assert_eq!(targets("Box::Max", "calls"), ["Helper::Seed"]); + assert_eq!(targets("Box::Seeded", "calls"), ["Helper::Seed"]); + + // Instantiations, including a target-typed `new()` of the declared + // type. + assert_eq!(targets("Box::_made", "instantiates"), ["Widget"]); + assert_eq!(targets("Box::_typed", "instantiates"), ["Widget"]); + assert_eq!(targets("Box::_typed", "calls"), ["Helper::Seed"]); + assert_eq!(targets("Box::_maybe", "instantiates"), ["Widget"]); + assert_eq!(targets("Box::_bag", "instantiates"), ["Bag"]); + assert_eq!(targets("Box::_crate", "instantiates"), ["Lib::Crate"]); + assert_eq!(targets("Box::Items", "instantiates"), ["Bag"]); + assert_eq!(targets("Box::Made", "instantiates"), ["Widget"]); + + // Static reads. + assert!(targets("Box::_label", "references").contains(&"Defaults".to_owned())); + assert!(targets("Box::Copy", "references").contains(&"Defaults".to_owned())); + + // Lambda bodies belong to the member the lambda initializes. + assert_eq!(targets("Box::_square", "calls"), ["Helper::Compute"]); + assert_eq!(targets("Box::_later", "calls"), ["Helper::Register"]); + + // A method passed as a value is the member's reference, captured + // once, not the class's as well. + assert!(targets("Box::_later", "references").contains(&"Box::Handle".to_owned())); + assert!(targets("Box::_direct", "references").contains(&"Box::Handle".to_owned())); + assert_eq!(targets("Box::Wrapped", "calls"), ["Helper::Wrap"]); + assert!(targets("Box::Wrapped", "references").contains(&"Box::Handle".to_owned())); + assert!(!targets("Box", "references").contains(&"Box::Handle".to_owned())); + + // Attribute arguments are not an initializer, and the class itself + // calls and creates nothing. + assert!(targets("Box::_flagged", "calls").is_empty()); + assert!(targets("Box", "calls").is_empty(), "{}", graph.report()); + assert!( + targets("Box", "instantiates").is_empty(), + "{}", + graph.report() + ); + } + + /// Upstream #2348 (`355afc70`, issue #2337): a C# type position never + /// names a constant, the kind `const` and `static readonly` fields get: + /// `new Version(5, 18)` bound to a claim-name `const string Version`, and + /// `static readonly Meter Meter = new(…)` instantiated itself. Upstream's + /// fixture. + #[test] + fn pr2348_a_csharp_type_position_never_names_a_constant() { + let project = Project::new() + .file( + "src/Claims.cs", + "namespace App;\n\ + public static class ClaimTypes {\n\ + \x20 public const string Version = \"v\";\n\ + }\n", + ) + .file( + "src/Encoder.cs", + "namespace App;\n\ + using System;\n\ + public class ValueFormatter { }\n\ + public class Encoder {\n\ + \x20 private static readonly Version MinVersion = new Version(5, 18);\n\ + \x20 private static readonly Meter Meter = new(\"app\");\n\ + \x20 private static readonly ValueFormatter ValueFormatter = new();\n\ + \x20 public void Check() { var v = new Version(1, 0); }\n\ + }\n", + ) + .index(); + let graph = project.graph(); + let mut targets: Vec = graph + .edges + .iter() + .filter(|edge| ["references", "instantiates", "type_of"].contains(&edge.kind.as_str())) + .filter_map(|edge| { + let source = graph.node(&edge.source)?; + let target = graph.node(&edge.target)?; + (source.file_path == "src/Encoder.cs" && source.kind != "file").then(|| { + format!( + "{} {} {}:{}", + source.name, edge.kind, target.kind, target.qualified_name + ) + }) + }) + .collect(); + targets.sort(); + assert!( + !targets.iter().any(|target| target.contains(" constant:")), + "{targets:?}" + ); + assert!( + targets.contains(&"ValueFormatter instantiates class:App::ValueFormatter".to_owned()), + "{targets:?}\n{}", + graph.report() + ); + } +} + +mod ruby { + use super::support::{Graph, Indexed, Project}; + + /// The qualified name of what every `calls` edge out of `file` reaches, + /// sorted (upstream's `callsFrom`). + fn call_targets(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream's #2147 fixture (`df761e45`). + fn project() -> Indexed { + Project::new() + .file( + "lib/app/ext/regexp.rb", + "module App\n\ + \x20 module Ext\n\ + \x20 module Expression\n\ + \x20 def loc\n\ + \x20 @loc\n\ + \x20 end\n\ + \x20 end\n\ + \x20 end\n\ + end\n", + ) + .file( + "lib/app/lsp/runtime.rb", + "module App\n\ + \x20 module LSP\n\ + \x20 class Runtime\n\ + \x20 def format(text)\n\ + \x20 text\n\ + \x20 end\n\ + \x20 end\n\ + \x20 end\n\ + end\n", + ) + .file( + "lib/app/web_push_request.rb", + "class WebPushRequest\n\ + \x20 def legacy_encrypt(payload)\n\ + \x20 payload\n\ + \x20 end\n\ + end\n", + ) + .file( + "lib/app/cop/base.rb", + "module App\n\ + \x20 module Cop\n\ + \x20 module RangeHelp\n\ + \x20 def range_between(a, b)\n\ + \x20 [a, b]\n\ + \x20 end\n\ + \x20 end\n\ + \n\ + \x20 class Base\n\ + \x20 def add_offense(node)\n\ + \x20 node\n\ + \x20 end\n\ + \x20 end\n\ + \x20 end\n\ + end\n", + ) + .file( + "lib/app/cop/style/foo.rb", + "module App\n\ + \x20 module Cop\n\ + \x20 module Style\n\ + \x20 class Foo < Base\n\ + \x20 include RangeHelp\n\ + \n\ + \x20 MSG = 'Use %s.'\n\ + \n\ + \x20 def on_send(node, web_push_request)\n\ + \x20 selector = node.loc\n\ + \x20 web_push_request.legacy_encrypt(selector)\n\ + \x20 add_offense(node)\n\ + \x20 range_between(1, 2)\n\ + \x20 format(MSG, x: 1)\n\ + \x20 end\n\ + \x20 end\n\ + \x20 end\n\ + \x20 end\n\ + end\n", + ) + .file( + "lib/app/greeting.rb", + "module Greeting\n\ + \x20 def greet\n\ + \x20 \"Hello, \" + display_name()\n\ + \x20 end\n\ + end\n", + ) + .file( + "lib/app/person.rb", + "class Person\n\ + \x20 include Greeting\n\ + \n\ + \x20 def display_name\n\ + \x20 'Ada'\n\ + \x20 end\n\ + end\n", + ) + .index() + } + + /// Upstream #2147: a bare Ruby call written inside a class is a call on + /// `self`, so it reaches only the class's own methods, its superclasses' + /// and the modules mixed into any of them (rubocop's `format(…)`, + /// Kernel's, is no LSP runtime's `format`); a call through an untyped + /// receiver takes the one method of its name only when the receiver is + /// named after its owner (`web_push_request.legacy_encrypt`, but not + /// `node.loc`). Upstream's fixture. + #[test] + fn pr2147_ruby_calls_by_name_alone_keep_the_ancestry_and_named_receivers() { + let project = project(); + assert_eq!( + call_targets(project.graph(), "lib/app/cop/style/foo.rb"), + [ + "App::Cop::Base::add_offense", + "App::Cop::RangeHelp::range_between", + "WebPushRequest::legacy_encrypt", + ], + "{}", + project.graph().report() + ); + } + + /// Upstream #2147's receiver gate is about the method's owner, not the + /// method: `user_name.name` shares `name` with `Person::name` itself + /// (enough for the receiver-word rule every language has here, #226) but + /// nothing with `Person`, so the one `name` is no guess; `person.name` is. + #[test] + fn pr2147_an_untyped_ruby_receiver_must_name_the_methods_owner() { + let project = Project::new() + .file( + "lib/person.rb", + "class Person\n def name\n 'Ada'\n end\nend\n", + ) + .file( + "lib/report.rb", + "class Report\n def run(user_name, person)\n user_name.name\n person.name\n end\nend\n", + ) + .index(); + let graph = project.graph(); + let calls: Vec<(i64, String)> = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == "lib/report.rb") + }) + .filter_map(|edge| { + graph + .node(&edge.target) + .map(|node| (edge.line.unwrap_or_default(), node.qualified_name.clone())) + }) + .collect(); + assert_eq!( + calls, + [(4, "Person::name".to_string())], + "{}", + graph.report() + ); + } + + /// Upstream #2147: a call in a module body is not judged, as a module's + /// methods run on whatever includes it. Upstream's fixture. + #[test] + fn pr2147_a_call_in_a_ruby_module_body_is_not_judged() { + let project = project(); + assert_eq!( + call_targets(project.graph(), "lib/app/greeting.rb"), + ["Person::display_name"], + "{}", + project.graph().report() + ); + } +} + +mod cfml { + use super::support::{Graph, Project}; + + /// A case: its files, as (path, contents), and the edges it must hold. + type Fixture<'a> = (&'a [(&'a str, &'a str)], &'a [&'a str]); + + /// The qualified name of what every `calls` edge out of `file` reaches, + /// sorted. + fn call_targets(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == file) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// `source-name kind target-qualified-name @target-file` of every edge of + /// `kinds`, sorted. + fn edges_of(graph: &Graph, kinds: &[&str]) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| kinds.contains(&edge.kind.as_str())) + .filter_map(|edge| { + let source = graph.node(&edge.source)?; + let target = graph.node(&edge.target)?; + Some(format!( + "{} {} {} @{}", + source.name, edge.kind, target.qualified_name, target.file_path + )) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2296 (issue #2091): calls written in ``, `` and + /// `` reach the methods they call; a `` wrapped in + /// `` (the Application.cfc shape) is indexed with + /// its calls; `` is an interface `implements` resolves to. + /// Upstream's fixture. + #[test] + fn pr2296_tag_based_cfml_calls_wrapped_functions_and_interfaces_are_indexed() { + let project = Project::new() + .file( + "model/Svc.cfc", + "\n\t\n\t\t\n\t\t\n\t\t\n\t\n\t\n\t\n\t\n\n", + ) + .file( + "Application.cfc", + "\n\t\n\t\t\n\t\t\t\n\t\t\n\t\t\n\t\n\n", + ) + .file( + "model/ISearchable.cfc", + "\n\t\n\n", + ) + .file( + "model/Catalog.cfc", + "\n\t\n\n", + ) + .index(); + let graph = project.graph(); + let calls = edges_of(graph, &["calls"]); + for expected in [ + "a calls Svc::b @model/Svc.cfc", + "a calls Svc::c @model/Svc.cfc", + "a calls Svc::d @model/Svc.cfc", + "onRequestStart calls Application::loadConfig @Application.cfc", + ] { + assert!( + calls.contains(&expected.to_string()), + "{calls:#?}\n{}", + graph.report() + ); + } + assert_eq!(calls.len(), 4, "{calls:#?}"); + let iface = graph + .nodes + .iter() + .find(|node| node.file_path == "model/ISearchable.cfc" && node.kind == "interface") + .expect("the interface is indexed"); + assert_eq!(iface.name, "ISearchable"); + assert_eq!( + edges_of(graph, &["implements"]), + ["Catalog implements model/ISearchable.cfc::ISearchable @model/ISearchable.cfc"] + ); + } + + /// Upstream #1152 / #1154: a CFML supertype written as a dotted component + /// path resolves to the class whose directories corroborate the path, + /// right to left and in any case, even when the mapping root is not in + /// the repository (the ColdBox shape); an uncorroborated namesake (an + /// out-of-repository mxunit supertype) and a corroboration tie are no + /// edge. Upstream's fixtures. + #[test] + fn pr1152_a_dotted_cfml_component_path_is_corroborated_by_its_directories() { + let cases: [Fixture; 5] = [ + ( + &[ + ( + "system/web/Controller.cfc", + "component {\n function handle() { return 1; }\n}\n", + ), + ( + "handlers/Main.cfc", + "component extends=\"coldbox.system.web.Controller\" {\n function index() { return 1; }\n}\n", + ), + ], + &["Main extends system/web/Controller.cfc::Controller @system/web/Controller.cfc"], + ), + ( + &[ + ("system/web/Controller.cfc", "component {}\n"), + ("other/Controller.cfc", "component {}\n"), + ( + "handlers/Main.cfc", + "component extends=\"coldbox.system.web.Controller\" {}\n", + ), + ], + &["Main extends system/web/Controller.cfc::Controller @system/web/Controller.cfc"], + ), + ( + &[ + ("system/web/Controller.cfc", "component {}\n"), + ( + "handlers/Main.cfc", + "component extends=\"COLDBOX.System.Web.Controller\" {}\n", + ), + ], + &["Main extends system/web/Controller.cfc::Controller @system/web/Controller.cfc"], + ), + ( + &[ + ("lib/TestCase.cfc", "component {}\n"), + ( + "tests/MyTest.cfc", + "component extends=\"mxunit.framework.TestCase\" {}\n", + ), + ], + &[], + ), + ( + &[ + ("a/models/User.cfc", "component {}\n"), + ("b/models/User.cfc", "component {}\n"), + ( + "handlers/Main.cfc", + "component extends=\"models.User\" {}\n", + ), + ], + &[], + ), + ]; + for (files, expected) in cases { + let mut project = Project::new(); + for (path, contents) in files { + project = project.file(path, contents); + } + let project = project.index(); + assert_eq!( + edges_of(project.graph(), &["extends", "implements"]), + expected, + "{}", + project.graph().report() + ); + } + } + + /// Upstream #1152 / #1154: a relative component path (`../base`, + /// `./app`, the FW/1 shape) resolves against the referencing file; a + /// dotted `implements` reaches an interface; the tag-based `extends` + /// attribute and a lowercase path (`framework.one`) resolve the same + /// way; and a dotted `calls` reference (a member chain) is never taken + /// for a component path. Upstream's fixtures. + #[test] + fn pr1152_relative_tag_based_and_implements_component_paths_resolve() { + let cases: [Fixture; 5] = [ + ( + &[ + ( + "examples/base.cfc", + "component {\n function shared() { return 1; }\n}\n", + ), + ("examples/sub/app.cfc", "component extends=\"../base\" {}\n"), + ( + "examples/sub/sibling.cfc", + "component extends=\"./app\" {}\n", + ), + ], + &[ + "app extends examples/base.cfc::base @examples/base.cfc", + "sibling extends examples/sub/app.cfc::app @examples/sub/app.cfc", + ], + ), + ( + &[ + ( + "app/interfaces/IService.cfc", + "interface {\n public string function getName();\n}\n", + ), + ( + "app/services/Greeter.cfc", + "component implements=\"app.interfaces.IService\" {\n public string function getName() { return \"hi\"; }\n}\n", + ), + ], + &[ + "Greeter implements app/interfaces/IService.cfc::IService @app/interfaces/IService.cfc", + ], + ), + ( + &[ + ("system/Base.cfc", "component {}\n"), + ( + "legacy/Old.cfc", + "\n\n\n", + ), + ], + &["Old extends system/Base.cfc::Base @system/Base.cfc"], + ), + ( + &[ + ( + "framework/one.cfc", + "component {\n function onRequest() { return 1; }\n}\n", + ), + ( + "Application.cfc", + "component extends=\"framework.one\" {}\n", + ), + ], + &["Application extends framework/one.cfc::one @framework/one.cfc"], + ), + ( + &[ + ("util/getName.cfc", "component {}\n"), + ( + "svc/Caller.cfc", + "component {\n function go() { return variables.dsn.getName(); }\n}\n", + ), + ], + &[], + ), + ]; + for (files, expected) in cases { + let mut project = Project::new(); + for (path, contents) in files { + project = project.file(path, contents); + } + let project = project.index(); + let graph = project.graph(); + let mut edges = edges_of(graph, &["extends", "implements"]); + edges.extend( + edges_of(graph, &["calls"]) + .into_iter() + .filter(|edge| edge.starts_with("go ") || edge.starts_with("Caller ")), + ); + assert_eq!(edges, expected, "{}", graph.report()); + } + } + + /// Upstream #1155: a CFML receiver is typed by its declaration (`new`, + /// a dotted path included; `createObject` in both forms; a typed cfscript + /// parameter; a `` read inside a `` body), and the + /// call goes to that component's method, never the same-named one of + /// another; a type that does not declare the method is no edge. The + /// methods are scoped under their component in all three extraction + /// paths. Upstream's fixtures. + #[test] + fn pr1155_cfml_receivers_are_typed_by_their_declarations() { + let user = "component {\n function save(any u) { return u; }\n}\n"; + let order = "component {\n function save(any o) { return o; }\n}\n"; + let project = Project::new() + .file("svc/UserService.cfc", user) + .file("svc/OrderService.cfc", order) + .file( + "tag/TagService.cfc", + "\n\n\n", + ) + .file( + "mod/ModuleConfig.cfc", + "\n\nfunction configure() { return 1; }\n\n\n", + ) + .file( + "handlers/Main.cfc", + "component {\n function bare() {\n var svc = new UserService();\n return svc.save(1);\n }\n function dotted() {\n var svc2 = new svc.UserService();\n return svc2.save(2);\n }\n}\n", + ) + .file( + "handlers/Legacy.cfc", + "component {\n function classic() {\n var svc = createObject(\"component\", \"svc.UserService\");\n return svc.save(1);\n }\n function modern() {\n var svc2 = CreateObject(\"svc.OrderService\");\n return svc2.save(2);\n }\n}\n", + ) + .file( + "handlers/Typed.cfc", + "component {\n function process(required UserService svc) {\n return svc.save(1);\n }\n}\n", + ) + .file( + "handlers/TagTyped.cfc", + "\n\n \n \n return svc.save(1);\n \n\n\n", + ) + .file( + "handlers/Wrong.cfc", + "component {\n function go() {\n var svc = new UserService();\n return svc.destroyEverything();\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let methods: Vec = graph + .nodes + .iter() + .filter(|node| node.kind == "method") + .map(|node| node.qualified_name.clone()) + .collect(); + for scoped in [ + "UserService::save", + "TagService::save", + "ModuleConfig::configure", + ] { + assert!(methods.contains(&scoped.to_string()), "{methods:?}"); + } + let calls = edges_of(graph, &["calls"]); + assert_eq!( + calls, + [ + "bare calls UserService::save @svc/UserService.cfc", + "classic calls UserService::save @svc/UserService.cfc", + "dotted calls UserService::save @svc/UserService.cfc", + "modern calls OrderService::save @svc/OrderService.cfc", + "process calls UserService::save @svc/UserService.cfc", + "tagged calls UserService::save @svc/UserService.cfc", + ], + "{}", + graph.report() + ); + } + + /// Upstream #1155: a component field reached through `variables.` is + /// typed by its pseudo-constructor assignment even below the call, or by + /// a WireBox-injected `property` (the ColdBox DI shape). Upstream's + /// fixtures. + #[test] + fn pr1155_cfml_component_fields_are_typed_from_anywhere_in_the_file() { + let user = "component {\n function save(any u) { return u; }\n}\n"; + let order = "component {\n function save(any o) { return o; }\n}\n"; + let project = Project::new() + .file("svc/UserService.cfc", user) + .file("svc/OrderService.cfc", order) + .file( + "handlers/Fielded.cfc", + "component {\n function handle() {\n return variables.svc.save(1);\n }\n function init() {\n variables.svc = new UserService();\n return this;\n }\n}\n", + ) + .file( + "handlers/Injected.cfc", + "component {\n property name=\"svc\" inject=\"UserService\";\n\n function handleInjected() {\n return variables.svc.save(1);\n }\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + edges_of(graph, &["calls"]) + .into_iter() + .filter(|edge| edge.contains("::save")) + .collect::>(), + [ + "handle calls UserService::save @svc/UserService.cfc", + "handleInjected calls UserService::save @svc/UserService.cfc", + ], + "{}", + graph.report() + ); + } + + /// W4-CF-02 (KEEP-RUST over upstream #1155, which takes the first): when + /// same-named components in several files declare the method, a typed + /// receiver's written path decides, by the directories its file + /// corroborates, and a bare type name by the call's own directory; no + /// corroboration, a tie, or namesakes of a supertype reached by name are + /// no edge. A lone component of the name is the type's, as upstream has + /// it. + #[test] + fn pr1155_same_named_cfml_components_are_told_apart_by_their_path() { + let save = "component {\n function save(any u) { return u; }\n}\n"; + let cases: [Fixture; 4] = [ + ( + &[ + ("a/UserService.cfc", save), + ("b/UserService.cfc", save), + ( + "handlers/Main.cfc", + "component {\n function dottedA() {\n var svc = new a.UserService();\n return svc.save(1);\n }\n function dottedB() {\n var svc = createObject(\"component\", \"b.UserService\");\n return svc.save(2);\n }\n function bare() {\n var svc = new UserService();\n return svc.save(3);\n }\n function uncorroborated() {\n var svc = new zz.UserService();\n return svc.save(4);\n }\n}\n", + ), + ( + "a/Caller.cfc", + "component {\n function beside() {\n var svc = new UserService();\n return svc.save(5);\n }\n}\n", + ), + ], + &[ + "beside calls UserService::save @a/UserService.cfc", + "dottedA calls UserService::save @a/UserService.cfc", + "dottedB calls UserService::save @b/UserService.cfc", + ], + ), + ( + &[ + ("p/a/UserService.cfc", save), + ("q/a/UserService.cfc", save), + ( + "handlers/Main.cfc", + "component {\n function tied() {\n var svc = new a.UserService();\n return svc.save(1);\n }\n function deeper() {\n var svc = new p.a.UserService();\n return svc.save(2);\n }\n}\n", + ), + ], + &["deeper calls UserService::save @p/a/UserService.cfc"], + ), + ( + &[ + ("a/Base.cfc", save), + ("b/Base.cfc", save), + ("b/Child.cfc", "component extends=\"b.Base\" {\n}\n"), + ( + "handlers/Main.cfc", + "component {\n function inherited() {\n var base = new Child();\n return base.save(1);\n }\n}\n", + ), + ], + &[], + ), + ( + &[ + ("svc/UserService.cfc", save), + ( + "handlers/Main.cfc", + "component {\n function lone() {\n var svc = new zz.UserService();\n return svc.save(1);\n }\n}\n", + ), + ], + &["lone calls UserService::save @svc/UserService.cfc"], + ), + ]; + for (files, expected) in cases { + let mut project = Project::new(); + for (path, contents) in files { + project = project.file(path, contents); + } + let project = project.index(); + assert_eq!( + edges_of(project.graph(), &["calls"]), + expected, + "{}", + project.graph().report() + ); + } + } + + /// Upstream #2148: a bare call in a component reaches only its own + /// methods and those of the components it `extends` (read from source, + /// the path matched by its longest suffix), so coldbox's `now()`, the + /// built-in, is no date helper's `now`; a call through an untyped + /// receiver takes the one method of its name only when the receiver is + /// named after its component (`server.keyExists` is the struct member + /// function). Upstream's fixture. + #[test] + fn pr2148_cfml_calls_by_name_alone_keep_the_extends_chain_and_named_receivers() { + let project = Project::new() + .file( + "system/core/DateTimeHelper.cfc", + "component {\n\ + \x20 function now() {\n\ + \x20 return createObject( \"java\", \"java.time.LocalDateTime\" ).now();\n\ + \x20 }\n\ + }\n", + ) + .file( + "system/core/InterceptorBuffer.cfc", + "component {\n\ + \x20 function keyExists( required key ) {\n\ + \x20 return false;\n\ + \x20 }\n\ + }\n", + ) + .file( + "system/Base.cfc", + "component {\n\ + \x20 function getSetting( required name ) {\n\ + \x20 return \"\";\n\ + \x20 }\n\ + }\n", + ) + .file( + "system/models/UserService.cfc", + "component {\n\ + \x20 function list() {\n\ + \x20 return [];\n\ + \x20 }\n\ + }\n", + ) + .file( + "system/Handler.cfc", + "component extends=\"app.system.Base\" {\n\ + \x20 function index( event, rc, prc, userService ) {\n\ + \x20 var stamp = now();\n\ + \x20 var lucee = server.keyExists( \"lucee\" );\n\ + \x20 var name = getSetting( \"appName\" );\n\ + \x20 var users = userService.list();\n\ + \x20 return stamp;\n\ + \x20 }\n\ + }\n", + ) + .index(); + assert_eq!( + call_targets(project.graph(), "system/Handler.cfc"), + ["Base::getSetting", "UserService::list"], + "{}", + project.graph().report() + ); + } + + /// Upstream #2148's receiver relaxations: the receiver's whole name + /// spelling its component (`cbsecurity` -> CBSecurity) or a word with a + /// trailing number dropped (`executor1` -> Executor) names the owner, so + /// the one method of that name is taken. + #[test] + fn pr2148_a_cfml_receiver_named_after_its_component_takes_its_method() { + let project = Project::new() + .file( + "system/security/CBSecurity.cfc", + "component {\n function secure( event ) {\n return true;\n }\n}\n", + ) + .file( + "system/Executor.cfc", + "component {\n function run() {\n return 1;\n }\n}\n", + ) + .file( + "system/Guard.cfc", + "component {\n function guard( cbsecurity, executor1, scheduler ) {\n cbsecurity.secure( \"x\" );\n executor1.run();\n scheduler.run();\n }\n}\n", + ) + .index(); + // `scheduler` names neither owner: the one `run` is no guess for it. + assert_eq!( + call_targets(project.graph(), "system/Guard.cfc"), + ["CBSecurity::secure", "Executor::run"], + "{}", + project.graph().report() + ); + } + + /// Upstream #2148: a dotted `extends` path picks, among the components of + /// its last segment's name, the one ending in the longest suffix of the + /// path, so `app.system.Base` is `system/Base.cfc`, not `other/Base.cfc`, + /// and the bare call reaches only that base's method. + #[test] + fn pr2148_a_dotted_cfml_extends_takes_the_longest_matching_suffix() { + let project = Project::new() + .file( + "system/Base.cfc", + "component {\n function getSetting( name ) {\n return 1;\n }\n}\n", + ) + .file( + "other/Base.cfc", + "component {\n function getSetting( name ) {\n return 2;\n }\n}\n", + ) + .file( + "app/Handler.cfc", + "component extends=\"app.system.Base\" {\n function index() {\n return getSetting( \"x\" );\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let calls: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == "calls") + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.file_path == "app/Handler.cfc") + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| format!("{}:{}", node.file_path, node.qualified_name)) + .collect(); + assert_eq!( + calls, + ["system/Base.cfc:Base::getSetting"], + "{}", + graph.report() + ); + } + + /// Upstream #2148: a bare call in a `.cfm` template is not judged (a + /// ColdBox view runs inside the renderer's scope), so the one project + /// function of its name is still taken. + #[test] + fn pr2148_a_bare_call_in_a_cfml_template_is_not_judged() { + let project = Project::new() + .file( + "system/Helpers.cfc", + "component {\n function formatStamp( value ) {\n return value;\n }\n}\n", + ) + .file( + "views/main.cfm", + "#formatStamp( now() )#\n\n formatStamp( 1 );\n\n", + ) + .index(); + let targets = call_targets(project.graph(), "views/main.cfm"); + assert!( + targets.contains(&"Helpers::formatStamp".to_string()), + "{targets:?}\n{}", + project.graph().report() + ); + } +} + +mod swift { + use super::support::{Graph, Project}; + + /// Every non-`contains` edge as `kind source -> target (file:line)`, + /// upstream's `edges()` shape, sorted. + fn edges(graph: &Graph) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind != "contains") + .filter_map(|edge| { + let source = graph.node(&edge.source)?; + let target = graph.node(&edge.target)?; + let file = target + .file_path + .rsplit('/') + .next() + .unwrap_or(&target.file_path); + Some(format!( + "{} {} -> {} ({file}:{})", + edge.kind, source.qualified_name, target.qualified_name, target.start_line + )) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2107: an extension is not the type it extends. A reference to + /// an SDK type the project only extends is no edge; one that reached a + /// type's extension moves to its declaration; a nested type is reached + /// bare only from its parent's scope; and a conforming type still reaches + /// the members the SDK type's extensions add. Upstream's fixture. + #[test] + fn pr2107_an_extension_is_not_the_type_it_extends() { + let project = Project::new() + .file( + "App/DesignSystem/Card.swift", + "import SwiftUI\nextension View {\n func cardStyle() -> some View { padding() }\n}\n", + ) + .file( + "App/DesignSystem/Text+Style.swift", + "import SwiftUI\nextension Text {\n func headline() -> Text { bold() }\n}\n", + ) + .file( + "App/Models/Account.swift", + "struct Account {\n let id: String\n}\n", + ) + .file( + "App/Other/Account+Display.swift", + "extension Account {\n var display: String { id }\n}\nstruct AccountRow {\n let account: Account\n}\n", + ) + .file( + "App/Home/HomeView.swift", + "import SwiftUI\nstruct HomeView: View {\n @State private var count = 0\n var last: Result?\n var body: some View {\n Text(\"Hello\").headline()\n }\n}\nfunc makeHome() -> some View {\n HomeView().cardStyle()\n}\nenum LoadError: Error {\n case offline\n}\n", + ) + .file( + "App/Home/ListViewModel.swift", + "public final class ListViewModel {\n public enum State { case loading, loaded }\n public struct Result { let ok: Bool }\n var state: State = .loading\n var latest: Result?\n}\n", + ) + .index(); + let all = edges(project.graph()); + let report = project.graph().report(); + // `struct HomeView: View`, `-> some View`, `Text("Hello")`: SwiftUI's, + // not the extension files'. + assert!( + !all.iter() + .any(|e| e.contains(" -> View (") || e.contains(" -> Text (")), + "{all:#?}\n{report}" + ); + // `AccountRow` sits in the extension's own file; proximity used to + // pick the extension. + assert_eq!( + all.iter() + .filter(|e| e.contains(" -> Account (")) + .collect::>(), + ["references AccountRow -> Account (Account.swift:1)"], + "{report}" + ); + // HomeView's `Result` is Swift's and its `@State` + // SwiftUI's; inside ListViewModel, `State` and `Result` are its own. + assert_eq!( + all.iter() + .filter( + |e| e.contains("ListViewModel::State") || e.contains("ListViewModel::Result") + ) + .collect::>(), + [ + "references ListViewModel -> ListViewModel::Result (ListViewModel.swift:3)", + "references ListViewModel -> ListViewModel::State (ListViewModel.swift:2)", + ], + "{report}" + ); + // `HomeView().cardStyle()`: `cardStyle` comes from `extension View`, + // and HomeView conforms to View. + for expected in [ + "calls makeHome -> View::cardStyle (Card.swift:3)", + "calls HomeView::body -> Text::headline (Text+Style.swift:3)", + ] { + assert!(all.contains(&expected.to_string()), "{all:#?}\n{report}"); + } + } + + /// Upstream #2107: a subclass names its superclass's nested type bare, a + /// generic qualifier is not part of a type's path, and inside `extension + /// Socket.Option` the bare `Option` is the type itself (Rust also keeps + /// the property's type annotation, W2-03c, so a `references` edge joins + /// upstream's `instantiates`). Upstream's fixture. + #[test] + fn pr2107_the_scopes_that_can_name_a_nested_type_bare() { + let project = Project::new() + .file( + "Sources/Core/Channel.swift", + "class BaseChannel {\n enum ReadResult { case some, none }\n}\nfinal class StreamChannel: BaseChannel {\n var last: ReadResult?\n}\n", + ) + .file( + "Sources/Core/Future.swift", + "struct Future {\n struct Isolated { let id: Int }\n}\nstruct Waiter {\n var isolated: Future.Isolated?\n}\n", + ) + .file( + "Sources/Core/Socket.swift", + "enum Socket {\n struct Option { let raw: Int }\n}\nextension Socket.Option {\n static let none: Option = Option(raw: 0)\n}\n", + ) + .index(); + let all = edges(project.graph()); + let report = project.graph().report(); + for expected in [ + "references StreamChannel -> BaseChannel::ReadResult (Channel.swift:2)", + "references Waiter -> Future::Isolated (Future.swift:2)", + ] { + assert!(all.contains(&expected.to_string()), "{all:#?}\n{report}"); + } + assert_eq!( + all.iter() + .filter(|e| e.contains(" -> Socket::Option ") + || e.ends_with("-> Option (Socket.swift:4)")) + .collect::>(), + [ + "instantiates Option -> Socket::Option (Socket.swift:2)", + "references Option -> Socket::Option (Socket.swift:2)", + ], + "{report}" + ); + } + + /// Upstream #2107: the index keeps `Build.Id` and `Package.Id` as refs + /// named `Id`; the line says which nested type each is. Upstream's + /// fixture; its qualified construction (`API.PackageController.Model(…)`) + /// is a type-path call here, W4-SW-02's. + #[test] + fn pr2107_a_qualified_type_name_lands_on_the_type_on_that_path() { + let project = Project::new() + .file( + "Sources/App/Models/Build.swift", + "import Foundation\nfinal class Build {\n typealias Id = UUID\n}\n", + ) + .file( + "Sources/App/Models/Package.swift", + "import Foundation\nfinal class Package {\n typealias Id = UUID\n}\n", + ) + .file( + "Sources/App/Commands/Trigger.swift", + "struct Trigger {\n let build: Build.Id\n let package: Package.Id\n}\n", + ) + .index(); + let all = edges(project.graph()); + assert_eq!( + all.iter() + .filter(|e| e.starts_with("references Trigger -> ")) + .collect::>(), + [ + "references Trigger -> Build (Build.swift:2)", + "references Trigger -> Build::Id (Build.swift:3)", + "references Trigger -> Package (Package.swift:2)", + "references Trigger -> Package::Id (Package.swift:3)", + ], + "{}", + project.graph().report() + ); + } + + /// Upstream #2118: a call through a type path lands on the type the path + /// names (declared nested, in `extension API.PackageController`, in + /// `extension A.B.C`, shortened inside its namespace, or module-qualified), + /// a nested type's initializer, a case with associated values or the case + /// a TCA `@Reducer enum` generates its `State` from, or nowhere. Upstream's + /// fixture. + #[test] + fn pr2118_a_call_through_a_type_path_lands_on_the_type_it_names() { + let project = Project::new() + .file( + "Sources/App/API.swift", + "enum API {}\nextension API {\n enum PackageController {\n enum GetRoute {\n static func query(on db: Int) -> Int { db }\n static func query(on db: Int, limit: Int) -> Int { db + limit }\n }\n struct Model { let name: String }\n }\n}\nextension API.PackageController {\n enum ShowRoute {\n static func query(on db: Int) -> Int { db }\n }\n}\nextension API.DependencyController.GetRoute {\n static func query(on db: Int) -> Int { db }\n}\nextension API {\n enum DependencyController {\n enum GetRoute {}\n static func inside() -> Int { PackageController.GetRoute.query(on: 0) }\n }\n}\n", + ) + .file( + "Sources/App/Other.swift", + "enum Other { static func query(on db: Int) -> Int { db } }\nenum Gitlab { enum Error: Swift.Error { case requestFailed(status: Int) } }\nenum Remote { enum Error: Swift.Error { case requestFailed(status: Int) } }\nstruct Detail {}\nenum Screens {\n @Reducer\n enum Path { case detail(Detail) }\n}\nenum Zed { enum GetRoute { static func query(on db: Int) -> Int { db } } }\nenum Yon { enum GetRoute { static func query(on db: Int) -> Int { db } } }\n", + ) + .file( + "Sources/App/Basics.swift", + "struct BasicsView {}\nstruct ObservableBasicsView {}\nextension BasicsView { struct Feature {} }\nextension ObservableBasicsView { struct Feature {} }\nextension BasicsView.Feature { struct State {} }\nextension ObservableBasicsView.Feature { struct State {} }\n", + ) + .file( + "Sources/App/Health.swift", + "import Foundation\nenum Health {\n static func one() -> Int { API.PackageController.GetRoute.query(on: 1) }\n static func two() -> Int {\n API.PackageController.GetRoute\n .query(on: 2)\n }\n static func three() -> Int { Other.query(on: 3) }\n static func four() -> Int { API.PackageController.ShowRoute.query(on: 4) }\n static func five() -> Int { API.DependencyController.GetRoute.query(on: 5) }\n static func six() -> API.PackageController.Model { API.PackageController.Model(name: \"x\") }\n static func seven() -> Int { App.Zed.GetRoute.query(on: 7) }\n static func eight() -> Int { Missing.Thing.query(on: 8) }\n static func nine() -> Int { API.PackageController.GetRoute.nothing(on: 9) }\n static func ten() throws { throw Gitlab.Error.requestFailed(status: 10) }\n static func eleven() { _ = Screens.Path.State.detail(Detail()) }\n static func twelve() { _ = ObservableBasicsView.Feature.State() }\n}\n", + ) + .index(); + let graph = project.graph(); + let reached = |caller: &str| -> Vec { + let from = graph + .nodes + .iter() + .find(|n| n.name == caller && n.kind == "method") + .unwrap_or_else(|| panic!("{caller}\n{}", graph.report())); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.source == from.id && (e.kind == "calls" || e.kind == "instantiates")) + .filter_map(|e| graph.node(&e.target)) + .filter(|n| n.language == "swift") + .map(|n| format!("{}:{}", n.qualified_name, n.start_line)) + .collect(); + out.sort(); + out + }; + let report = graph.report(); + // The first declared of GetRoute's two overloads. + assert_eq!( + reached("one"), + ["API::PackageController::GetRoute::query:5"], + "{report}" + ); + assert_eq!( + reached("two"), + ["API::PackageController::GetRoute::query:5"], + "{report}" + ); + assert_eq!(reached("three"), ["Other::query:1"], "{report}"); + // Declared in `extension API.PackageController { enum ShowRoute … }`. + assert_eq!( + reached("four"), + ["PackageController::ShowRoute::query:13"], + "{report}" + ); + // Declared in `extension API.DependencyController.GetRoute { … }`. + assert_eq!(reached("five"), ["GetRoute::query:17"], "{report}"); + // A nested type's initializer. + assert_eq!( + reached("six"), + ["API::PackageController::Model:8"], + "{report}" + ); + // A module qualifier. + assert_eq!(reached("seven"), ["Zed::GetRoute::query:9"], "{report}"); + // No such type, and no such member: nothing, not some other type's + // `query`. + assert!(reached("eight").is_empty(), "{report}"); + assert!(reached("nine").is_empty(), "{report}"); + // A case with associated values. + assert_eq!( + reached("ten"), + ["Gitlab::Error::requestFailed:2"], + "{report}" + ); + // The case a TCA `@Reducer enum` generates its State from. + assert!( + reached("eleven").contains(&"Screens::Path::detail:7".to_string()), + "{report}" + ); + // Two types share the qualified name `Feature::State`; the extension + // each is declared in tells them apart. + assert_eq!(reached("twelve"), ["Feature::State:6"], "{report}"); + // Shortened inside its namespace. + assert_eq!( + reached("inside"), + ["API::PackageController::GetRoute::query:5"], + "{report}" + ); + } + + /// The calls out of `file`, as their targets' qualified names, sorted. + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2170: a bare, `self.` or `super.` Swift call reaches the + /// nearest member of the types around it and what they inherit (UIKit + /// ancestry included), `super.` the inherited only; down a longer chain + /// a standard-library name needs the receiver to name the owner or a + /// label only the project's method takes; a playground's global is no + /// call target. Upstream's fixture. + #[test] + fn pr2170_a_swift_call_reached_by_name_follows_how_it_is_written() { + let project = Project::new() + .file( + "Sources/Charts/Utils/Sequence+KeyPath.swift", + "extension Sequence {\n func max(by keyPath: KeyPath) -> Element? {\n return nil\n }\n}\n", + ) + .file( + "Sources/Charts/Renderers/Renderer.swift", + "open class Renderer {\n public struct XBounds {\n public var min: Int = 0\n }\n}\n", + ) + .file( + "Sources/Charts/Data/ChartData.swift", + "open class ChartData {\n open func removeAll() { }\n}\n", + ) + .file( + "Sources/Charts/Formatters/Formatter.swift", + "open class Formatter {\n open func reset() { }\n}\n", + ) + .file( + "Sources/Charts/Charts/ChartViewBase.swift", + "open class ChartViewBase: NSObject {\n open func highlightValue(_ h: Int) { }\n open func reset() { }\n}\n", + ) + .file( + "Sources/Charts/Charts/BarChartView.swift", + "open class BarChartView: ChartViewBase {\n open override func highlightValue(_ h: Int) { }\n}\n", + ) + .file( + "Sources/Charts/Charts/BarLineChartViewBase.swift", + "open class BarLineChartViewBase: ChartViewBase {\n open func tapped(axis: Axis) {\n highlightValue(1)\n let widest = max(1, 2)\n let narrowest = min(3, 4)\n super.reset()\n axis.entries.removeAll()\n }\n}\n", + ) + .file( + "Sources/Charts/Wrapper/KingfisherWrapper.swift", + "public struct KingfisherWrapper {\n public let base: Base\n}\n\nextension KingfisherWrapper where Base == Data {\n func contains(jpeg marker: Int) -> Bool {\n return false\n }\n}\n", + ) + .file( + "Sources/Charts/Wrapper/Decoder.swift", + "final class Decoder {\n func scan(data: Data) -> Bool {\n return data.kf.contains(jpeg: 0xC2)\n }\n}\n", + ) + .file( + "Demo/Extensions/UIViewController+Bar.swift", + "extension UIViewController {\n func setupOperationBar() { }\n}\n", + ) + .file( + "Demo/Controllers/ListController.swift", + "class ListController: UICollectionViewController {\n override func viewDidLoad() {\n setupOperationBar()\n }\n}\n", + ) + .file("Demo/Chart.playground/Contents.swift", "let min = 20.0\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + calls_from(graph, "Sources/Charts/Charts/BarLineChartViewBase.swift"), + ["ChartViewBase::highlightValue", "ChartViewBase::reset"], + "{report}" + ); + assert_eq!( + calls_from(graph, "Sources/Charts/Wrapper/Decoder.swift"), + ["KingfisherWrapper::contains"], + "{report}" + ); + assert_eq!( + calls_from(graph, "Demo/Controllers/ListController.swift"), + ["UIViewController::setupOperationBar"], + "{report}" + ); + } + + /// Upstream #2205: a call on a property the type around it declares with + /// a type reaches that type's method, not a subclass override elsewhere + /// (upstream's fixture), even when the property's name shares no word + /// with its type, and a function's own `let` shadows the property. + #[test] + fn pr2205_a_call_on_a_typed_swift_property_is_a_member_of_its_type() { + let project = Project::new() + .file( + "Sources/Cache/ImageCache.swift", + "open class ImageCache {\n open func imageCachedType(forKey key: String) -> Int {\n return 0\n }\n}\n", + ) + .file( + "Sources/Cache/Disk.swift", + "open class DiskBackend {\n open func imageCachedType(forKey key: String) -> Int {\n return 2\n }\n}\n", + ) + .file( + "Tests/KingfisherTests/ImagePrefetcherTests.swift", + "class EvictingPrefetchCache: ImageCache {\n override func imageCachedType(forKey key: String) -> Int {\n return 1\n }\n}\n", + ) + .file( + "Tests/KingfisherTests/ImageCacheTests.swift", + "class ImageCacheTests {\n var cache: ImageCache!\n\n func testCachedType() {\n _ = cache.imageCachedType(forKey: \"a\")\n _ = self.cache.imageCachedType(forKey: \"b\")\n }\n}\n", + ) + .file( + "Tests/KingfisherTests/StoreTests.swift", + "class StoreTests {\n var store: ImageCache!\n\n func testCachedType() {\n _ = store.imageCachedType(forKey: \"a\")\n _ = self.store.imageCachedType(forKey: \"b\")\n }\n func testShadowed() {\n let store = DiskBackend()\n _ = store.imageCachedType(forKey: \"c\")\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + calls_from(graph, "Tests/KingfisherTests/ImageCacheTests.swift"), + ["ImageCache::imageCachedType", "ImageCache::imageCachedType"], + "{report}" + ); + assert_eq!( + calls_from(graph, "Tests/KingfisherTests/StoreTests.swift"), + [ + "DiskBackend::imageCachedType", + "ImageCache::imageCachedType", + "ImageCache::imageCachedType", + ], + "{report}" + ); + } +} + +mod objc { + use super::support::{Graph, Project}; + + /// The calls out of `file`, as their targets' qualified names, sorted. + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2150: a message to `self` or `super` (the extractor drops + /// the receiver) reaches a method of the sender's hierarchy, never a + /// proxy class's `class`; C call syntax never names a method or + /// property; a message to an untyped local needs its declaration + /// (`FMResultSet *rs`) or a receiver named after the class. Upstream's + /// fixture. + #[test] + fn pr2150_an_objc_message_reached_by_name_alone_must_be_in_reach() { + let project = Project::new() + .file( + "Core/SDWeakProxy.h", + "#import \n@interface SDWeakProxy : NSProxy\n- (Class)class;\n- (BOOL)respondsToSelector:(SEL)aSelector;\n@end\n", + ) + .file( + "Core/SDWeakProxy.m", + "#import \"SDWeakProxy.h\"\n@implementation SDWeakProxy\n- (Class)class {\n return nil;\n}\n- (BOOL)respondsToSelector:(SEL)aSelector {\n return NO;\n}\n@end\n", + ) + .file( + "Core/SDPrefetchToken.h", + "#import \n@interface SDPrefetchToken : NSObject\n@property (nonatomic, copy) void (^completionBlock)(void);\n@end\n", + ) + .file( + "Core/FMResultSet.h", + "#import \n@interface FMResultSet : NSObject\n- (BOOL)next;\n@end\n", + ) + .file( + "Core/FMResultSet.m", + "#import \"FMResultSet.h\"\n@implementation FMResultSet\n- (BOOL)next {\n return NO;\n}\n@end\n", + ) + .file( + "Core/SDBase.h", + "#import \n@interface SDBase : NSObject\n- (void)prepare;\n@end\n", + ) + .file( + "Core/SDBase.m", + "#import \"SDBase.h\"\n@implementation SDBase\n- (void)prepare {\n}\n@end\n", + ) + .file( + "Core/SDCache.h", + "#import \"SDBase.h\"\n@interface SDCache : SDBase\n- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image;\n@end\n", + ) + .file( + "Core/SDCache.m", + "#import \"SDCache.h\"\n#import \"FMResultSet.h\"\n@implementation SDCache\n- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image {\n Class cls = [self class];\n [super prepare];\n [self helper];\n FMResultSet *rs = [self query];\n while ([rs next]) {\n }\n if ([image respondsToSelector:@selector(copy)]) {\n }\n completionBlock();\n}\n- (void)helper {\n}\n- (FMResultSet *)query {\n return nil;\n}\n@end\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let method = graph + .nodes + .iter() + .find(|n| n.qualified_name == "SDCache::storeWithCompletion:image:") + .unwrap_or_else(|| panic!("{report}")); + let mut targets: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls" && e.source == method.id) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect(); + targets.sort(); + assert_eq!( + targets, + [ + "FMResultSet::next", + "SDBase::prepare", + "SDCache::helper", + "SDCache::query" + ], + "{report}" + ); + } + + /// Upstream #2150: an untyped receiver keeps the one method of its name + /// when it ends in a property whose declared type has the method's + /// owner (`managed.anyDataObj`, an `RLMSet *`), and not when it names + /// nothing the owner is (`image`). A class receiver reaches the class + /// method it inherits. + #[test] + fn pr2150_a_class_or_typed_property_receiver_keeps_its_inherited_method() { + let project = Project::new() + .file( + "RLMObject.h", + "#import \n@interface RLMObject : NSObject\n+ (id)objectsInRealm:(id)realm;\n@end\n", + ) + .file( + "RLMObject.m", + "#import \"RLMObject.h\"\n@implementation RLMObject\n+ (id)objectsInRealm:(id)realm {\n return nil;\n}\n@end\n", + ) + .file( + "RLMSet.h", + "#import \n@interface RLMSet : NSObject\n- (BOOL)containsObject:(id)object;\n@end\n", + ) + .file( + "RLMSet.m", + "#import \"RLMSet.h\"\n@implementation RLMSet\n- (BOOL)containsObject:(id)object {\n return NO;\n}\n@end\n", + ) + .file( + "AllTypesObject.h", + "#import \"RLMObject.h\"\n#import \"RLMSet.h\"\n@interface AllTypesObject : RLMObject\n@property (nonatomic) RLMSet *anyDataObj;\n@end\n", + ) + .file( + "Tests.m", + "#import \"AllTypesObject.h\"\n@interface Tests : NSObject\n@end\n@implementation Tests\n- (void)run:(AllTypesObject *)managed image:(id)image {\n [AllTypesObject objectsInRealm:nil];\n [managed.anyDataObj containsObject:@\"x\"];\n [image containsObject:@\"y\"];\n}\n@end\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_from(graph, "Tests.m"), + ["RLMObject::objectsInRealm:", "RLMSet::containsObject:"], + "{}", + graph.report() + ); + } + + /// Upstream #2150: a method parameter's declaration + /// (`(nullable FMResultSet *)rs`) types the messages to it, and a + /// function the parameter's name shadows (`rs(void)`) types nothing. + #[test] + fn pr2150_a_typed_parameter_types_its_messages() { + let project = Project::new() + .file( + "FMResultSet.m", + "@interface FMResultSet : NSObject\n@end\n@implementation FMResultSet\n- (BOOL)next {\n return NO;\n}\n@end\n", + ) + .file( + "FMCursor.m", + "@interface FMCursor : NSObject\n@end\n@implementation FMCursor\n- (BOOL)next {\n return NO;\n}\n@end\n", + ) + .file( + "Reader.m", + "@interface Reader : NSObject\n@end\n@implementation Reader\n- (void)read:(nullable FMResultSet *)rs {\n [rs next];\n}\n- (void)scan {\n FMCursor *rs(void);\n [rs next];\n}\n@end\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let calls_of = |method: &str| -> Vec { + let id = &graph + .nodes + .iter() + .find(|n| n.qualified_name == method) + .unwrap_or_else(|| panic!("{report}")) + .id; + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls" && &e.source == id) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect(); + out.sort(); + out + }; + assert_eq!(calls_of("Reader::read:"), ["FMResultSet::next"], "{report}"); + assert!(calls_of("Reader::scan").is_empty(), "{report}"); + } + + /// Upstream #2150's system table: a category on UIImageView sending + /// `[self sd_internalSetImageWithURL:]` reaches the UIView category's + /// method, UIView being UIImageView's superclass, and not another + /// class's method of that name. + #[test] + fn pr2150_a_category_on_a_system_class_reaches_its_superclass_category() { + let project = Project::new() + .file( + "UIView+WebCache.m", + "#import \n@implementation UIView (WebCache)\n- (void)sd_internalSetImageWithURL:(NSURL *)url {\n}\n@end\n", + ) + .file( + "SDDecoy.m", + "#import \n@interface SDDecoy : NSObject\n@end\n@implementation SDDecoy\n- (void)sd_internalSetImageWithURL:(NSURL *)url {\n}\n@end\n", + ) + .file( + "UIImageView+WebCache.m", + "#import \n@implementation UIImageView (WebCache)\n- (void)sd_setImageWithURL:(NSURL *)url {\n [self sd_internalSetImageWithURL:url];\n}\n@end\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_from(graph, "UIImageView+WebCache.m"), + ["UIView::sd_internalSetImageWithURL:"], + "{}", + graph.report() + ); + } + + /// Upstream #2179: `[super init]` goes past the class it is written in, + /// to the superclass's `init`, or for an NSObject subclass to no project + /// method at all. Upstream's fixture. + #[test] + fn pr2179_a_message_to_super_reaches_the_superclass() { + let project = Project::new() + .file( + "Base.h", + "#import \n@interface Base : NSObject\n- (instancetype)init;\n@end\n", + ) + .file( + "Base.m", + "#import \"Base.h\"\n@implementation Base\n- (instancetype)init {\n self = [super init];\n return self;\n}\n@end\n", + ) + .file("Child.h", "#import \"Base.h\"\n@interface Child : Base\n@end\n") + .file( + "Child.m", + "#import \"Child.h\"\n@implementation Child\n- (instancetype)init {\n self = [super init];\n return self;\n}\n@end\n", + ) + .file( + "Other.m", + "#import \n@interface Other : NSObject\n@end\n@implementation Other\n- (instancetype)init {\n self = [super init];\n return self;\n}\n@end\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!(calls_from(graph, "Child.m"), ["Base::init"], "{report}"); + assert!(calls_from(graph, "Base.m").is_empty(), "{report}"); + assert!(calls_from(graph, "Other.m").is_empty(), "{report}"); + } + + /// Upstream #2242: a class conforming to a protocol of its own name + /// (SDWebImage's `@interface SDDiskCache : NSObject `, + /// its header copied into `include/`) implements the protocol in its + /// own header. Upstream's fixture. + #[test] + fn pr2242_a_class_conforming_to_its_namesake_protocol_implements_it() { + let header = "#import \n\n@protocol SDDiskCache \n- (nullable NSData *)dataForKey:(nonnull NSString *)key;\n@end\n\n@interface SDDiskCache : NSObject \n@end\n"; + let project = Project::new() + .file("SDWebImage/Core/SDDiskCache.h", header) + .file("SDWebImage/include/SDWebImage/SDDiskCache.h", header) + .file( + "Examples/SwiftUIDemo/ContentView.swift", + "import SwiftUI\n\nstruct ContentView: View {\n var body: some View { Text(\"x\") }\n}\n", + ) + .index(); + let graph = project.graph(); + let class = graph + .nodes + .iter() + .find(|n| n.kind == "class" && n.file_path == "SDWebImage/Core/SDDiskCache.h") + .unwrap_or_else(|| panic!("{}", graph.report())); + let targets: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "implements" && e.source == class.id) + .filter_map(|e| graph.node(&e.target)) + .map(|n| format!("{} {}", n.kind, n.file_path)) + .collect(); + assert_eq!( + targets, + ["protocol SDWebImage/Core/SDDiskCache.h"], + "{}", + graph.report() + ); + } +} + +mod go { + use super::support::{Graph, NodeRow, Project, codegraph}; + + /// `file::qualifiedName` of every `kind` target of the symbol `name` in + /// `file`, sorted (upstream's `targets` / `callTargets`). + fn targets(graph: &Graph, file: &str, name: &str, kind: &str) -> Vec { + let source = graph + .nodes + .iter() + .find(|n| n.file_path == file && n.name == name) + .unwrap_or_else(|| panic!("{name} in {file}\n{}", graph.report())); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == kind && e.source == source.id) + .filter_map(|e| graph.node(&e.target)) + .map(|n| format!("{}::{}", n.file_path, n.qualified_name)) + .collect(); + out.sort(); + out + } + + /// `kind name` of every node of `file` but the file and its imports, in + /// source order (upstream's `declarations`). + fn declarations(graph: &Graph, file: &str) -> Vec { + let mut nodes: Vec<_> = graph + .nodes + .iter() + .filter(|n| n.file_path == file && n.kind != "file" && n.kind != "import") + .collect(); + nodes.sort_by_key(|n| n.start_line); + nodes + .iter() + .map(|n| format!("{} {}", n.kind, n.name)) + .collect() + } + + /// `source name` of every `references` reference out of a type + /// declaration of `file`, resolved or not, sorted (upstream's `typeRefs`). + fn type_refs(graph: &Graph, file: &str) -> Vec { + let is_type = |kind: &str| matches!(kind, "type_alias" | "struct" | "interface"); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "references") + .filter_map(|e| Some((graph.node(&e.source)?, graph.node(&e.target)?))) + .filter(|(source, _)| source.file_path == file && is_type(&source.kind)) + .map(|(source, target)| format!("{} {}", source.name, target.name)) + .chain( + graph + .unresolved + .iter() + .filter(|r| r.reference_kind == "references") + .filter_map(|r| Some((graph.node(&r.from_node_id)?, r))) + .filter(|(source, _)| source.file_path == file && is_type(&source.kind)) + .map(|(source, r)| format!("{} {}", source.name, r.reference_name)), + ) + .collect(); + out.sort(); + out + } + + fn docstring_of(graph: &Graph, file: &str, kind: &str, name: &str) -> Option { + let found: Vec<_> = graph + .nodes + .iter() + .filter(|n| n.file_path == file && n.kind == kind && n.name == name) + .collect(); + assert_eq!(found.len(), 1, "{kind} {name}\n{}", graph.report()); + found[0].docstring.clone() + } + + const ALIAS_SOURCE: &str = "package clientv3\n\nimport (\n\t\"context\"\n\n\t\"go.etcd.io/etcd/api/v3/mvccpb\"\n)\n\n// Event is the event a watcher reports.\ntype Event = mvccpb.Event\n\ntype WatchChan <-chan WatchResponse\n\ntype WatchResponse struct {\n\tEvents []*Event\n}\n\ntype (\n\t// Local names a type of this package.\n\tLocal = WatchResponse\n\tPtr = *WatchResponse\n\tSlice = []WatchResponse\n\tHandler = func(ctx context.Context, r WatchResponse) error\n\tIndex = map[string]*mvccpb.KeyValue\n\tDefined WatchResponse\n)\n\ntype Plain = List[WatchResponse]\n\ntype Anon = struct {\n\tKey string\n}\n\ntype Closer = interface {\n\tClose() error\n}\n\ntype Type = string\n\ntype Word = uint\n"; + + const STORE: &str = "package store\n\ntype Manager struct{}\n\nfunc New() *Manager { return &Manager{} }\n\nfunc (m *Manager) CreateItem(name string) error { return nil }\n"; + const CLOCK: &str = "package clock\n\nfunc Now() int { return 0 }\n"; + + fn service(module: &str) -> String { + format!( + "package domain\n\nimport (\n\t\"{module}/internal/store\"\n\t\"{module}/pkg/clock\"\n)\n\ntype Service struct {{\n\tdb *store.Manager\n}}\n\nfunc NewService() *Service {{\n\treturn &Service{{db: store.New()}}\n}}\n\nfunc (s *Service) AddItem(name string) error {{\n\treturn s.db.CreateItem(name)\n}}\n\nfunc Stamp() int {{\n\treturn clock.Now()\n}}\n" + ) + } + + /// Upstream #2323 (via #2361): a call through a method receiver of an + /// unexported type, through a field of one, and through a type it + /// embeds, all within the receiver's own package; another package's + /// same-named types are never targets. Upstream's fixture. + #[test] + fn pr2361_calls_through_an_unexported_receiver_stay_in_its_package() { + let project = Project::new() + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "internal/domain/service.go", + "package domain\n\ntype Service struct{}\n\nfunc (s *Service) AddItem(name string) error { return nil }\n\nfunc (s *Service) Count() int { return 0 }\n", + ) + .file( + "internal/handlers/server.go", + "package handlers\n\nimport \"example.com/app/internal/domain\"\n\ntype server struct {\n\tBaseAPI\n\tservice *domain.Service\n\tstore *store\n}\n\nfunc (s *server) Create(name string) error {\n\ts.store.Put(name)\n\ts.Close()\n\ts.SendError()\n\treturn s.service.AddItem(name)\n}\n\ntype cache[T any] struct {\n\tservice *domain.Service\n}\n\nfunc (c *cache[T]) Size() int {\n\treturn c.service.Count()\n}\n", + ) + .file( + "internal/handlers/store.go", + "package handlers\n\ntype store struct{}\n\nfunc (st *store) Put(name string) {}\n\nfunc (s *server) Close() {}\n", + ) + .file( + "internal/handlers/base.go", + "package handlers\n\ntype BaseAPI struct{}\n\nfunc (b *BaseAPI) SendError() {}\n", + ) + .file( + "internal/admin/server.go", + "package admin\n\ntype server struct {\n\tBaseAPI\n\tstore *store\n}\n\ntype store struct{}\n\nfunc (st *store) Put(name string) {}\n\nfunc (s *server) Close() {}\n\ntype BaseAPI struct{}\n\nfunc (b *BaseAPI) SendError() {}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + targets(graph, "internal/handlers/server.go", "Create", "calls"), + [ + "internal/domain/service.go::Service::AddItem", + "internal/handlers/base.go::BaseAPI::SendError", + "internal/handlers/store.go::server::Close", + "internal/handlers/store.go::store::Put", + ], + "{report}" + ); + assert_eq!( + targets(graph, "internal/handlers/server.go", "Size", "calls"), + ["internal/domain/service.go::Service::Count"], + "{report}" + ); + let admin: Vec<&str> = graph + .nodes + .iter() + .filter(|n| n.file_path == "internal/admin/server.go" && n.kind == "method") + .map(|n| n.id.as_str()) + .collect(); + assert!( + !graph + .edges + .iter() + .any(|e| e.kind == "calls" && admin.contains(&e.target.as_str())), + "{report}" + ); + } + + /// Upstream #2322 (via #2361): a module in a subdirectory resolves its + /// package-qualified calls, and a call through a struct field typed with + /// one of its packages. Upstream's fixture. + #[test] + fn pr2361_a_go_module_in_a_subdirectory() { + let project = Project::new() + .file("svc/go.mod", "module example.com/app/svc\n\ngo 1.22\n") + .file("svc/internal/store/store.go", STORE) + .file("svc/pkg/clock/clock.go", CLOCK) + .file( + "svc/internal/domain/service.go", + &service("example.com/app/svc"), + ) + .file("web/package.json", "{ \"name\": \"web\" }\n") + .index(); + let graph = project.graph(); + let file = "svc/internal/domain/service.go"; + assert_eq!( + targets(graph, file, "NewService", "calls"), + ["svc/internal/store/store.go::New"] + ); + assert_eq!( + targets(graph, file, "Stamp", "calls"), + ["svc/pkg/clock/clock.go::Now"] + ); + assert_eq!( + targets(graph, file, "AddItem", "calls"), + ["svc/internal/store/store.go::Manager::CreateItem"] + ); + } + + /// Upstream #2322: modules side by side, etcd's shape. Upstream's + /// fixture. + #[test] + fn pr2361_go_modules_side_by_side() { + let project = Project::new() + .file("go.mod", "module example.com/etcd/v3\n\ngo 1.22\n") + .file( + "etcdutl/main.go", + "package main\n\nimport \"example.com/etcd/server/v3/storage/wal\"\n\nfunc main() {\n\twal.OpenForRead(\"dir\")\n}\n", + ) + .file("server/go.mod", "module example.com/etcd/server/v3\n\ngo 1.22\n") + .file( + "server/storage/wal/wal.go", + "package wal\n\nfunc OpenForRead(dir string) error { return nil }\n", + ) + .file("server/api/api.go", "package api\n\nfunc Start() int { return 1 }\n") + .file( + "server/cmd/main.go", + "package main\n\nimport (\n\t\"example.com/etcd/server/v3/api\"\n\t\"example.com/tools/lint\"\n)\n\ntype App struct {\n\tlinter *lint.Linter\n}\n\nfunc main() {\n\tapi.Start()\n\tlint.Run()\n}\n\nfunc (a *App) check() {\n\ta.linter.Check()\n}\n", + ) + .file( + "server/cmd/ext.go", + "package main\n\nimport (\n\t\"example.com/toolsx/api\"\n)\n\nfunc external() {\n\tapi.Start()\n}\n", + ) + .file("tools/go.mod", "module example.com/tools\n\ngo 1.22\n") + .file("tools/api/api.go", "package api\n\nfunc Start() int { return 2 }\n") + .file( + "tools/lint/lint.go", + "package lint\n\ntype Linter struct{}\n\nfunc Run() {}\n\nfunc (l *Linter) Check() {}\n", + ) + .file( + "tools/gen/gen.go", + "package main\n\nimport \"example.com/tools/api\"\n\nfunc generate() {\n\tapi.Start()\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + targets(graph, "etcdutl/main.go", "main", "calls"), + ["server/storage/wal/wal.go::OpenForRead"] + ); + assert_eq!( + targets(graph, "tools/gen/gen.go", "generate", "calls"), + ["tools/api/api.go::Start"] + ); + assert_eq!( + targets(graph, "server/cmd/main.go", "main", "calls"), + ["server/api/api.go::Start", "tools/lint/lint.go::Run"] + ); + assert_eq!( + targets(graph, "server/cmd/main.go", "check", "calls"), + ["tools/lint/lint.go::Linter::Check"] + ); + assert!(targets(graph, "server/cmd/ext.go", "external", "calls").is_empty()); + } + + /// Upstream #2322: a name written through a package of a nested module + /// is that package's; a module under testdata never answers for the + /// module it imitates. Upstream's fixture. + #[test] + fn pr2361_names_written_through_a_package_of_a_nested_module() { + let project = Project::new() + .file("src/go.mod", "module example.com/harbor/src\n\ngo 1.22\n") + .file("src/jobservice/job/op.go", "package job\n\ntype OPCommand string\n") + .file( + "src/jobservice/impl/context.go", + "package impl\n\nimport \"example.com/harbor/src/jobservice/job\"\n\ntype Context struct{}\n\nfunc (c *Context) OPCommand() (job.OPCommand, bool) {\n\treturn \"\", false\n}\n", + ) + .file( + "src/pkg/artifact/manager.go", + "package artifact\n\ntype Manager interface {\n\tCount() int64\n}\n", + ) + .file( + "src/pkg/cached/redis/manager.go", + "package redis\n\nimport \"example.com/harbor/src/pkg/artifact\"\n\ntype Manager struct {\n\tdelegator artifact.Manager\n}\n\nfunc (m *Manager) Count() int64 {\n\treturn m.delegator.Count()\n}\n", + ) + .file( + "internal/testdata/stub/go.mod", + "module example.com/harbor/src\n\ngo 1.22\n", + ) + .file( + "internal/testdata/stub/pkg/artifact/manager.go", + "package artifact\n\ntype Manager interface {\n\tCount() int64\n}\n", + ) + .file("tools/go.mod", "module example.com/harbor/tools\n\ngo 1.22\n") + .file( + "tools/report/report.go", + "package report\n\nimport \"example.com/harbor/src/pkg/artifact\"\n\ntype Reporter struct {\n\tmgr artifact.Manager\n}\n\nfunc (r *Reporter) Total() int64 {\n\treturn r.mgr.Count()\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + targets( + graph, + "src/jobservice/impl/context.go", + "OPCommand", + "references" + ), + ["src/jobservice/job/op.go::OPCommand"] + ); + assert_eq!( + targets(graph, "src/pkg/cached/redis/manager.go", "Count", "calls"), + ["src/pkg/artifact/manager.go::Manager::Count"] + ); + assert_eq!( + targets(graph, "tools/report/report.go", "Total", "calls"), + ["src/pkg/artifact/manager.go::Manager::Count"] + ); + } + + /// Upstream #2322: two copies declaring one module path resolve each + /// one's imports into its own copy. Upstream's fixture. + #[test] + fn pr2361_two_go_modules_declaring_the_same_path() { + let mut project = Project::new(); + for copy in ["v1", "v2"] { + project = project + .file( + &format!("{copy}/go.mod"), + "module example.com/app/svc\n\ngo 1.22\n", + ) + .file(&format!("{copy}/internal/store/store.go"), STORE) + .file(&format!("{copy}/pkg/clock/clock.go"), CLOCK) + .file( + &format!("{copy}/internal/domain/service.go"), + &service("example.com/app/svc"), + ); + } + let project = project.index(); + let graph = project.graph(); + for copy in ["v1", "v2"] { + let file = format!("{copy}/internal/domain/service.go"); + assert_eq!( + targets(graph, &file, "NewService", "calls"), + [format!("{copy}/internal/store/store.go::New")] + ); + assert_eq!( + targets(graph, &file, "Stamp", "calls"), + [format!("{copy}/pkg/clock/clock.go::Now")] + ); + } + } + + /// Upstream #2322: a root-level go.mod resolves as before, the field + /// chain included. Upstream's fixture. + #[test] + fn pr2361_a_go_module_at_the_project_root() { + let project = Project::new() + .file("go.mod", "module example.com/app/svc\n\ngo 1.22\n") + .file("internal/store/store.go", STORE) + .file("pkg/clock/clock.go", CLOCK) + .file( + "internal/domain/service.go", + &service("example.com/app/svc"), + ) + .index(); + let graph = project.graph(); + let file = "internal/domain/service.go"; + assert_eq!( + targets(graph, file, "NewService", "calls"), + ["internal/store/store.go::New"] + ); + assert_eq!( + targets(graph, file, "Stamp", "calls"), + ["pkg/clock/clock.go::Now"] + ); + assert_eq!( + targets(graph, file, "AddItem", "calls"), + ["internal/store/store.go::Manager::CreateItem"] + ); + } + + /// Every `references` reference out of a type declaration of `file` + /// sits on the name it writes, where resolution reads a package + /// qualifier back (upstream's `expectRefsOnTheirNames`). + #[track_caller] + fn assert_type_refs_on_their_names(graph: &Graph, file: &str, source: &str) { + let lines: Vec<&str> = source.lines().collect(); + let is_type = |id: &str| { + graph.node(id).is_some_and(|n| { + n.file_path == file + && matches!(n.kind.as_str(), "type_alias" | "struct" | "interface") + }) + }; + let resolved = graph + .edges + .iter() + .filter(|e| e.kind == "references" && is_type(&e.source)) + .filter_map(|e| Some((graph.node(&e.target)?.name.as_str(), e.line?, e.col?))); + let unresolved = graph + .unresolved + .iter() + .filter(|r| r.reference_kind == "references" && is_type(&r.from_node_id)) + .map(|r| (r.reference_name.as_str(), r.line, r.col)); + let mut seen = 0; + for (name, line, col) in resolved.chain(unresolved) { + let text = lines[usize::try_from(line - 1).unwrap()]; + assert!( + text.get(usize::try_from(col).unwrap()..) + .is_some_and(|rest| rest.starts_with(name)), + "{name} at {line}:{col} of {file}: {text:?}" + ); + seen += 1; + } + assert!(seen > 0, "no type references in {file}"); + } + + /// Each LF fixture as written, then with CRLF line ends. + fn with_line_ends(source: &str) -> [(&'static str, String); 2] { + [ + ("LF", source.to_string()), + ("CRLF", source.replace('\n', "\r\n")), + ] + } + + /// Upstream #2417: an alias is a node of the kind a declaration of its + /// type gets, nested under its file, documented as any type declaration + /// is, and references the types it names, each on its name; a defined + /// type references its own (#2441). Upstream's fixtures, LF and CRLF. + /// Its generic aliases need no fallback here: the grammar has their rule. + #[test] + fn pr2417_an_alias_is_a_node_that_references_the_types_it_names() { + let generic = "package clientv3\n\ntype List[T any] struct {\n\titems []T\n}\n\n// Set is a set of comparable values.\ntype Set[T comparable] = map[T]struct{}\n\ntype Items[T any] = List[T]\n\ntype Pairs[K comparable, V any] = map[K]List[V]\n"; + for ((label, aliases), (_, generic)) in with_line_ends(ALIAS_SOURCE) + .into_iter() + .zip(with_line_ends(generic)) + { + let project = Project::new() + .file("client/v3/watch.go", &aliases) + .file("client/v3/set.go", &generic) + .index(); + let graph = project.graph(); + let report = graph.report(); + let file = "client/v3/watch.go"; + assert_eq!( + declarations(graph, file), + [ + "type_alias Event", + "type_alias WatchChan", + "struct WatchResponse", + "type_alias Local", + "type_alias Ptr", + "type_alias Slice", + "type_alias Handler", + "type_alias Index", + "type_alias Defined", + "type_alias Plain", + "struct Anon", + "interface Closer", + "method Close", + "type_alias Type", + "type_alias Word", + ], + "{label}\n{report}" + ); + let event = node_in(graph, file, "Event"); + assert_eq!(event.kind, "type_alias", "{label}"); + assert!(event.is_exported, "{label}"); + assert_eq!(event.start_line, 10, "{label}"); + assert!( + graph.edges.iter().any(|e| e.kind == "contains" + && e.source == format!("file:{file}") + && e.target == event.id), + "{label}\n{report}" + ); + assert_eq!( + docstring_of(graph, file, "type_alias", "Local").as_deref(), + Some("Local names a type of this package."), + "{label}" + ); + // #2438: a type declared on its own keeps the comment above it. + assert_eq!( + event.docstring.as_deref(), + Some("Event is the event a watcher reports."), + "{label}" + ); + assert_eq!( + type_refs(graph, file), + [ + "Defined WatchResponse", + "Event Event", + "Handler Context", + "Handler WatchResponse", + "Index KeyValue", + "Local WatchResponse", + "Plain List", + "Plain WatchResponse", + "Ptr WatchResponse", + "Slice WatchResponse", + "WatchChan WatchResponse", + ], + "{label}\n{report}" + ); + assert_type_refs_on_their_names(graph, file, &aliases); + let set = "client/v3/set.go"; + assert_eq!( + declarations(graph, set), + [ + "struct List", + "type_alias Set", + "type_alias Items", + "type_alias Pairs" + ], + "{label}" + ); + assert!(node_in(graph, set, "Set").is_exported, "{label}"); + assert_eq!( + type_refs(graph, set), + ["Items List", "Pairs List"], + "{label}" + ); + assert_type_refs_on_their_names(graph, set, &generic); + } + } + + /// The one node named `name` in `file`, imports aside. + #[track_caller] + fn node_in<'g>(graph: &'g Graph, file: &str, name: &str) -> &'g NodeRow { + let found: Vec<_> = graph + .nodes + .iter() + .filter(|n| n.file_path == file && n.name == name && n.kind != "import") + .collect(); + assert_eq!(found.len(), 1, "{name} in {file}\n{}", graph.report()); + found[0] + } + + /// `kind file:qualifiedName` of each edge but `contains` out of the one + /// node named `name` in `file`, sorted and deduplicated (upstream's + /// `linksFrom`). + #[track_caller] + fn links_from(graph: &Graph, file: &str, name: &str) -> Vec { + let id = &node_in(graph, file, name).id; + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind != "contains" && &e.source == id) + .filter_map(|e| graph.node(&e.target).map(|t| (e.kind.as_str(), t))) + .map(|(kind, t)| format!("{kind} {}:{}", t.file_path, t.qualified_name)) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// `kind name` of every unresolved reference out of the one node named + /// `name` in `file`. + #[track_caller] + fn unresolved_from(graph: &Graph, file: &str, name: &str) -> Vec { + let id = &node_in(graph, file, name).id; + graph + .unresolved + .iter() + .filter(|r| &r.from_node_id == id) + .map(|r| format!("{} {}", r.reference_kind, r.reference_name)) + .collect() + } + + /// Upstream #2417: an indexed module resolves through its aliases — an + /// alias references the type it names in the package it is written + /// through, a bare name in the alias's package is the alias, a method + /// called on an alias is the aliased type's (through two aliases, one of + /// a pointer, too), and an alias written through a package the index does + /// not know links to nothing. Upstream's fixture; its `implements` half + /// (an alias owning the methods written on it) is W7-15's. + #[test] + fn pr2417_an_indexed_go_module_resolves_through_its_aliases() { + let project = Project::new() + .file("go.mod", "module go.etcd.io/etcd\n\ngo 1.24\n") + .file( + "api/v3/mvccpb/kv.go", + "package mvccpb\n\ntype Event struct {\n\tType int\n}\n\nfunc (e *Event) IsCreate() bool { return e.Type == 0 }\n", + ) + .file( + "alpha/event.go", + "package alpha\n\ntype Event struct{}\n\nfunc (e *Event) IsCreate() bool { return false }\n", + ) + .file( + "client/v3/watch.go", + "package clientv3\n\nimport \"go.etcd.io/etcd/api/v3/mvccpb\"\n\ntype Event = mvccpb.Event\n\ntype WatchResponse struct {\n\tEvents []*Event\n}\n\nfunc (wr *WatchResponse) Err() error { return nil }\n\ntype (\n\tLocal = WatchResponse\n\tPtr = *Local\n)\n\nfunc newEvent() Event { return Event{} }\n\nfunc isCreate(e *Event) bool { return e.IsCreate() }\n\nfunc check(p Ptr) error { return p.Err() }\n", + ) + .file( + "client/v3/list.go", + "package clientv3\n\ntype List[T any] struct{ items []T }\n\nfunc (l *List[T]) Len() int { return len(l.items) }\n\ntype Items[T any] = List[T]\n\nfunc count(xs *Items[int]) int { return xs.Len() }\n", + ) + .file( + "discovery/kuma.go", + "package discovery\n\ntype Config interface {\n\tName() string\n}\n\ntype SDConfig struct{}\n\ntype KumaSDConfig = SDConfig\n\nfunc (*KumaSDConfig) Name() string { return \"kuma\" }\n", + ) + .file( + "watcher/watcher.go", + "package watcher\n\nimport \"go.etcd.io/etcd/client/v3\"\n\n// The package is clientv3, though the index knows the import as v3 or client.\ntype Event = clientv3.Event\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let links = |file: &str, name: &str| links_from(graph, file, name); + assert_eq!( + links("client/v3/watch.go", "Event"), + ["references api/v3/mvccpb/kv.go:Event"], + "{report}" + ); + assert_eq!( + links("client/v3/watch.go", "Local"), + ["references client/v3/watch.go:WatchResponse"] + ); + assert_eq!( + links("client/v3/watch.go", "Ptr"), + ["references client/v3/watch.go:Local"] + ); + assert_eq!( + links("client/v3/list.go", "Items"), + ["references client/v3/list.go:List"] + ); + assert_eq!( + links("client/v3/watch.go", "newEvent"), + [ + "instantiates client/v3/watch.go:Event", + "references client/v3/watch.go:Event" + ] + ); + assert_eq!( + links("client/v3/watch.go", "isCreate"), + [ + "calls api/v3/mvccpb/kv.go:Event::IsCreate", + "references client/v3/watch.go:Event" + ] + ); + assert_eq!( + links("client/v3/watch.go", "check"), + [ + "calls client/v3/watch.go:WatchResponse::Err", + "references client/v3/watch.go:Ptr" + ] + ); + assert_eq!( + links("client/v3/list.go", "count"), + [ + "calls client/v3/list.go:List::Len", + "references client/v3/list.go:Items" + ] + ); + // Upstream also has `implements discovery/kuma.go:Config` here: W7-15. + assert!( + links("discovery/kuma.go", "KumaSDConfig") + .contains(&"references discovery/kuma.go:SDConfig".to_string()), + "{report}" + ); + // Not to itself, nor to client/v3's, mvccpb's or alpha's `Event`. + assert!(links("watcher/watcher.go", "Event").is_empty(), "{report}"); + assert_eq!( + unresolved_from(graph, "watcher/watcher.go", "Event"), + ["references Event"] + ); + } + + /// Upstream #2441: a defined type is still one `type_alias` node, and + /// references each type its right-hand side names, on the name: not the + /// predeclared types, its own type parameters, or its own name written + /// bare. Upstream's fixture, LF and CRLF. + #[test] + fn pr2441_a_defined_type_references_each_type_it_names() { + let source = "package gin\n\nimport (\n\t\"context\"\n\n\t\"github.com/gin-gonic/gin/render\"\n)\n\n// HandlerFunc defines the handler used by gin middleware as return value.\ntype HandlerFunc func(*Context)\n\n// HandlersChain defines a HandlerFunc slice.\ntype HandlersChain []HandlerFunc\n\ntype Context struct {\n\thandlers HandlersChain\n}\n\ntype WatchChan <-chan WatchResponse\n\ntype WatchResponse struct{}\n\ntype (\n\t// Render names another package's type of its own name.\n\tRender render.Render\n\tLookup map[Key]*WatchResponse\n\tGrid [4][4]Cell\n\tPipeline func(ctx context.Context, in <-chan Cell) (Key, error)\n\tWrapped (Cell)\n\tNamed Cell\n\tTables map[string]struct {\n\t\tcell Cell\n\t\t*Key\n\t}\n)\n\ntype Generic[T any] List[T]\n\ntype Pair[K comparable, V any] map[K]List[V]\n\ntype stateFn func(*Lexer) stateFn\n\ntype Tree []*Tree\n\ntype List[T any] struct {\n\titems []T\n}\n\ntype Cell struct{}\n\ntype Key string\n\ntype Dur int\n\ntype Mode uint8\n\ntype Lexer struct{}\n"; + for (label, text) in with_line_ends(source) { + let project = Project::new().file("gin.go", &text).index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + declarations(graph, "gin.go"), + [ + "type_alias HandlerFunc", + "type_alias HandlersChain", + "struct Context", + "type_alias WatchChan", + "struct WatchResponse", + "type_alias Render", + "type_alias Lookup", + "type_alias Grid", + "type_alias Pipeline", + "type_alias Wrapped", + "type_alias Named", + "type_alias Tables", + "type_alias Generic", + "type_alias Pair", + "type_alias stateFn", + "type_alias Tree", + "struct List", + "struct Cell", + "type_alias Key", + "type_alias Dur", + "type_alias Mode", + "struct Lexer", + ], + "{label}\n{report}" + ); + assert_eq!( + type_refs(graph, "gin.go"), + [ + "Generic List", + "Grid Cell", + "HandlerFunc Context", + "HandlersChain HandlerFunc", + "Lookup Key", + "Lookup WatchResponse", + "Named Cell", + "Pair List", + "Pipeline Cell", + "Pipeline Context", + "Pipeline Key", + "Render Render", + "Tables Cell", + "Tables Key", + "WatchChan WatchResponse", + "Wrapped Cell", + "stateFn Lexer", + ], + "{label}\n{report}" + ); + assert_type_refs_on_their_names(graph, "gin.go", &text); + // `render.Render` is written through its package: the reference + // sits on the second `Render`. + let render = &node_in(graph, "gin.go", "Render").id; + let lines: Vec<&str> = text.lines().collect(); + let before: Vec = graph + .unresolved + .iter() + .filter(|r| &r.from_node_id == render && r.reference_name == "Render") + .map(|r| { + let line = lines[usize::try_from(r.line - 1).unwrap()]; + line[..usize::try_from(r.col).unwrap()] + .trim_start() + .to_string() + }) + .collect(); + assert_eq!(before, ["Render render."], "{label}\n{report}"); + } + } + + /// Upstream #2441: an indexed module links its defined types — a bare + /// name is its own package's type, `pb.PutResponse` the import's, a name + /// written through a package the index does not know links to nothing, + /// a recursive type has no edge or row for its own name, a defined type + /// has none of its underlying type's methods, and impact on a type + /// reaches the defined types built from it. Upstream's fixture, LF and + /// CRLF. + #[test] + fn pr2441_an_indexed_go_module_links_its_defined_types() { + let module: [(&str, &str); 6] = [ + ("go.mod", "module go.etcd.io/etcd\n\ngo 1.24\n"), + ( + "api/v3/etcdserverpb/rpc.pb.go", + "package etcdserverpb\n\ntype PutResponse struct {\n\tHeader *ResponseHeader\n}\n\ntype ResponseHeader struct{}\n\nfunc (m *PutResponse) GetHeader() *ResponseHeader { return m.Header }\n", + ), + ( + "alpha/names.go", + "package alpha\n\ntype PutResponse struct{}\n\nfunc (m *PutResponse) GetHeader() int { return 0 }\n\ntype WatchResponse struct{}\n\ntype Context struct{}\n\ntype Op struct{}\n\ntype watcher struct{}\n", + ), + ( + "client/v3/watch.go", + "package clientv3\n\nimport pb \"go.etcd.io/etcd/api/v3/etcdserverpb\"\n\ntype PutResponse pb.PutResponse\n\ntype WatchChan <-chan WatchResponse\n\ntype WatchResponse struct {\n\tCanceled bool\n}\n\ntype Op struct{}\n\ntype watcher struct{}\n\nfunc (w *watcher) Watch() WatchChan { return nil }\n\nfunc header(r *PutResponse) { r.GetHeader() }\n\ntype stateFn func(*watcher) stateFn\n", + ), + ( + "client/v3/ordering/util.go", + "package ordering\n\nimport \"go.etcd.io/etcd/client/v3\"\n\ntype OrderViolationFunc func(op clientv3.Op) error\n", + ), + ( + "gin/gin.go", + "package gin\n\ntype HandlerFunc func(*Context)\n\ntype HandlersChain []HandlerFunc\n\ntype Context struct {\n\thandlers HandlersChain\n}\n\ntype Engine struct{}\n\nfunc (engine *Engine) Use(middleware ...HandlerFunc) {}\n", + ), + ]; + for crlf in [false, true] { + let label = if crlf { "CRLF" } else { "LF" }; + let project = module + .iter() + .fold(Project::new(), |project, (path, text)| { + let text = if crlf { + text.replace('\n', "\r\n") + } else { + (*text).to_string() + }; + project.file(path, &text) + }) + .index(); + let graph = project.graph(); + let report = graph.report(); + let links = |file: &str, name: &str| links_from(graph, file, name); + assert_eq!( + links("client/v3/watch.go", "WatchChan"), + ["references client/v3/watch.go:WatchResponse"], + "{label}\n{report}" + ); + assert_eq!( + links("gin/gin.go", "HandlerFunc"), + ["references gin/gin.go:Context"], + "{label}" + ); + assert_eq!( + links("gin/gin.go", "HandlersChain"), + ["references gin/gin.go:HandlerFunc"], + "{label}" + ); + assert_eq!( + links("client/v3/watch.go", "PutResponse"), + ["references api/v3/etcdserverpb/rpc.pb.go:PutResponse"], + "{label}\n{report}" + ); + assert_eq!( + links("client/v3/watch.go", "stateFn"), + ["references client/v3/watch.go:watcher"], + "{label}" + ); + assert!( + unresolved_from(graph, "client/v3/watch.go", "stateFn").is_empty(), + "{label}" + ); + assert!( + links("client/v3/ordering/util.go", "OrderViolationFunc").is_empty(), + "{label}" + ); + assert_eq!( + unresolved_from(graph, "client/v3/ordering/util.go", "OrderViolationFunc"), + ["references Op"], + "{label}" + ); + // Go has no `GetHeader` on a type defined from pb.PutResponse. + assert_eq!( + links("client/v3/watch.go", "header"), + ["references client/v3/watch.go:PutResponse"], + "{label}\n{report}" + ); + let impact = |file: &str, name: &str| -> Vec { + let output = codegraph( + project.root(), + [ + "impact", name, "-p", ".", "--depth", "3", "--file", file, "--json", + ], + ); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("impact --json output"); + let mut out: Vec = value["definitions"] + .as_array() + .expect("impact definitions") + .iter() + .flat_map(|d| d["affected"].as_array().expect("affected").iter()) + .filter_map(|a| graph.node(a["id"].as_str()?)) + .map(|n| format!("{}:{}", n.file_path, n.qualified_name)) + .collect(); + out.sort(); + out.dedup(); + out + }; + assert_eq!( + impact("client/v3/watch.go", "WatchResponse"), + [ + "client/v3/watch.go:WatchChan", + "client/v3/watch.go:WatchResponse", + "client/v3/watch.go:watcher::Watch", + ], + "{label}" + ); + assert_eq!( + impact("gin/gin.go", "Context"), + [ + "gin/gin.go:Context", + "gin/gin.go:Engine::Use", + "gin/gin.go:HandlerFunc", + "gin/gin.go:HandlersChain", + ], + "{label}" + ); + } + } + + /// Upstream #2233: a Go name written through an import from outside the + /// module (`context.Context`, testify's `require.Contains`) is that + /// package's, never a project symbol of its name, not even the same + /// file's method; a name through one of the module's own packages still + /// resolves. Upstream's fixture. + #[test] + fn pr2233_a_go_name_through_an_outside_package_is_never_a_project_symbol() { + let project = Project::new() + .file("go.mod", "module github.com/gofiber/fiber/v3\n\ngo 1.22\n") + .file( + "middleware/sse/sse.go", + "package sse\n\nimport (\n\t\"context\"\n\n\t\"github.com/gofiber/fiber/v3/extractors\"\n)\n\ntype Stream struct{}\n\nfunc (s *Stream) Context() context.Context {\n\treturn nil\n}\n\nfunc run(ctx context.Context) bool {\n\treturn extractors.Contains(\"a\", \"b\")\n}\n", + ) + .file( + "extractors/extractors.go", + "package extractors\n\nfunc Contains(haystack, needle string) bool {\n\treturn true\n}\n", + ) + .file( + "middleware/sse/sse_test.go", + "package sse\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestStream(t *testing.T) {\n\trequire.Contains(t, \"abc\", \"a\")\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let targets_from = |file: &str| -> Vec { + graph + .edges + .iter() + .filter(|e| e.kind != "contains") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect() + }; + let sse = targets_from("middleware/sse/sse.go"); + assert!(!sse.contains(&"Stream::Context".to_string()), "{report}"); + assert!( + !targets_from("middleware/sse/sse_test.go").contains(&"Contains".to_string()), + "{report}" + ); + assert!(sse.contains(&"Contains".to_string()), "{report}"); + } + + /// Upstream #2448's fixture (`go-ref-qualifier.test.ts`): project + /// methods named like outside packages' functions, and locals and + /// parameters named like imports. + const GO_REF_QUALIFIER: [(&str, &str); 17] = [ + ("go.mod", "module example.com/app\n\ngo 1.22\n"), + ( + "logging/wrapper.go", + "package logging\n\n// An adapter with klog's method names, like kubernetes' etcd3 logger.\ntype klogWrapper struct{}\n\nfunc (klogWrapper) Infof(format string, args ...interface{}) {}\nfunc (klogWrapper) Errorf(format string, args ...interface{}) {}\n", + ), + ( + "registry/suite.go", + "package registry\n\ntype Suite struct{}\n\nfunc (s *Suite) Digest(v string) string { return v }\n", + ), + ( + "diff/differ.go", + "package diff\n\ntype Differ struct{}\n\nfunc (d *Differ) Diff(a, b string) string { return a + b }\n", + ), + ( + "example/example.go", + "package example\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/distribution/distribution/v3\"\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/opencontainers/go-digest\"\n\t\"github.com/patrickmn/go-cache\"\n\t\"k8s.io/klog/v2\"\n)\n\ntype parseError struct{}\n\nfunc (e *parseError) Error() string { return \"parse\" }\n\ntype lru struct{}\n\nfunc (l *lru) Get(key string) string { return key }\n\ntype Server struct {\n\tcache *lru\n}\n\nfunc (s *Server) Lookup(key string) string {\n\treturn s.cache.Get(key)\n}\n\nfunc NewServer() *Server {\n\t_ = cache.New(0, 0)\n\treturn &Server{cache: &lru{}}\n}\n\nfunc Run(dig string) {\n\tklog.Infof(\"Log using Infof, key: %s\", \"value\")\n\terr := errors.New(\"fail\")\n\tklog.Errorf(\"Log using Errorf, err: %v\", err)\n\tdesc := &distribution.Descriptor{Digest: digest.Digest(dig)}\n\t_ = desc\n\tperr := &parseError{}\n\tklog.Error(perr.Error())\n}\n\nfunc TestParser(t *testing.T) {\n\tt.Errorf(\"Unexpected policy! Diff:\\n%s\", cmp.Diff(\"a\", \"b\"))\n}\n", + ), + ( + "auth/jwt.go", + "package auth\n\nimport \"github.com/golang-jwt/jwt/v5\"\n\ntype tokenJWT struct{ key string }\n\nfunc newTokenProviderJWT(key string) (*tokenJWT, error) { return &tokenJWT{key: key}, nil }\n\nfunc (t *tokenJWT) assign(user string) (string, error) { return user, nil }\n\nfunc (t *tokenJWT) info(token string) (string, bool) { return token, true }\n\n// Named like the package's Parse.\ntype jwtOptions struct{}\n\nfunc (o *jwtOptions) Parse(token string) error { return nil }\n\nfunc parse(token string) {\n\t_, _ = jwt.Parse(token, nil)\n}\n", + ), + ( + "auth/jwt_test.go", + "package auth\n\nimport (\n\t\"testing\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\nfunc TestJWTInfo(t *testing.T) {\n\tjwt, err := newTokenProviderJWT(\"key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttoken, _ := jwt.assign(\"abc\")\n\t_, ok := jwt.info(token)\n\t_ = ok\n}\n\nfunc TestJWTParse(t *testing.T) {\n\tjwt, err := jwt.Parse(\"x\", nil)\n\t_, _ = jwt, err\n}\n\nfunc TestJWTClosure(t *testing.T) {\n\tcheck := func(jwt *tokenJWT) {\n\t\tjwt.info(\"t\")\n\t}\n\tcheck(nil)\n\t_, _ = jwt.Parse(\"y\", nil)\n}\n", + ), + ( + "compare/compare.go", + "package compare\n\nimport \"github.com/google/go-cmp/cmp\"\n\ntype Comparer interface {\n\tCompare(a, b int) int\n}\n\nfunc comparers() []Comparer { return nil }\n\nfunc Check(cmp Comparer) int {\n\treturn cmp.Compare(1, 2)\n}\n\nfunc Report(a, b string) string {\n\tfor _, cmp := range comparers() {\n\t\t_ = cmp.Compare(3, 4)\n\t}\n\treturn cmp.Diff(a, b)\n}\n\nfunc Report2(a, b string) string {\n\tcmp := cmp.Diff(a, b)\n\treturn cmp\n}\n", + ), + ( + "clock/clock.go", + "package clock\n\nimport \"time\"\n\n// Named like k8s.io/utils/clock's interface.\ntype Clock interface {\n\tNow() time.Time\n}\n", + ), + ( + "cache/store.go", + "package cache\n\ntype Store interface {\n\tAdd(obj interface{}) error\n}\n", + ), + ( + "jobs/logger/logger.go", + "package logger\n\ntype Interface interface {\n\tError(v ...interface{})\n\tString() string\n}\n\nfunc Error(v ...interface{}) {}\n", + ), + ( + "jobs/transfer/transfer.go", + "package transfer\n\ntype Logger struct{}\n\nfunc (l *Logger) Error(v ...interface{}) {}\n", + ), + ( + "jobs/job.go", + "package jobs\n\nimport (\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"example.com/app/cache\"\n\t\"example.com/app/jobs/logger\"\n\t\"k8s.io/utils/clock\"\n\tclocktesting \"k8s.io/utils/clock/testing\"\n)\n\nfunc logError(logger logger.Interface, err error) {\n\tlogger.Error(err)\n}\n\nfunc nextCheck(clock clock.PassiveClock) int64 {\n\treturn clock.Now().Unix()\n}\n\nfunc cachedHasSynced(store cache.Store) func() bool {\n\tcache := &atomic.Bool{}\n\tcache.Store(false)\n\treturn cache.Load\n}\n\nfunc describe(logger logger.Interface) string {\n\treturn logger.Kind.String()\n}\n\nfunc elapsed() time.Time {\n\tclock := clocktesting.NewFakePassiveClock(time.Unix(1, 0))\n\tnow := clock.Now()\n\treturn now\n}\n", + ), + ( + "robot/model/model.go", + "package model\n\ntype Robot struct{ Name string }\n\nfunc (r *Robot) ToJSON() (string, error) { return r.Name, nil }\n", + ), + ( + "robot/robot.go", + "package robot\n\nimport \"example.com/app/robot/model\"\n\ntype Robot struct {\n\tmodel.Robot\n\tLevel string\n}\n", + ), + ( + "scan/job_test.go", + "package scan\n\nimport (\n\t\"testing\"\n\n\t\"example.com/app/robot\"\n)\n\nfunc TestJob(t *testing.T) {\n\trobot := &robot.Robot{Level: \"system\"}\n\tdata, _ := robot.ToJSON()\n\t_ = data\n}\n", + ), + ( + "store/store.go", + "package store\n\nimport \"github.com/patrickmn/go-cache\"\n\ntype store struct{}\n\nfunc (cache *store) Refresh() {\n\tcache.load()\n}\n\nfunc (s *store) load() {}\n\nfunc New() *cache.Cache {\n\treturn cache.New(0, 0)\n}\n", + ), + ]; + + /// Lines beside upstream's: chi's `r.Use(middleware.Logger)` (a method + /// value recorded at its member), etcd's `(*v3.DeleteResponse)(resp)` (a + /// conversion recorded at its parenthesis), etcd's `zap.String("…", + /// p.id.String())`, + /// chi's `Chain(…).Handler(h)`, and a field of a call's result named like + /// an outside import. + const GO_REF_QUALIFIER_EXTRA: [(&str, &str); 6] = [ + ( + "app/convert.go", + "package app\n\nimport \"example.com/app/mw\"\n\nfunc convert(r any) {\n\t_ = (*mw.Response)(nil)\n}\n", + ), + ( + "mw/logger.go", + "package mw\n\nfunc Logger(next func()) func() { return next }\n\ntype Response struct{}\n", + ), + ( + "app/router.go", + "package app\n\nimport \"example.com/app/mw\"\n\ntype router struct{}\n\nfunc (r *router) Use(f func(func()) func()) {}\n\nfunc route(r *router) {\n\tr.Use(mw.Logger)\n}\n", + ), + ( + "rafthttp/peer.go", + "package rafthttp\n\nimport \"go.uber.org/zap\"\n\ntype ID uint64\n\nfunc (i ID) String() string { return \"\" }\n\ntype peer struct {\n\tid ID\n}\n\nfunc (p *peer) send(lg *zap.Logger) {\n\tlg.Info(\"sent\", zap.String(\"remote-peer-id\", p.id.String()))\n}\n", + ), + ( + "chain/chain.go", + "package chain\n\nimport \"net/http\"\n\ntype Middlewares []func(http.Handler) http.Handler\n\nfunc Chain(middlewares ...func(http.Handler) http.Handler) Middlewares { return middlewares }\n\nfunc (mws Middlewares) Handler(h http.Handler) http.Handler { return h }\n\nfunc mount(h http.Handler) http.Handler {\n\treturn Chain(func(next http.Handler) http.Handler { return next }).Handler(h)\n}\n", + ), + ( + "app/start.go", + "package app\n\nimport \"github.com/acme/bar\"\n\ntype barRunner struct{}\n\nfunc (b *barRunner) Run() {}\n\ntype outer struct {\n\tbar *barRunner\n}\n\nfunc makeOuter() *outer { return &outer{} }\n\nfunc start() {\n\t_ = bar.Version\n\tmakeOuter().bar.Run()\n}\n", + ), + ]; + + /// What the edges out of `file`'s nodes on the `nth` line holding + /// `snippet` reach, as qualified names (upstream's `reachedAt`). + fn reached_at(graph: &Graph, file: &str, snippet: &str, nth: usize) -> Vec { + let text = GO_REF_QUALIFIER + .iter() + .chain(GO_REF_QUALIFIER_EXTRA.iter()) + .find(|(path, _)| *path == file) + .map(|(_, text)| *text) + .unwrap_or_else(|| panic!("no {file}")); + let line = text + .lines() + .enumerate() + .filter(|(_, line)| line.contains(snippet)) + .nth(nth - 1) + .map(|(index, _)| i64::try_from(index + 1).unwrap()) + .unwrap_or_else(|| panic!("no {snippet} in {file}")); + graph + .edges + .iter() + .filter(|e| e.kind != "contains" && e.line == Some(line)) + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect() + } + + fn go_ref_qualifier_project() -> super::support::Indexed { + GO_REF_QUALIFIER + .iter() + .chain(GO_REF_QUALIFIER_EXTRA.iter()) + .fold(Project::new(), |project, (path, text)| { + project.file(path, text) + }) + .index() + } + + /// Upstream #2448's first half: a dotted Go call is written through its + /// own operand, the only part that can be a package, whatever else its + /// line spells — neither a second spelling of the name (`"Log using + /// Infof"`, `Digest:`, `Diff:`) nor another selector before it + /// (`klog.Error(perr.Error())`). Upstream's fixture, with etcd's, chi's + /// and a call result's field's lines beside it. + #[test] + fn pr2448_a_dotted_go_call_is_written_through_its_own_receiver() { + let project = go_ref_qualifier_project(); + let graph = project.graph(); + let report = graph.report(); + let at = |file: &str, snippet: &str| reached_at(graph, file, snippet, 1); + let ex = "example/example.go"; + assert!( + !at(ex, "klog.Infof(").contains(&"klogWrapper::Infof".to_string()), + "{report}" + ); + assert!( + !at(ex, "klog.Errorf(").contains(&"klogWrapper::Errorf".to_string()), + "{report}" + ); + assert!( + !at(ex, "digest.Digest(dig)").contains(&"Suite::Digest".to_string()), + "{report}" + ); + assert!( + !at(ex, "cmp.Diff(\"a\", \"b\")").contains(&"Differ::Diff".to_string()), + "{report}" + ); + assert!( + at(ex, "klog.Error(perr.Error())").contains(&"parseError::Error".to_string()), + "{report}" + ); + assert!( + at(ex, "s.cache.Get(key)").contains(&"lru::Get".to_string()), + "{report}" + ); + assert_eq!( + at("rafthttp/peer.go", "p.id.String()"), + ["ID::String"], + "{report}" + ); + // A conversion through a project package, recorded at its parenthesis. + assert!( + at("app/convert.go", "(*mw.Response)(nil)").contains(&"Response".to_string()), + "{report}" + ); + // A value written through a project package, recorded at its member. + assert!( + at("app/router.go", "r.Use(mw.Logger)").contains(&"Logger".to_string()), + "{report}" + ); + assert!( + at("chain/chain.go", "}).Handler(h)").contains(&"Middlewares::Handler".to_string()), + "{report}" + ); + assert!( + at("app/start.go", "makeOuter().bar.Run()").contains(&"barRunner::Run".to_string()), + "{report}" + ); + } + + /// Upstream #2448's second half: a parameter or local named like an + /// import is that variable where it is in scope — a closure's parameter, + /// a function's, a range variable, a method receiver — and the package + /// everywhere else (another function, the declaration's own right-hand + /// side, past a closure or a loop that bound the name). Its declared type + /// says whose methods it has: an outside package's type or a value an + /// outside function hands out has none of the project's, a project + /// package's type its own (an embedded type's method too), and a field + /// read through the local keeps its own type. Upstream's fixture, less + /// the untyped range variable's guessed callee (KEEP-RUST OD-8). + #[test] + fn pr2448_a_go_local_named_like_an_import_is_that_variable() { + let project = go_ref_qualifier_project(); + let graph = project.graph(); + let report = graph.report(); + let at = |file: &str, snippet: &str| reached_at(graph, file, snippet, 1); + let has = |reached: Vec, target: &str| reached.contains(&target.to_string()); + assert!( + has(at("auth/jwt_test.go", "jwt.assign("), "tokenJWT::assign"), + "{report}" + ); + assert!( + has(at("auth/jwt_test.go", "jwt.info(token)"), "tokenJWT::info"), + "{report}" + ); + assert!( + has(at("auth/jwt_test.go", "jwt.info(\"t\")"), "tokenJWT::info"), + "{report}" + ); + assert!( + has( + at("compare/compare.go", "cmp.Compare(1, 2)"), + "Comparer::Compare" + ), + "{report}" + ); + // A range variable is that variable too (`go_scope`'s unit tests), but + // nothing types it: upstream's lone-candidate guess takes + // `Comparer::Compare`, which the receiver-word gate keeps from `cmp` + // here (#226, KEEP-RUST OD-8). + assert!( + has(at("store/store.go", "cache.load()"), "store::load"), + "{report}" + ); + assert!( + !has( + at("auth/jwt.go", "jwt.Parse(token, nil)"), + "jwtOptions::Parse" + ), + "{report}" + ); + assert!( + !has( + at("auth/jwt_test.go", "jwt.Parse(\"x\", nil)"), + "jwtOptions::Parse" + ), + "{report}" + ); + assert!( + !has( + at("auth/jwt_test.go", "jwt.Parse(\"y\", nil)"), + "jwtOptions::Parse" + ), + "{report}" + ); + assert!( + !has( + at("compare/compare.go", "return cmp.Diff(a, b)"), + "Differ::Diff" + ), + "{report}" + ); + assert!( + !has( + at("compare/compare.go", "cmp := cmp.Diff(a, b)"), + "Differ::Diff" + ), + "{report}" + ); + assert!( + !has(at("jobs/job.go", "clock.Now()"), "Clock::Now"), + "{report}" + ); + assert!( + at("jobs/job.go", "cache.Store(false)").is_empty(), + "{report}" + ); + assert!( + at("jobs/job.go", "now := clock.Now()").is_empty(), + "{report}" + ); + assert_eq!( + at("jobs/job.go", "logger.Error(err)"), + ["Interface::Error"], + "{report}" + ); + assert_eq!( + at("scan/job_test.go", "robot.ToJSON()"), + ["Robot::ToJSON"], + "{report}" + ); + assert!( + !has( + at("jobs/job.go", "logger.Kind.String()"), + "Interface::String" + ), + "{report}" + ); + } + + /// Upstream #2448: the local is read from the file as a sync leaves it — + /// the same lines either way, so a stale reading would still see it. + #[test] + fn pr2448_a_go_local_is_read_from_the_file_a_sync_leaves() { + let jwt = GO_REF_QUALIFIER + .iter() + .find(|(path, _)| *path == "auth/jwt.go") + .map(|(_, text)| *text) + .unwrap(); + let use_file = |decl: &str| { + format!( + "package auth\nimport \"github.com/golang-jwt/jwt/v5\"\nfunc use() {{\n\t{decl}\n\tjwt.assign(\"user\")\n}}\n" + ) + }; + let mut project = Project::new() + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file("auth/jwt.go", jwt) + .file( + "auth/use.go", + &use_file("jwt, _ := newTokenProviderJWT(\"key\")"), + ) + .index(); + let assigns = |graph: &Graph| -> Vec { + graph + .edges + .iter() + .filter(|e| e.kind != "contains" && e.line == Some(5)) + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.file_path == "auth/use.go") + }) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect() + }; + assert_eq!( + assigns(project.graph()), + ["tokenJWT::assign"], + "{}", + project.graph().report() + ); + project.write("auth/use.go", &use_file("_, _ = jwt.Parse(\"key\", nil)")); + let graph = project.sync(); + assert!(assigns(graph).is_empty(), "{}", graph.report()); + } + + /// `kind qualifiedName@file` of each edge but `contains` out of the + /// nodes of `file`, sorted (upstream's `targetsFrom`). + fn targets_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind != "contains") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target).map(|t| (e.kind.as_str(), t))) + .map(|(kind, t)| format!("{kind} {}@{}", t.qualified_name, t.file_path)) + .collect(); + out.sort(); + out + } + + /// Upstream #2410: a name written through an unaliased versioned or `go-` + /// import (`yaml.Node`, `klog.V(2)`, `semver.Version`, `sqlite3.Error`) is + /// that package's, never a project symbol sharing the name, and a project + /// import whose path ends in a major version (`example.com/kit/v2`, package + /// `kit`) or names a package `v1` still resolves. Upstream's fixture. + #[test] + fn pr2410_go_references_through_a_versioned_or_go_import() { + let project = Project::new() + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "other/other.go", + "package other\n\ntype Node struct{}\n\ntype Version struct{}\n\ntype Error struct{}\n\nfunc Unmarshal(b []byte, v any) error { return nil }\n\ntype klogWrapper struct{}\n\nfunc (k *klogWrapper) V(level int) bool { return true }\n\nfunc (k *klogWrapper) Infof(format string, args ...any) {}\n", + ) + .file( + "store/store.go", + "package store\n\nimport (\n\t\"github.com/Masterminds/semver/v3\"\n\t\"github.com/mattn/go-sqlite3\"\n\t\"go.yaml.in/yaml/v3\"\n\t\"k8s.io/klog/v2\"\n)\n\nfunc Load(b []byte, root *yaml.Node, min *semver.Version) error {\n\tif klog.V(2) {\n\t\tklog.Infof(\"loading %d bytes\", len(b))\n\t}\n\tvar e sqlite3.Error\n\t_ = e\n\treturn yaml.Unmarshal(b, root)\n}\n", + ) + .file("kit/go.mod", "module example.com/kit/v2\n\ngo 1.22\n") + .file( + "kit/kit.go", + "package kit\n\ntype Kit struct{}\n\nfunc New() *Kit { return &Kit{} }\n", + ) + .file( + "decoy/decoy.go", + "package decoy\n\ntype Kit struct{}\n\nfunc New() *Kit { return &Kit{} }\n\nfunc Find(id string) string { return id }\n", + ) + .file("api/core/v1/types.go", "package v1\n\ntype Pod struct{}\n") + .file("pkg/apis/core/types.go", "package core\n\ntype Pod struct{}\n") + .file( + "repo/repo.go", + "package repo\n\nfunc Find(id string) string { return id }\n", + ) + .file( + "app/app.go", + "package app\n\nimport (\n\t\"fmt\" // for printing\n\t\"example.com/app/repo\"\n\t\"example.com/app/api/core/v1\"\n\t\"example.com/app/pkg/apis/core\"\n\t\"example.com/kit/v2\"\n)\n\nfunc Run(p *v1.Pod, q *core.Pod) *kit.Kit {\n\tfmt.Println(repo.Find(\"x\"))\n\treturn kit.New()\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let store: Vec = targets_from(graph, "store/store.go") + .into_iter() + .filter(|t| t.contains("@other/")) + .collect(); + assert!(store.is_empty(), "{store:?}\n{report}"); + assert_eq!( + targets_from(graph, "app/app.go"), + [ + "calls Find@repo/repo.go", + "calls New@kit/kit.go", + "references Kit@kit/kit.go", + "references Pod@api/core/v1/types.go", + "references Pod@pkg/apis/core/types.go", + ], + "{report}" + ); + } + + /// Upstream #2397 with #2410: an embedded interface or struct links the + /// type its package declares — a bare name its own package's, `pkg.T` the + /// import's — and nothing for a type from outside the project (`io.Closer`, + /// `sync.Mutex`, `yaml.Node` under `go.yaml.in/yaml/v3`) or through a + /// package none of the file's imports is named (`clientv3.KV`), whose + /// references stay unresolved. Upstream's fixture, less its viewer half. + #[test] + fn pr2397_an_indexed_go_module_links_embedded_types_in_their_package() { + let gin = "package gin\n\ntype HandlerFunc func(*Context)\n\ntype IRoutes interface {\n\tUse(...HandlerFunc) IRoutes\n\tHandle(string, string, ...HandlerFunc) IRoutes\n}\n\ntype IRouter interface {\n\tIRoutes\n\tGroup(string, ...HandlerFunc) *RouterGroup\n}\n"; + let project = Project::new() + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "storage/storage.go", + "package storage\n\ntype LabelQuerier interface {\n\tLabelValues(name string) []string\n}\n\ntype Base struct{}\n\nfunc (b *Base) Close() error { return nil }\n", + ) + .file( + "other/other.go", + "package other\n\ntype Closer interface{ Close() error }\n\ntype Mutex struct{}\n\ntype Reader interface{ Read(p []byte) (int, error) }\n\ntype Node struct{}\n\ntype KV interface{ Get(key string) string }\n", + ) + .file( + "store/store.go", + "package store\n\nimport (\n\t\"io\"\n\t\"sync\"\n\n\t\"example.com/app/storage\"\n\t\"go.etcd.io/etcd/client/v3\"\n\t\"go.yaml.in/yaml/v3\"\n)\n\n// The package is yaml, though its import path ends in v3.\ntype RuleGroupNode struct {\n\tyaml.Node\n\tName string\n}\n\n// The package is clientv3: not v3, nor the client goimports would assume.\ntype kvPrefix struct {\n\tclientv3.KV\n}\n\ntype Queryable interface{ Q() }\n\ntype Querier interface {\n\tQueryable\n\tio.Closer\n\tstorage.LabelQuerier\n\tSelect(sorted bool) int\n}\n\ntype Number interface {\n\t~int | ~float64\n}\n\ntype Engine struct{}\n\ntype Reader interface{ Read() }\n\ntype List[T any] struct{ items []T }\n\ntype Store struct {\n\t*Engine\n\tReader\n\tsync.Mutex\n\t*storage.Base\n\tList[int]\n}\n", + ) + .file( + "wrap/wrap.go", + "package wrap\n\nimport \"io\"\n\ntype Reader interface {\n\tio.Reader\n\tPeek() byte\n}\n", + ) + .file("gin/routergroup.go", gin) + .file( + "parser/ast.go", + "package parser\n\ntype Node interface{ String() string }\n\ntype Expr interface {\n\tNode\n\tType() string\n}\n\ntype exprWrapper struct {\n\tNode\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let sources: Vec<&str> = [ + ("Querier", "store/store.go"), + ("Number", "store/store.go"), + ("Store", "store/store.go"), + ("RuleGroupNode", "store/store.go"), + ("kvPrefix", "store/store.go"), + ("Reader", "wrap/wrap.go"), + ("IRouter", "gin/routergroup.go"), + ("Expr", "parser/ast.go"), + ("exprWrapper", "parser/ast.go"), + ] + .iter() + .map(|(name, file)| node_in(graph, file, name).id.as_str()) + .collect(); + let mut supertypes: Vec = graph + .edges + .iter() + .filter(|e| matches!(e.kind.as_str(), "extends" | "implements")) + .filter(|e| e.provenance.as_deref() != Some("heuristic")) + .filter(|e| sources.contains(&e.source.as_str())) + .filter_map(|e| Some((graph.node(&e.source)?, e, graph.node(&e.target)?))) + .map(|(s, e, t)| format!("{} {} {}@{}", s.name, e.kind, t.name, t.file_path)) + .collect(); + supertypes.sort(); + assert_eq!( + supertypes, + [ + "Expr extends Node@parser/ast.go", + "IRouter extends IRoutes@gin/routergroup.go", + "Querier extends LabelQuerier@storage/storage.go", + "Querier extends Queryable@store/store.go", + "Store extends Base@storage/storage.go", + "Store extends Engine@store/store.go", + "Store extends List@store/store.go", + "Store implements Reader@store/store.go", + "exprWrapper implements Node@parser/ast.go", + ], + "{report}" + ); + let unresolved = |file: &str, name: &str| -> Vec { + let id = &node_in(graph, file, name).id; + let mut out: Vec = graph + .unresolved + .iter() + .filter(|r| &r.from_node_id == id && r.reference_kind == "extends") + .map(|r| r.reference_name.clone()) + .collect(); + out.sort(); + out + }; + assert_eq!(unresolved("store/store.go", "Querier"), ["Closer"]); + assert_eq!(unresolved("store/store.go", "Store"), ["Mutex"]); + assert_eq!(unresolved("store/store.go", "RuleGroupNode"), ["Node"]); + assert_eq!(unresolved("store/store.go", "kvPrefix"), ["KV"]); + assert_eq!(unresolved("wrap/wrap.go", "Reader"), ["Reader"]); + let other: Vec<&str> = ["Closer", "Mutex", "Reader", "Node", "KV"] + .iter() + .map(|name| node_in(graph, "other/other.go", name).id.as_str()) + .collect(); + assert!( + !graph + .edges + .iter() + .any(|e| matches!(e.kind.as_str(), "extends" | "implements") + && e.provenance.as_deref() != Some("heuristic") + && other.contains(&e.target.as_str())), + "{report}" + ); + } + + /// Upstream #2408: Go reads a name from one package only — a bare one + /// from the reference's own, `pkg.Name` from that import's, `x[i].Name()` + /// from whatever type `x[i]` has. A bare type is its own package's + /// interface, a type through an outside import none of the project's, a + /// method called through an expression no instantiation of a namesake + /// struct, a result type its package's type and not the method the line + /// declares, a variadic `...pkg.T` that import's type, and a conversion or + /// a composite literal still its own package's struct. Upstream's fixture, + /// less the route handler (a Go framework resolver's route: W7-19i) and + /// `var _ error = (*Error)(nil)`'s conversion (a package-level `var` + /// initializer's calls are not extracted here: pre-existing). + #[test] + fn pr2408_a_go_name_is_read_from_the_package_go_reads_it_from() { + let project = Project::new() + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "promql/parser/ast.go", + "package parser\n\ntype Node interface {\n\tString() string\n}\n\ntype Visitor interface {\n\tVisit(node Node, path []Node) (Visitor, error)\n}\n\nfunc Walk(v Visitor, node Node, path []Node) error {\n\treturn nil\n}\n", + ) + .file( + "discovery/kubernetes/node.go", + "package kubernetes\n\nimport apiv1 \"k8s.io/api/core/v1\"\n\ntype Node struct {\n\tname string\n}\n\nfunc nodeSource(n *apiv1.Node) string {\n\treturn n.Name\n}\n", + ) + .file( + "storage/interface.go", + "package storage\n\ntype Appender interface {\n\tAppend(ref uint64) error\n}\n\ntype Filter interface {\n\tAccept(value string) bool\n}\n", + ) + .file( + "storage/fanout.go", + "package storage\n\ntype fanout struct{}\n\nfunc (f *fanout) Appender() Appender {\n\treturn nil\n}\n", + ) + .file("discovery/aws/aws.go", "package aws\n\ntype Filter struct {\n\tName string\n}\n") + .file( + "web/api/v1/search.go", + "package v1\n\nimport \"example.com/app/storage\"\n\ntype ChainFilter struct{}\n\nfunc NewChainFilter(filters ...storage.Filter) *ChainFilter {\n\treturn &ChainFilter{}\n}\n", + ) + .file( + "errors.go", + "package gin\n\ntype Error struct {\n\tErr error\n}\n\nfunc (msg *Error) Error() string {\n\treturn msg.Err.Error()\n}\n\nvar _ error = (*Error)(nil)\n\nfunc newError(err error) *Error {\n\treturn &Error{Err: err}\n}\n", + ) + .file( + "binding/validator.go", + "package binding\n\nimport \"strings\"\n\ntype SliceValidationError []error\n\nfunc (err SliceValidationError) Error() string {\n\tvar b strings.Builder\n\tfor i := range err {\n\t\tb.WriteString(err[i].Error())\n\t}\n\treturn b.String()\n}\n", + ) + .file( + "handler/handler.go", + "package handler\n\ntype Handler struct{}\n\nfunc (h *Handler) Follow(c Context) error {\n\treturn nil\n}\n\ntype Context interface{}\n", + ) + .file("model/user.go", "package model\n\ntype Follow struct {\n\tFollowerID uint\n}\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + // `kind file:qualifiedName` of each edge out of `file`'s nodes (named + // `from` when given) to a target named `name` (upstream's `linksFrom`). + let links = |file: &str, name: &str, from: Option<&str>| -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind != "contains") + .filter(|e| { + graph.node(&e.source).is_some_and(|n| { + n.file_path == file && from.is_none_or(|from| n.name == from) + }) + }) + .filter_map(|e| graph.node(&e.target).map(|t| (e.kind.as_str(), t))) + .filter(|(_, t)| t.name == name) + .map(|(kind, t)| format!("{kind} {}:{}", t.file_path, t.qualified_name)) + .collect(); + out.sort(); + out.dedup(); + out + }; + assert_eq!( + links("promql/parser/ast.go", "Node", None), + ["references promql/parser/ast.go:Node"], + "{report}" + ); + assert!( + links("discovery/kubernetes/node.go", "Node", None).is_empty(), + "{report}" + ); + let errors = links("binding/validator.go", "Error", None); + assert!( + !errors.iter().any(|l| l.starts_with("instantiates")), + "{errors:?}" + ); + assert!( + !errors.contains(&"calls errors.go:Error".to_string()), + "{errors:?}" + ); + assert_eq!( + links("storage/fanout.go", "Appender", None), + ["references storage/interface.go:Appender"], + "{report}" + ); + assert_eq!( + links("web/api/v1/search.go", "Filter", None), + ["references storage/interface.go:Filter"], + "{report}" + ); + assert_eq!( + links("errors.go", "Error", Some("newError")), + ["instantiates errors.go:Error", "references errors.go:Error"], + "{report}" + ); + } + + /// Upstream #2416: a Go type position — a parameter or result type, a + /// composite literal's type — names a type of the package Go reads it + /// from, never a method or a function of that name; a generic receiver's + /// `[T]` declares `T`. Upstream's fixture. + #[test] + fn pr2416_a_go_type_position_resolves_to_a_type() { + let project = Project::new() + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "mvcc/key_index.go", + "package mvcc\n\ntype keyIndex struct {\n\tkey []byte\n}\n\ntype revisions []int64\n", + ) + .file( + "mvcc/index.go", + "package mvcc\n\ntype treeIndex struct {\n\ttree map[string]*keyIndex\n}\n\nfunc (ti *treeIndex) KeyIndex(keyi *keyIndex) *keyIndex {\n\treturn ti.keyIndex(keyi)\n}\n\nfunc (ti *treeIndex) keyIndex(keyi *keyIndex) *keyIndex {\n\treturn &keyIndex{key: keyi.key}\n}\n\nfunc (ti *treeIndex) revisions() revisions {\n\treturn revisions{1, 2}\n}\n", + ) + .file( + "mvcc/watchable_store.go", + "package mvcc\n\ntype contains interface {\n\tcontains(rev int64) bool\n}\n\nfunc kvsToEvents(c contains, revs [][]byte) {}\n", + ) + .file( + "discovery/aws/ec2.go", + "package aws\n\nimport \"context\"\n\ntype ec2Client interface {\n\tDescribeInstances(ctx context.Context) error\n}\n\ntype EC2Discovery struct {\n\tclient ec2Client\n}\n\nfunc (d *EC2Discovery) ec2Client(ctx context.Context) (ec2Client, error) {\n\treturn d.client, nil\n}\n", + ) + .file( + "util/zeropool/pool.go", + "package zeropool\n\ntype Pool[T any] struct {\n\titems []T\n}\n\nfunc (p *Pool[T]) Get() T {\n\tvar zero T\n\treturn zero\n}\n\nfunc (p *Pool[T]) Put(item T) {}\n\ntype LazyLoader[K comparable, V any] struct{}\n\nfunc (l *LazyLoader[K, V]) Load(key K) (V, error) {\n\tvar v V\n\treturn v, nil\n}\n", + ) + .file( + "prompb/types.pb.go", + "package prompb\n\ntype Histogram struct {\n\tCount isHistogram_Count\n}\n\ntype isHistogram_Count interface {\n\tisHistogram_Count()\n}\n\ntype Histogram_CountInt struct {\n\tCountInt uint64\n}\n\nfunc (*Histogram_CountInt) isHistogram_Count() {}\n\nfunc (m *Histogram) GetCount() isHistogram_Count {\n\treturn m.Count\n}\n", + ) + .file( + "prompb/codec.go", + "package prompb\n\nfunc FromIntHistogram(count uint64) Histogram {\n\treturn Histogram{\n\t\tCount: &Histogram_CountInt{CountInt: count},\n\t}\n}\n", + ) + .file( + "prompb/io/prometheus/write/v2/types.pb.go", + "package writev2\n\ntype Histogram_CountInt struct {\n\tCountInt uint64\n}\n\ntype Sample struct {\n\tts int64\n}\n\nfunc (s Sample) T() int64 { return s.ts }\n", + ) + .file("model/value/value.go", "package value\n\ntype T struct{}\n\ntype K struct{}\n") + .file( + "storage/remote/client_test.go", + "package remote\n\nimport (\n\t\"testing\"\n\n\tconfig_util \"github.com/prometheus/common/config\"\n)\n\ntype ClientConfig struct {\n\tURL *config_util.URL\n}\n\nfunc TestStoreHTTPErrorHandling(t *testing.T) {\n\tconf := &ClientConfig{\n\t\tURL: &config_util.URL{URL: nil},\n\t}\n\t_ = conf\n}\n", + ) + .file( + "scrape/target.go", + "package scrape\n\ntype Target struct{}\n\nfunc (t *Target) URL() string { return \"\" }\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + // `kind file:qualifiedName` of each `references` or `instantiates` + // edge out of `file`'s nodes (the one named `from` when given) to a + // target named `name` (upstream's `typeLinksFrom`). + let type_links = |file: &str, name: &str, from: Option<&str>| -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| matches!(e.kind.as_str(), "references" | "instantiates")) + .filter(|e| { + graph.node(&e.source).is_some_and(|n| { + n.file_path == file && from.is_none_or(|from| n.name == from) + }) + }) + .filter_map(|e| graph.node(&e.target).map(|t| (e.kind.as_str(), t))) + .filter(|(_, t)| t.name == name) + .map(|(kind, t)| format!("{kind} {}:{}", t.file_path, t.qualified_name)) + .collect(); + out.sort(); + out.dedup(); + out + }; + assert_eq!( + type_links("mvcc/index.go", "keyIndex", Some("KeyIndex")), + ["references mvcc/key_index.go:keyIndex"], + "{report}" + ); + assert_eq!( + type_links("mvcc/index.go", "keyIndex", Some("keyIndex")), + [ + "instantiates mvcc/key_index.go:keyIndex", + "references mvcc/key_index.go:keyIndex" + ], + "{report}" + ); + let calls: Vec<&str> = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.file_path == "mvcc/index.go" && n.name == "KeyIndex") + }) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.as_str()) + .collect(); + assert_eq!(calls, ["treeIndex::keyIndex"], "{report}"); + assert_eq!( + type_links("mvcc/index.go", "revisions", Some("revisions")), + [ + "instantiates mvcc/key_index.go:revisions", + "references mvcc/key_index.go:revisions" + ], + "{report}" + ); + assert_eq!( + type_links("mvcc/watchable_store.go", "contains", Some("kvsToEvents")), + ["references mvcc/watchable_store.go:contains"] + ); + assert_eq!( + type_links("prompb/types.pb.go", "isHistogram_Count", Some("GetCount")), + ["references prompb/types.pb.go:isHistogram_Count"] + ); + assert_eq!( + type_links("discovery/aws/ec2.go", "ec2Client", Some("ec2Client")), + ["references discovery/aws/ec2.go:ec2Client"], + "{report}" + ); + assert_eq!( + type_links("prompb/codec.go", "Histogram_CountInt", None), + ["instantiates prompb/types.pb.go:Histogram_CountInt"], + "{report}" + ); + for (name, from) in [("T", "Get"), ("T", "Put"), ("K", "Load"), ("V", "Load")] { + assert!( + type_links("util/zeropool/pool.go", name, Some(from)).is_empty(), + "{name} from {from}\n{report}" + ); + } + assert!( + type_links("storage/remote/client_test.go", "URL", None).is_empty(), + "{report}" + ); + } + + /// `qualifiedName` of every `calls` target out of the node named `name` + /// in `file`, sorted. + fn calls_from(graph: &Graph, file: &str, name: &str) -> Vec { + let id = &node_in(graph, file, name).id; + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls" && &e.source == id) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2167 (its Go half) and #2177: a call on an expression + /// arrives by its bare name, so its shape is read at the call. A + /// standard-library method name chained on a receiver not named after a + /// project type is the library's (cobra's `Flags().String(…)`, etcd's + /// `c.GetLogger().Error(…)`, net/http's `w.Header().Get(…)`); a + /// project-specific one keeps its match (`c.Root().Name()`); a chained + /// call is never a package function, nor a bare call a method; a link on a + /// later line is chained. Upstream's fixtures, with etcd's and cobra's + /// lines beside them. + #[test] + fn pr2167_a_go_call_on_an_expression_is_read_by_its_shape() { + let project = Project::new() + .file("go.mod", "module example.com/app\n\ngo 1.21\n") + .file( + "flags.go", + "package app\n\ntype customString struct{}\n\nfunc (c customString) String() string { return \"\" }\n\ntype FlagSet struct{}\n\nfunc Flags() *FlagSet { return nil }\n", + ) + .file("cmd.go", "package app\n\nfunc run() {\n\tFlags().String(\"name\", \"\", \"\")\n}\n") + .file( + "context.go", + "package gin\n\ntype Context struct{}\n\nfunc (c *Context) Get(key string) (any, bool) { return nil, false }\n\nfunc handler(w Writer) string {\n\treturn w.Header().Get(\"Content-Type\")\n}\n", + ) + .file( + "client/lease.go", + "package client\n\ntype ErrKeepAliveHalted struct{}\n\nfunc (e ErrKeepAliveHalted) Error() string { return \"\" }\n\ntype Command struct{}\n\nfunc (c *Command) Name() string { return \"\" }\n\nfunc (c *Command) Root() *Command { return c }\n\nfunc helper() int { return 0 }\n\ntype Client struct{}\n\nfunc (c *Client) GetLogger() *Client { return c }\n\nfunc (c *Client) Info(msg string) {}\n", + ) + .file( + "client/retry.go", + "package client\n\nfunc retry(c *Client, cmd *Command) {\n\tc.GetLogger().Error(\"getToken failed\")\n\t_ = cmd.Root().Name()\n\t_ = c.GetLogger().helper()\n\tc.GetLogger().\n\t\tInfo(\"done\")\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + assert!( + !calls_from(graph, "cmd.go", "run") + .iter() + .any(|t| t.ends_with("customString::String")), + "{report}" + ); + assert!( + !calls_from(graph, "context.go", "handler").contains(&"Context::Get".to_string()), + "{report}" + ); + let retry = calls_from(graph, "client/retry.go", "retry"); + assert!( + !retry.contains(&"ErrKeepAliveHalted::Error".to_string()), + "{retry:?}\n{report}" + ); + assert!( + retry.contains(&"Command::Name".to_string()), + "{retry:?}\n{report}" + ); + assert!( + !retry.contains(&"helper".to_string()), + "{retry:?}\n{report}" + ); + assert!( + retry.contains(&"Client::Info".to_string()), + "{retry:?}\n{report}" + ); + } + + /// The module upstream's #2444 resolution tests index (the gRPC handlers + /// with CRLF line ends), with namesakes where name matching looked first. + const ASSERTED_CALLS: [(&str, &str); 8] = [ + ("go.mod", "module example.com/app\n\ngo 1.22\n"), + ( + "pb/rpc_grpc.pb.go", + "package pb\r\n\r\nimport \"context\"\r\n\r\ntype KVServer interface {\r\n\tRange(context.Context, *RangeRequest) (*RangeResponse, error)\r\n\tPut(context.Context, *RangeRequest) (*RangeResponse, error)\r\n}\r\n\r\ntype UnimplementedKVServer struct{}\r\n\r\nfunc (UnimplementedKVServer) Range(context.Context, *RangeRequest) (*RangeResponse, error) {\r\n\treturn nil, nil\r\n}\r\n\r\nfunc (UnimplementedKVServer) Put(context.Context, *RangeRequest) (*RangeResponse, error) {\r\n\treturn nil, nil\r\n}\r\n\r\ntype RangeRequest struct{}\r\n\r\ntype RangeResponse struct{}\r\n\r\nfunc _KV_Range_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor func(context.Context, interface{}, func(context.Context, interface{}) (interface{}, error)) (interface{}, error)) (interface{}, error) {\r\n\tin := new(RangeRequest)\r\n\tif err := dec(in); err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\tif interceptor == nil {\r\n\t\treturn srv.(KVServer).Range(ctx, in)\r\n\t}\r\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\r\n\t\treturn srv.(KVServer).Range(ctx, req.(*RangeRequest))\r\n\t}\r\n\treturn interceptor(ctx, in, handler)\r\n}\r\n\r\nfunc _KV_Put_Handler(srv interface{}, ctx context.Context, in *RangeRequest) (interface{}, error) {\r\n\treturn srv.(KVServer).\r\n\t\tPut(ctx, in)\r\n}\r\n", + ), + ( + "internal/bufconn/bufconn.go", + "package bufconn\n\nimport \"io\"\n\ntype pipe struct{}\n\nfunc (p *pipe) Close() error { return nil }\n\nfunc (p *pipe) closeWrite() error { return nil }\n\nfunc (p *pipe) close() {}\n\ntype conn struct {\n\tio.Reader\n\tio.Writer\n}\n\nfunc (c *conn) Close() error {\n\terr1 := c.Reader.(*pipe).Close()\n\terr2 := c.Writer.(*pipe).closeWrite()\n\tc.Reader.(*pipe).close()\n\tif err1 != nil {\n\t\treturn err1\n\t}\n\treturn err2\n}\n", + ), + ( + "alpha/alpha.go", + "package alpha\n\ntype Store interface {\n\tFetch(key string) string\n}\n\ntype sink struct{}\n\nfunc (s *sink) Fetch(key string) string { return \"\" }\n\nfunc (s *sink) Flush() error { return nil }\n\nfunc (s *sink) Read(p []byte) (int, error) { return 0, nil }\n\ntype Ctx struct{}\n\nfunc (c *Ctx) Done() <-chan struct{} { return nil }\n", + ), + ( + "storage/storage.go", + "package storage\n\ntype Store interface {\n\tFetch(key string) string\n}\n\ntype Base struct{}\n\nfunc (b *Base) Flush() error { return nil }\n", + ), + ( + "store/store.go", + "package store\n\nimport (\n\t\"context\"\n\n\t\"example.com/app/storage\"\n)\n\ntype Reader interface {\n\tRead(p []byte) (int, error)\n}\n\ntype ReadCloser interface {\n\tReader\n\tClose() error\n}\n\ntype Wrapper struct {\n\t*storage.Base\n\tname string\n}\n\ntype Job struct{}\n\nfunc (j *Job) Start() {}\n\ntype Factory interface {\n\tNew() *Job\n}\n\ntype Builder struct{}\n\nfunc (b *Builder) Add(n int) *Builder { return b }\n\ntype Ctx = context.Context\n\ntype Keeper = storage.Store\n", + ), + ( + "store/use.go", + "package store\n\nimport (\n\t\"net/http\"\n\n\t\"example.com/app/storage\"\n)\n\ntype sink struct{}\n\nfunc (s *sink) Flush() error { return nil }\n\nfunc (s *sink) Read(p []byte) (int, error) { return 0, nil }\n\nfunc (s *sink) Fetch(key string) string { return \"\" }\n\nfunc Use(v any, w http.ResponseWriter, p []byte) string {\n\tv.(*Wrapper).Flush()\n\tv.(ReadCloser).Read(p)\n\tw.(http.Flusher).Flush()\n\treturn v.(storage.Store).Fetch(\"k\")\n}\n\nfunc Anon(v any) string {\n\treturn v.(interface{ Fetch(key string) string }).Fetch(\"k\")\n}\n\nfunc Run(v any) {\n\tv.(Factory).New().Start()\n}\n\nfunc lookup(key string) any { return nil }\n\nfunc Quoted() string {\n\treturn lookup(\")\").(storage.Store).Fetch(\"k\")\n}\n\nfunc (s *sink) Add(n int) *sink { return s }\n\nfunc Build(v any) {\n\tv.(*Builder).Add(1).Add(2)\n}\n\nfunc Wait(v any) {\n\tv.(Ctx).Done()\n}\n\nfunc Keep(v any) string {\n\treturn v.(Keeper).Fetch(\"k\")\n}\n", + ), + ( + "dot/dot.go", + "package dot\n\nimport . \"example.com/app/storage\"\n\nfunc Get(v any) string {\n\treturn v.(Store).Fetch(\"k\")\n}\n", + ), + ]; + + /// Upstream #2444: a Go call through a type assertion is a call of the + /// asserted type's method — its own, the one its interface declares, or + /// one embedding promotes into it; written bare, as a pointer, through a + /// project package or a dot import, or through an alias — and links + /// nothing for a type from outside the project, a type literal or an + /// alias of an outside type. A later link of the chain is left to the type + /// the call before it returns. Upstream's fixture. + #[test] + fn pr2444_a_go_call_through_a_type_assertion_reaches_the_asserted_types_method() { + let project = ASSERTED_CALLS + .iter() + .fold(Project::new(), |project, (path, text)| { + project.file(path, text) + }) + .index(); + let graph = project.graph(); + let report = graph.report(); + // `line file::qualifiedName` of every call the function or method + // `qualified` in `file` makes, sorted (upstream's `calls`). + let calls = |file: &str, qualified: &str| -> Vec { + let caller = graph + .nodes + .iter() + .find(|n| { + n.file_path == file + && n.qualified_name == qualified + && matches!(n.kind.as_str(), "function" | "method") + }) + .unwrap_or_else(|| panic!("{qualified} in {file}\n{report}")); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls" && e.source == caller.id) + .filter_map(|e| { + let target = graph.node(&e.target)?; + Some(format!( + "{} {}::{}", + e.line?, target.file_path, target.qualified_name + )) + }) + .collect(); + out.sort(); + out + }; + assert_eq!( + calls("pb/rpc_grpc.pb.go", "_KV_Range_Handler"), + [ + "30 pb/rpc_grpc.pb.go::KVServer::Range", + "33 pb/rpc_grpc.pb.go::KVServer::Range", + ], + "{report}" + ); + // A chain continued on the next line. + assert_eq!( + calls("pb/rpc_grpc.pb.go", "_KV_Put_Handler"), + ["39 pb/rpc_grpc.pb.go::KVServer::Put"], + "{report}" + ); + assert_eq!( + calls("internal/bufconn/bufconn.go", "conn::Close"), + [ + "19 internal/bufconn/bufconn.go::pipe::Close", + "20 internal/bufconn/bufconn.go::pipe::closeWrite", + "21 internal/bufconn/bufconn.go::pipe::close", + ], + "{report}" + ); + // *Wrapper's Flush is promoted from *storage.Base; ReadCloser's Read + // from Reader; http.Flusher's Flush links nothing. + assert_eq!( + calls("store/use.go", "Use"), + [ + "18 storage/storage.go::Base::Flush", + "19 store/store.go::Reader::Read", + "21 storage/storage.go::Store::Fetch", + ], + "{report}" + ); + assert_eq!( + calls("dot/dot.go", "Get"), + ["6 storage/storage.go::Store::Fetch"], + "{report}" + ); + // An alias is the type it names. + assert_eq!( + calls("store/use.go", "Keep"), + ["49 storage/storage.go::Store::Fetch"], + "{report}" + ); + // A string argument holding a parenthesis is not the end of the call. + assert_eq!( + calls("store/use.go", "Quoted"), + [ + "35 storage/storage.go::Store::Fetch", + "35 store/use.go::lookup", + ], + "{report}" + ); + assert!(calls("store/use.go", "Anon").is_empty(), "{report}"); + // Ctx is an alias of context.Context. + assert!(calls("store/use.go", "Wait").is_empty(), "{report}"); + let flush = &node_in(graph, "store/use.go", "Flush").id; + assert!( + !graph + .edges + .iter() + .any(|e| e.kind == "calls" && &e.target == flush), + "{report}" + ); + assert_eq!( + calls("store/use.go", "Run"), + [ + "29 store/store.go::Factory::New", + "29 store/store.go::Job::Start", + ], + "{report}" + ); + // Both `Add` calls start at `v`, so nothing tells them apart. + let mut build = calls("store/use.go", "Build"); + build.dedup(); + assert_eq!(build, ["41 store/store.go::Builder::Add"], "{report}"); + let decoys: Vec<&str> = graph + .nodes + .iter() + .filter(|n| n.kind == "method") + .filter(|n| { + n.file_path == "alpha/alpha.go" + || (n.file_path == "store/use.go" && n.qualified_name.starts_with("sink::")) + || (n.file_path == "pb/rpc_grpc.pb.go" + && n.qualified_name.starts_with("UnimplementedKVServer::")) + }) + .map(|n| n.id.as_str()) + .collect(); + assert_eq!(decoys.len(), 11, "{report}"); + for decoy in decoys { + assert!( + !graph.edges.iter().any(|e| e.kind == "calls" + && e.target == decoy + && e.provenance.as_deref() != Some("heuristic")), + "{}\n{report}", + graph.describe(decoy) + ); + } + } + + /// Upstream #2438: a type declared on its own takes the comment above + /// `type`; a group's comment is the group's unless the group holds one + /// type without a comment of its own; a comment after code on its line is + /// that line's, never the next declaration's. Upstream's fixture, less its + /// `const sides` (a Go constant carries no docstring here: pre-existing). + #[test] + fn pr2438_a_go_type_declared_on_its_own_keeps_its_doc_comment() { + let source = "// Copyright 2026 The Shapes Authors.\n\n// Package shapes is documented.\npackage shapes\n\ntype Bare struct{}\n\n// Point is documented on its own.\ntype Point struct{ X, Y int }\n\n// Shape is an interface documented on its own.\ntype Shape interface {\n\tArea() float64\n}\n\n// ID is a defined type documented on its own.\ntype ID int\n\n// Set is a generic type documented on its own.\ntype Set[T comparable] map[T]struct{}\n\n// Alias is an alias documented on its own.\ntype Alias = Point\n\n/*\nBox is documented in a block comment.\n*/\ntype Box struct{}\n\n// Kinds of shapes. The comment documents the group.\ntype (\n\tCircle struct{}\n\t// Square is documented inside the group.\n\tSquare struct{}\n)\n\n// Lone is the only member of its group.\ntype (\n\tLone int\n)\n\n// A group of one whose member has its own comment.\ntype (\n\t// Own is documented inside its group.\n\tOwn int\n)\n\n// Pair groups a type with its alias.\ntype (\n\tFirst int\n\tSecond = First\n)\n\ntype (\n\tCelsius float64 // degrees C.\n\tKelvin float64\n)\n\n// sides is documented above its line.\nconst sides = 4 /* four */ // sides of a square.\n\n// Rect is documented below a constant's line.\ntype Rect struct{}\n\nvar origin = Point{} // the origin.\ntype Polygon struct{}\n\n// Area is documented as before.\nfunc Area(s Shape) float64 { return s.Area() }\n\n// Norm is documented as before.\nfunc (p Point) Norm() int { return p.X } // Norm's line comment.\nfunc Next() int { return 0 }\n"; + for (label, text) in with_line_ends(source) { + let project = Project::new().file("shapes/shapes.go", &text).index(); + let graph = project.graph(); + let file = "shapes/shapes.go"; + let doc = |kind: &str, name: &str| docstring_of(graph, file, kind, name); + let found: Vec<(&str, Option)> = [ + ("struct", "Bare"), + ("struct", "Point"), + ("interface", "Shape"), + ("type_alias", "ID"), + ("type_alias", "Set"), + ("type_alias", "Alias"), + ("struct", "Box"), + ("struct", "Circle"), + ("struct", "Square"), + ("type_alias", "Lone"), + ("type_alias", "Own"), + ("type_alias", "First"), + ("type_alias", "Second"), + ("type_alias", "Celsius"), + ("type_alias", "Kelvin"), + ("struct", "Rect"), + ("struct", "Polygon"), + ("function", "Area"), + ("method", "Norm"), + ("function", "Next"), + ] + .into_iter() + .map(|(kind, name)| (name, doc(kind, name))) + .collect(); + let want: Vec<(&str, Option)> = [ + ("Bare", None), + ("Point", Some("Point is documented on its own.")), + ( + "Shape", + Some("Shape is an interface documented on its own."), + ), + ("ID", Some("ID is a defined type documented on its own.")), + ("Set", Some("Set is a generic type documented on its own.")), + ("Alias", Some("Alias is an alias documented on its own.")), + ("Box", Some("Box is documented in a block comment.")), + ("Circle", None), + ("Square", Some("Square is documented inside the group.")), + ("Lone", Some("Lone is the only member of its group.")), + ("Own", Some("Own is documented inside its group.")), + ("First", None), + ("Second", None), + ("Celsius", None), + ("Kelvin", None), + ("Rect", Some("Rect is documented below a constant's line.")), + ("Polygon", None), + ("Area", Some("Area is documented as before.")), + ("Norm", Some("Norm is documented as before.")), + ("Next", None), + ] + .into_iter() + .map(|(name, doc)| (name, doc.map(str::to_string))) + .collect(); + assert_eq!(found, want, "{label}"); + } + } +} + +mod rust { + use super::support::{Graph, Project}; + + /// `kind:qualifiedName` of every `calls` / `references` / `instantiates` + /// target out of the functions named `name` (upstream's `targets`). + fn targets(graph: &Graph, name: &str) -> Vec { + let from: Vec<&str> = graph + .nodes + .iter() + .filter(|n| n.kind == "function" && n.name == name) + .map(|n| n.id.as_str()) + .collect(); + graph + .edges + .iter() + .filter(|e| matches!(e.kind.as_str(), "calls" | "references" | "instantiates")) + .filter(|e| from.contains(&e.source.as_str())) + .filter_map(|e| graph.node(&e.target)) + .map(|n| format!("{}:{}", n.kind, n.qualified_name)) + .collect() + } + + /// The files every edge but `contains` out of `file`'s nodes lands in + /// (upstream's `targetsFrom`). + fn target_files(graph: &Graph, file: &str) -> Vec { + graph + .edges + .iter() + .filter(|e| e.kind != "contains") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.file_path.clone()) + .collect() + } + + /// Upstream #2124: a bare Rust name reaches only what is in scope — + /// `Some(x)` / `Ok(x)` the prelude's, not a project variant or a struct + /// another file declares; a variant through a `use` of its enum's `*`; a + /// path (`crate::error::Result`) is no prelude lookup; a same-file struct + /// shadows the prelude. Upstream's fixture, its hygiene structs `pub` (a + /// private one is out of reach already), with the audit's `drop(v)`: with + /// no `use` it is the prelude's, not a `pub fn drop` in `util.rs`; a `use` + /// of it, or a glob over its module, shadows the prelude. + #[test] + fn pr2124_a_bare_rust_name_reaches_what_is_in_scope() { + let project = Project::new() + .file("Cargo.toml", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n") + .file( + "src/lowargs.rs", + "pub enum EncodingMode {\n Auto,\n Some(u8),\n Disabled,\n}\n\npub enum ParseResult {\n Ok(T),\n Err(String),\n}\n", + ) + .file( + "src/hygiene.rs", + "pub struct Ok;\npub struct Result;\n\nfn local() -> Ok { Ok }\n", + ) + .file( + "src/decompress.rs", + "pub fn find(cmd: u8) -> Option {\n if cmd > 0 { return Some(cmd); }\n let r: std::result::Result = Ok(cmd);\n None\n}\n", + ) + .file( + "src/error.rs", + "pub struct Error;\npub type Result = std::result::Result;\n", + ) + .file( + "src/api.rs", + "pub fn load() -> crate::error::Result { unimplemented!() }\n", + ) + .file( + "src/modes.rs", + "use crate::lowargs::EncodingMode::*;\n\npub fn pick() -> crate::lowargs::EncodingMode { Some(1) }\n", + ) + .file("src/util.rs", "pub fn drop(_value: T) {}\n") + .file( + "src/release.rs", + "pub fn release(v: Vec) {\n drop(v);\n}\n", + ) + .file( + "src/flush.rs", + "use crate::util::drop;\n\npub fn flush(v: Vec) {\n drop(v);\n}\n", + ) + .file( + "src/tidy.rs", + "use crate::util::*;\n\npub fn tidy(v: Vec) {\n drop(v);\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let find: Vec = targets(graph, "find") + .into_iter() + .filter(|t| t.contains("Some") || t.contains("Ok") || t.contains("Result")) + .collect(); + assert!(find.is_empty(), "{find:?}\n{report}"); + assert!( + !targets(graph, "release").contains(&"function:drop".to_string()), + "{report}" + ); + assert!( + targets(graph, "flush").contains(&"function:drop".to_string()), + "{report}" + ); + assert!( + targets(graph, "tidy").contains(&"function:drop".to_string()), + "{report}" + ); + assert!( + targets(graph, "pick").contains(&"enum_member:EncodingMode::Some".to_string()), + "{report}" + ); + // A path names its module: `crate::error::Result` is `error.rs`'s + // alias, not `hygiene.rs`'s struct. + let load = targets(graph, "load"); + assert!( + load.contains(&"type_alias:Result".to_string()) + && !load.contains(&"struct:Result".to_string()), + "{load:?}\n{report}" + ); + assert!( + targets(graph, "local").contains(&"struct:Ok".to_string()), + "{report}" + ); + } + + /// Upstream #2214: a bare Rust type or function from another file is in + /// scope only through a `use` that binds it or a glob over its module; one + /// the file imports from outside the project (`use + /// std::task::{Context, Poll}`) is that outside item; a path names its + /// module (`io::Result` under `use std::io` is std's, `std::io::Error` + /// too); a crate-local `use` and an inline test module still reach the + /// project's item. Upstream's fixture, with two more: an outside import + /// shadows a project glob (`poll.rs`), and a crate `Cargo.toml` depends on + /// is outside the project (`use futures::Stream`). + #[test] + fn pr2214_a_rust_name_is_in_scope_only_through_its_module_or_a_use() { + let project = Project::new() + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nfutures = \"0.3\"\n", + ) + .file( + "src/lib.rs", + "pub mod runtime;\npub mod io;\npub mod net;\npub mod stream;\npub mod poll;\npub mod source;\npub mod sink;\npub mod handle;\n", + ) + .file("src/stream.rs", "pub trait Stream {}\n") + .file( + "src/poll.rs", + "use crate::runtime::context::*;\nuse std::task::Context;\n\npub fn wake(cx: &mut Context<'_>) {\n let _ = cx;\n}\n", + ) + .file( + "src/source.rs", + "use futures::Stream;\n\npub fn drain(s: impl Stream) {\n let _ = s;\n}\n", + ) + .file( + "src/sink.rs", + "use crate::stream::Stream;\n\npub fn fill(s: impl Stream) {\n let _ = s;\n}\n", + ) + .file( + "src/runtime/mod.rs", + "pub mod context;\npub mod task;\npub mod driver;\n", + ) + .file( + "src/runtime/context.rs", + "pub struct Context {\n pub depth: u8,\n}\n\npub fn try_current() -> u8 {\n 0\n}\n", + ) + .file( + "src/handle.rs", + "pub fn current() -> u8 {\n try_current()\n}\n", + ) + .file( + "src/runtime/task/mod.rs", + "pub type Result = std::result::Result;\n", + ) + .file("src/io/mod.rs", "pub mod copy;\n") + .file("src/io/copy.rs", "pub struct Error;\n") + .file( + "src/net.rs", + "use std::io;\nuse std::task::{Context, Poll};\n\npub fn poll_ready(cx: &mut Context<'_>) -> Poll> {\n let _ = cx;\n Poll::Ready(Ok(()))\n}\n\npub fn fail() -> std::io::Error {\n std::io::Error::other(\"x\")\n}\n", + ) + .file( + "src/runtime/driver.rs", + "use crate::runtime::context::Context;\n\npub fn enter(ctx: &Context) -> u8 {\n ctx.depth\n}\n", + ) + .file("tests/support/panic.rs", "pub fn test_panic() {}\n") + .file( + "tests/io_panic.rs", + "mod support {\n pub mod panic;\n}\n\nuse support::panic::test_panic;\n\n#[test]\nfn panics() {\n test_panic();\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let net = target_files(graph, "src/net.rs"); + for outside in [ + "src/runtime/context.rs", + "src/runtime/task/mod.rs", + "src/io/copy.rs", + ] { + assert!(!net.contains(&outside.to_string()), "{outside}\n{report}"); + } + assert!( + target_files(graph, "src/runtime/driver.rs") + .contains(&"src/runtime/context.rs".to_string()), + "{report}" + ); + assert!( + target_files(graph, "tests/io_panic.rs") + .contains(&"tests/support/panic.rs".to_string()), + "{report}" + ); + // The audit's `try_current()` with no `use`: another file's function. + assert!( + !target_files(graph, "src/handle.rs").contains(&"src/runtime/context.rs".to_string()), + "{report}" + ); + assert!( + !target_files(graph, "src/poll.rs").contains(&"src/runtime/context.rs".to_string()), + "{report}" + ); + assert!( + !target_files(graph, "src/source.rs").contains(&"src/stream.rs".to_string()), + "{report}" + ); + assert!( + target_files(graph, "src/sink.rs").contains(&"src/stream.rs".to_string()), + "{report}" + ); + } + + /// A Rust project of `files` under `src/`, with upstream's #2354 manifest. + fn rust_project(files: &[(&str, &str)]) -> super::support::Indexed { + let mut project = Project::new().file( + "Cargo.toml", + "[package]\nname = \"repro\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ); + for (file, text) in files { + project = project.file(&format!("src/{file}"), text); + } + project.index() + } + + /// The node of `kind` named `name` declared in `src/`. + fn declared<'g>( + graph: &'g Graph, + kind: &str, + file: &str, + name: &str, + ) -> &'g super::support::NodeRow { + let path = format!("src/{file}"); + graph + .nodes + .iter() + .find(|n| n.kind == kind && n.file_path == path && n.name == name) + .unwrap_or_else(|| panic!("{kind} {name} in {path}\n{}", graph.report())) + } + + /// `file:name` of every symbol with a `references` edge to `id`, `src/` + /// left out (upstream's `referencers`). + fn referencers(graph: &Graph, id: &str) -> Vec { + let names: std::collections::BTreeSet = graph + .edges + .iter() + .filter(|e| e.kind == "references" && e.target == id) + .filter_map(|e| graph.node(&e.source)) + .map(|n| { + let file = n.file_path.strip_prefix("src/").unwrap_or(&n.file_path); + format!("{file}:{}", n.name) + }) + .collect(); + names.into_iter().collect() + } + + /// `kind:qualifiedName` of every edge but `contains` out of the function + /// named `name`. + fn outgoing(graph: &Graph, name: &str) -> Vec { + let from = graph + .nodes + .iter() + .find(|n| n.kind == "function" && n.name == name) + .unwrap_or_else(|| panic!("function {name}\n{}", graph.report())); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind != "contains" && e.source == from.id) + .filter_map(|e| { + graph + .node(&e.target) + .map(|n| format!("{}:{}", e.kind, n.qualified_name)) + }) + .collect(); + out.sort(); + out + } + + const MODE: &str = "pub enum Mode {\n A,\n B,\n C(u8),\n D { x: u8 },\n}\n\npub fn takes(_m: Mode) {}\n\npub fn current() -> Mode { Mode::B }\n"; + + /// Upstream #2354 (#2328): a variant path in an expression and in match + /// arms, written through the module, references the enum. + #[test] + fn pr2354_a_variant_path_through_its_module_references_the_enum() { + let project = rust_project(&[ + ("mode.rs", MODE), + ( + "variant_use.rs", + "use crate::mode;\n\npub fn code(flag: bool) -> u8 {\n let m = if flag { mode::Mode::A } else { mode::Mode::B };\n match m {\n mode::Mode::A => 1,\n mode::Mode::B => 2,\n _ => 3,\n }\n}\n", + ), + ( + "main.rs", + "mod mode;\nmod variant_use;\n\nfn main() {\n mode::takes(mode::Mode::A);\n let _ = variant_use::code(true);\n}\n", + ), + ]); + let graph = project.graph(); + let mode = declared(graph, "enum", "mode.rs", "Mode"); + assert_eq!( + referencers(graph, &mode.id), + [ + "main.rs:main", + "mode.rs:current", + "mode.rs:takes", + "variant_use.rs:code" + ], + "{}", + graph.report() + ); + } + + /// Upstream #2354: a bare `Mode::A` after `use crate::mode::Mode;`, at the + /// receiver's column. + #[test] + fn pr2354_a_bare_variant_path_after_a_use_of_its_enum() { + let project = rust_project(&[ + ("lib.rs", "pub mod mode;\npub mod bare;\n"), + ("mode.rs", MODE), + ( + "bare.rs", + "use crate::mode::{self, Mode};\n\npub fn pick() -> u8 {\n let _m = Mode::B;\n 0\n}\n\npub fn score() -> u8 {\n match mode::current() {\n Mode::A => 1,\n _ => 0,\n }\n}\n", + ), + ]); + let graph = project.graph(); + let mode = declared(graph, "enum", "mode.rs", "Mode"); + assert_eq!( + referencers(graph, &mode.id), + [ + "bare.rs:pick", + "bare.rs:score", + "mode.rs:current", + "mode.rs:takes" + ], + "{}", + graph.report() + ); + let pick = declared(graph, "function", "bare.rs", "pick"); + let at: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "references" && e.source == pick.id) + .map(|e| format!("{}:{}", e.line.unwrap_or(0), e.col.unwrap_or(0))) + .collect(); + assert_eq!(at, ["4:13"], "{}", graph.report()); + } + + /// Upstream #2354: tuple and struct variant patterns, and a variant read + /// as a value. + #[test] + fn pr2354_tuple_and_struct_variant_patterns_and_a_variant_value() { + let project = rust_project(&[ + ("lib.rs", "pub mod mode;\npub mod pat;\n"), + ("mode.rs", MODE), + ( + "pat.rs", + "use crate::mode::{self, Mode};\n\npub fn tuple() -> u8 {\n match mode::current() {\n Mode::C(x) => x,\n _ => 0,\n }\n}\n\npub fn structure() -> u8 {\n if let crate::mode::Mode::D { x } = mode::current() { x } else { 0 }\n}\n\npub fn ctor_value(xs: Vec) -> usize {\n xs.into_iter().map(Mode::C).count()\n}\n", + ), + ]); + let graph = project.graph(); + let mode = declared(graph, "enum", "mode.rs", "Mode"); + assert_eq!( + referencers(graph, &mode.id), + [ + "mode.rs:current", + "mode.rs:takes", + "pat.rs:ctor_value", + "pat.rs:structure", + "pat.rs:tuple" + ], + "{}", + graph.report() + ); + } + + /// Upstream #2354: constructing a tuple or struct variant stays linked to + /// the variant itself. + #[test] + fn pr2354_constructing_a_variant_stays_linked_to_the_variant() { + let project = rust_project(&[ + ("lib.rs", "pub mod mode;\npub mod build;\n"), + ("mode.rs", MODE), + ( + "build.rs", + "use crate::mode::Mode;\n\npub fn build() {\n let _c = Mode::C(3);\n let _d = Mode::D { x: 2 };\n}\n", + ), + ]); + let graph = project.graph(); + assert_eq!( + outgoing(graph, "build"), + ["calls:Mode::C", "instantiates:Mode::D"], + "{}", + graph.report() + ); + } + + /// Upstream #2354: `Self::A` inside `impl Mode`, in the enum's file and in + /// another one. + #[test] + fn pr2354_self_variant_in_the_enums_impl() { + let mode_impl = format!( + "{MODE}\nimpl Mode {{\n pub fn flip(&self) -> u8 {{\n match self {{\n Self::A => 1,\n _ => 0,\n }}\n }}\n}}\n" + ); + let project = rust_project(&[ + ("lib.rs", "pub mod mode;\npub mod ops;\n"), + ("mode.rs", &mode_impl), + ( + "ops.rs", + "use crate::mode::Mode;\n\nimpl Mode {\n pub fn is_b(&self) -> bool {\n if let Self::B = self { true } else { false }\n }\n}\n", + ), + ]); + let graph = project.graph(); + let mode = declared(graph, "enum", "mode.rs", "Mode"); + assert_eq!( + referencers(graph, &mode.id), + [ + "mode.rs:current", + "mode.rs:flip", + "mode.rs:takes", + "ops.rs:is_b" + ], + "{}", + graph.report() + ); + } + + /// Upstream #2354: `Self::A` names the impl's type, never through a path + /// written elsewhere on its line (`other::Mode::Z`, whose enum declares no + /// `A`). + #[test] + fn pr2354_a_self_variant_ignores_another_path_on_its_line() { + let project = rust_project(&[ + ("lib.rs", "pub mod mode;\npub mod other;\npub mod ops;\n"), + ("mode.rs", MODE), + ("other.rs", "pub enum Mode {\n Y,\n Z,\n}\n"), + ( + "ops.rs", + "use crate::mode::Mode;\nuse crate::other;\n\nimpl Mode {\n pub fn to_other(&self) -> u8 {\n match self {\n Self::A => other::Mode::Z as u8,\n _ => 0,\n }\n }\n}\n", + ), + ]); + let graph = project.graph(); + let mode = declared(graph, "enum", "mode.rs", "Mode"); + assert_eq!( + referencers(graph, &mode.id), + ["mode.rs:current", "mode.rs:takes", "ops.rs:to_other"], + "{}", + graph.report() + ); + let other = declared(graph, "enum", "other.rs", "Mode"); + assert_eq!( + referencers(graph, &other.id), + ["ops.rs:to_other"], + "{}", + graph.report() + ); + } + + /// Upstream #2354: a variant imported by name and used bare has no path to + /// read the enum from, and never lands on another module's namesake. + #[test] + fn pr2354_a_variant_imported_by_name_has_no_path_to_read() { + let project = rust_project(&[ + ("lib.rs", "pub mod mode;\npub mod other;\npub mod glob;\n"), + ("mode.rs", MODE), + ("other.rs", "pub enum Mode {\n A,\n Z,\n}\n"), + ( + "glob.rs", + "use crate::mode::Mode::*;\n\npub fn bare(m: u8) -> u8 {\n match m {\n 0 => { let _ = A; 1 }\n _ => 0,\n }\n}\n", + ), + ]); + let graph = project.graph(); + let mode = declared(graph, "enum", "mode.rs", "Mode"); + assert_eq!( + referencers(graph, &mode.id), + ["mode.rs:current", "mode.rs:takes"], + "{}", + graph.report() + ); + let other = declared(graph, "enum", "other.rs", "Mode"); + assert!( + referencers(graph, &other.id).is_empty(), + "{}", + graph.report() + ); + } + + /// Upstream #2354: the path picks the type that declares the variant, + /// never a same-named type without it; another crate's enum is its own. + #[test] + fn pr2354_the_path_picks_the_type_declaring_the_variant() { + let project = rust_project(&[ + ( + "lib.rs", + "pub mod matching;\npub mod other;\npub mod user;\n", + ), + ( + "matching.rs", + "pub enum Match {\n None,\n Ignore,\n}\n", + ), + ("other.rs", "pub struct Match;\n"), + ( + "user.rs", + "use crate::matching::Match;\n\npub enum ColorChoice {\n Auto,\n Ansi,\n}\n\npub fn first() -> u8 {\n let _m = Match::None;\n 0\n}\n\npub fn color() -> u8 {\n let _c = termcolor::ColorChoice::AlwaysAnsi;\n 0\n}\n", + ), + ]); + let graph = project.graph(); + let matching = declared(graph, "enum", "matching.rs", "Match"); + assert_eq!( + referencers(graph, &matching.id), + ["user.rs:first"], + "{}", + graph.report() + ); + let other = declared(graph, "struct", "other.rs", "Match"); + assert!( + referencers(graph, &other.id).is_empty(), + "{}", + graph.report() + ); + let color = declared(graph, "enum", "user.rs", "ColorChoice"); + assert!( + referencers(graph, &color.id).is_empty(), + "{}", + graph.report() + ); + } + + /// Upstream #2354: an associated const or function read through a type is + /// not a variant path. + #[test] + fn pr2354_an_associated_const_or_function_is_no_variant_path() { + let mode_impl = format!( + "{MODE}\nimpl Mode {{\n pub const ALL: [u8; 2] = [0, 1];\n pub fn count() -> usize {{ Self::ALL.len() }}\n}}\n" + ); + let project = rust_project(&[ + ("lib.rs", "pub mod mode;\npub mod limits;\npub mod user;\n"), + ("mode.rs", &mode_impl), + ( + "limits.rs", + "pub struct Limits;\n\nimpl Limits {\n pub const MAX: u8 = 3;\n pub fn new() -> Self { Limits }\n}\n", + ), + ( + "user.rs", + "use crate::limits::Limits;\nuse crate::mode::Mode;\n\npub fn cap() -> u8 {\n Limits::MAX\n}\n\npub fn all() -> usize {\n Mode::ALL.len()\n}\n\npub fn make() {\n let _l = Limits::new();\n let _f = Limits::new;\n}\n", + ), + ]); + let graph = project.graph(); + let mode = declared(graph, "enum", "mode.rs", "Mode"); + assert_eq!( + referencers(graph, &mode.id), + ["mode.rs:current", "mode.rs:takes"], + "{}", + graph.report() + ); + let limits = declared(graph, "struct", "limits.rs", "Limits"); + assert!( + referencers(graph, &limits.id).is_empty(), + "{}", + graph.report() + ); + assert_eq!( + outgoing(graph, "make"), + ["calls:Limits::new"], + "{}", + graph.report() + ); + } + + /// Upstream #2354: std and prelude enums never reach a same-named project + /// enum, nor their variants a project variant. + #[test] + fn pr2354_std_and_prelude_enums_never_reach_a_project_enum() { + let project = rust_project(&[ + ("lib.rs", "pub mod shapes;\npub mod user;\n"), + ( + "shapes.rs", + "pub enum Ordering {\n Less,\n Greater,\n}\n\npub enum Option {\n Some,\n None,\n}\n", + ), + ( + "user.rs", + "use std::cmp::{self, Ordering};\n\npub fn sorted(a: u8, b: u8) -> bool {\n a.cmp(&b) == Ordering::Less || b.cmp(&a) == cmp::Ordering::Greater || a.cmp(&b) != std::cmp::Ordering::Less\n}\n\npub fn first(o: Option) -> u8 {\n match o {\n Option::Some(x) => x,\n Option::None => 0,\n }\n}\n\npub fn bare(o: Option, r: Result) -> u8 {\n match (o, r) {\n (Some(x), Ok(_)) => x,\n (None, Err(())) => 0,\n _ => 1,\n }\n}\n", + ), + ]); + let graph = project.graph(); + for name in ["Ordering", "Option"] { + let shape = declared(graph, "enum", "shapes.rs", name); + assert!( + referencers(graph, &shape.id).is_empty(), + "{name}\n{}", + graph.report() + ); + } + for variant in graph.nodes.iter().filter(|n| n.kind == "enum_member") { + assert!( + !graph + .edges + .iter() + .any(|e| e.target == variant.id && e.kind != "contains"), + "{}\n{}", + variant.qualified_name, + graph.report() + ); + } + } + + /// Upstream #2354: a module path, an associated function and a same-named + /// enum elsewhere are not references to the enum. + #[test] + fn pr2354_a_module_path_or_associated_function_is_no_enum_reference() { + let mode_impl = + format!("{MODE}\nimpl Mode {{\n pub fn new() -> Mode {{ Mode::A }}\n}}\n"); + let project = rust_project(&[ + ( + "lib.rs", + "pub mod mode;\npub mod other;\npub mod util;\npub mod paths;\n", + ), + ("mode.rs", &mode_impl), + ("other.rs", "pub enum Mode {\n A,\n Z,\n}\n"), + ("util.rs", "pub fn take(x: u8) -> u8 { x }\n"), + ( + "paths.rs", + "use crate::mode;\nuse crate::other;\nuse crate::util;\n\npub fn via_module() -> u8 {\n util::take(3)\n}\n\npub fn via_ctor() {\n let _m = mode::Mode::new();\n}\n\npub fn via_other() -> u8 {\n match other::Mode::A {\n other::Mode::Z => 1,\n _ => 0,\n }\n}\n\npub fn via_mode() {\n let _m = mode::Mode::A;\n}\n", + ), + ]); + let graph = project.graph(); + let via_module = declared(graph, "function", "paths.rs", "via_module"); + assert!( + !graph + .edges + .iter() + .any(|e| e.kind == "references" && e.source == via_module.id), + "{}", + graph.report() + ); + let other = declared(graph, "enum", "other.rs", "Mode"); + assert_eq!( + referencers(graph, &other.id), + ["paths.rs:via_other"], + "{}", + graph.report() + ); + let mode = declared(graph, "enum", "mode.rs", "Mode"); + assert_eq!( + referencers(graph, &mode.id), + [ + "mode.rs:current", + "mode.rs:new", + "mode.rs:takes", + "paths.rs:via_mode" + ], + "{}", + graph.report() + ); + } + + /// `Owner::member` of every call edge out of `file` (upstream's + /// `callsFrom`), sorted. + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut calls: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect(); + calls.sort(); + calls + } + + /// Upstream #2177's Rust half: a CamelCase path receiver the project + /// doesn't declare (`Vec::new`, `String::from`) is an outside type, a + /// lowercase module path (`task::spawn`) never reaches a method, and a + /// link further down a multi-line chain is named after the chain's head + /// (`bat()\n.stdout(…)` is assert_cmd's, `Arg::new(…)\n.value_hint(…)` + /// the project's). Upstream's fixture, with the audit's `String::from` to + /// a `StringBuilder::from` and a project trait's associated function + /// reached through the trait. + #[test] + fn pr2177_rust_calls_on_outside_types_and_module_paths_stay_outside() { + let project = Project::new() + .file( + "src/lib.rs", + "pub struct VecWithInitialized;\nimpl VecWithInitialized {\n pub fn new() -> Self { VecWithInitialized }\n}\n\npub struct TaskTracker;\nimpl TaskTracker {\n pub fn spawn(&self) {}\n}\n\npub struct OutputType;\nimpl OutputType {\n pub fn stdout(&self) {}\n}\n\npub struct Arg;\nimpl Arg {\n pub fn new(name: &str) -> Arg { Arg }\n pub fn value_hint(self, hint: u8) -> Arg { self }\n}\n\npub struct StringBuilder;\nimpl StringBuilder {\n pub fn from(text: &str) -> Self { StringBuilder }\n}\n\npub trait Plugin {\n fn create() -> Self;\n}\n\npub struct Widget;\nimpl Widget {\n pub fn fresh() -> Self { Widget }\n}\npub type WidgetRef = Widget;\n\npub fn run() {\n let v: Vec = Vec::new();\n task::spawn(async {});\n bat()\n .assert()\n .stdout(\"x\");\n let a = Arg::new(\"cmd\")\n .value_hint(1);\n let s = String::from(\"x\");\n let r = WidgetRef::fresh();\n}\n\npub fn make() -> P {\n

      ::create()\n}\n\npub fn build() -> u8 {\n let _b = Plugin::create;\n 0\n}\n", + ) + .index(); + let graph = project.graph(); + let calls = calls_from(graph, "src/lib.rs"); + for outside in [ + "VecWithInitialized::new", + "TaskTracker::spawn", + "OutputType::stdout", + "StringBuilder::from", + ] { + assert!( + !calls.contains(&outside.to_string()), + "{outside}: {calls:?}\n{}", + graph.report() + ); + } + assert!( + calls.contains(&"Arg::value_hint".to_string()), + "{calls:?}\n{}", + graph.report() + ); + // A project type alias is a declared type: `WidgetRef::fresh()` keeps + // its guess (`trait` and `type_alias` count, upstream #2177). + assert!( + calls.contains(&"Widget::fresh".to_string()), + "{calls:?}\n{}", + graph.report() + ); + } + + /// Upstream #2231: a path through a generic type parameter (`T::Output`) + /// names no project item, not even the alias another impl in its own file + /// declares. + #[test] + fn pr2231_a_generic_parameter_path_names_no_alias_of_its_file() { + let project = Project::new() + .file("Cargo.toml", "[package]\nname = \"app\"\n") + .file( + "src/lib.rs", + "pub struct Wrapper;\n\nimpl std::ops::Add for Wrapper {\n type Output = Wrapper;\n fn add(self, _other: Wrapper) -> Wrapper {\n Wrapper\n }\n}\n\npub fn sum(a: T, b: T) -> T::Output {\n a + b\n}\n", + ) + .index(); + let graph = project.graph(); + let sum = graph + .nodes + .iter() + .find(|n| n.kind == "function" && n.name == "sum") + .expect("sum"); + let targets: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "references" && e.source == sum.id) + .filter_map(|e| graph.node(&e.target)) + .map(|t| format!("{} {}", t.kind, t.qualified_name)) + .collect(); + assert!( + !targets.iter().any(|t| t.starts_with("type_alias")), + "{targets:?}\n{}", + graph.report() + ); + } + + /// Upstream #2231: `Self::Error` in a trait is the trait's own associated + /// type, never an impl's alias in another file nor a struct of that name, + /// and in an impl the alias that impl declares. + #[test] + fn pr2231_a_self_path_reads_its_own_impl_or_trait() { + let project = Project::new() + .file("Cargo.toml", "[package]\nname = \"serde\"\n") + .file("src/de/value.rs", "pub struct Error {\n msg: String,\n}\n") + .file( + "src/de/mod.rs", + "pub mod value;\npub mod unit;\n\npub trait Deserializer {\n type Error;\n fn deserialize_unit(self) -> Result<(), Self::Error>;\n}\n", + ) + .file( + "src/de/unit.rs", + "use crate::de::value::Error;\n\npub struct Unit;\n\nimpl crate::de::Deserializer for Unit {\n type Error = Error;\n fn deserialize_unit(self) -> Result<(), Self::Error> {\n Ok(())\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let targets_at = |file: &str, line: i64| -> Vec { + graph + .edges + .iter() + .filter(|e| e.kind == "references" && e.line == Some(line)) + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|t| format!("{} {} {}:{}", t.kind, t.name, t.file_path, t.start_line)) + .collect() + }; + let in_trait = targets_at("src/de/mod.rs", 6); + assert!( + !in_trait.iter().any(|t| t.contains(" Error ")), + "{in_trait:?}\n{}", + graph.report() + ); + let in_impl = targets_at("src/de/unit.rs", 7); + assert!( + in_impl.contains(&"type_alias Error src/de/unit.rs:6".to_string()), + "{in_impl:?}\n{}", + graph.report() + ); + assert!( + !in_impl.iter().any(|t| t.contains("src/de/value.rs")), + "{in_impl:?}\n{}", + graph.report() + ); + } + + /// Upstream #2231: `Self::Error` in a signature is the enclosing impl's + /// own associated type, and `V::Value` an associated type of a generic's + /// bound — never a struct of that name. Upstream's fixture. + #[test] + fn pr2231_self_and_generic_parameter_paths_name_no_struct() { + let project = Project::new() + .file("Cargo.toml", "[package]\nname = \"serde\"\n") + .file( + "src/de/value.rs", + "pub struct Error {\n msg: String,\n}\n\npub struct Value;\n", + ) + .file( + "src/private/de.rs", + "use crate::de::value::{Error, Value};\n\npub trait Deserializer {\n type Error;\n}\n\npub struct ContentDeserializer {\n err: E,\n}\n\nimpl Deserializer for ContentDeserializer {\n type Error = E;\n\n fn deserialize_any(self, visitor: V) -> Result {\n todo!()\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let targets: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "references" && e.line == Some(14)) + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.file_path == "src/private/de.rs") + }) + .filter_map(|e| graph.node(&e.target)) + .map(|t| format!("{} {}:{}", t.kind, t.file_path, t.start_line)) + .collect(); + assert!( + targets.contains(&"type_alias src/private/de.rs:12".to_string()), + "{targets:?}\n{}", + graph.report() + ); + assert!( + !targets.iter().any(|t| t.contains("src/de/value.rs")), + "{targets:?}\n{}", + graph.report() + ); + } +} + +mod php { + use super::support::{Graph, Project}; + + /// `Owner::member` of every call edge out of the method named `method` in + /// `file`, sorted (upstream's `callees`). + fn callees(graph: &Graph, file: &str, method: &str) -> Vec { + let from = graph + .nodes + .iter() + .find(|n| n.kind == "method" && n.name == method && n.file_path == file) + .unwrap_or_else(|| panic!("method {method} in {file}\n{}", graph.report())); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls" && e.source == from.id) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect(); + out.sort(); + out + } + + /// Upstream #2142: `$this->m()`, `self::m()`, `static::m()` and + /// `parent::m()` name a method of the class the call is written in, a + /// class it extends, or a trait any of them uses — never another class's + /// method that shares the name; inside a trait `$this` is the using class, + /// a base may call what a subclass defines, and past an ancestor outside + /// the repository the repository's traits still count. Upstream's fixture. + #[test] + fn pr2142_php_this_self_and_parent_calls_stay_in_the_class_hierarchy() { + let project = Project::new() + .file( + "core/lib/StringTranslationTrait.php", + "helper();\n return $this->t('Hi');\n }\n}\n", + ) + .file( + "core/tests/NodeTest.php", + "assertEquals(1, 1);\n }\n}\n", + ) + .file( + "src/Testing/InteractsWithTime.php", + "getValue($attribute); }\n}\n", + ) + .file( + "src/Validation/Validator.php", + "book(); }\n}\n", + ) + .file( + "app/Page.php", + "travelTo('2000-01-02');\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + callees(graph, "core/modules/my/MyForm.php", "setUp"), + [ + "Drupal\\Core\\Form::FormBase::helper", + "Drupal\\Core\\Form::FormBase::setUp", + "Drupal\\Core\\StringTranslation::StringTranslationTrait::t", + ], + "{report}" + ); + assert!( + callees(graph, "core/tests/NodeTest.php", "testTitle").is_empty(), + "{report}" + ); + assert_eq!( + callees( + graph, + "src/Validation/ValidatesAttributes.php", + "validateSame" + ), + ["Illuminate\\Validation::Validator::getValue"], + "{report}" + ); + assert_eq!( + callees(graph, "app/Entity.php", "parentBook"), + ["BookStack\\Entities::Page::book"], + "{report}" + ); + assert_eq!( + callees(graph, "tests/QueueTest.php", "testDelay"), + ["Illuminate\\Foundation\\Testing\\Concerns::InteractsWithTime::travelTo"], + "{report}" + ); + } + + /// Upstream #2142's rule at the exact-name step: a same-namespace class's + /// method of the name is the one candidate there, and it is no ancestor's + /// of a test class extending PHPUnit's `TestCase`; a trait the test uses + /// is. + #[test] + fn pr2142_a_this_call_in_a_vendor_subclass_skips_a_same_namespace_namesake() { + let project = Project::new() + .file( + "tests/Fixtures.php", + "seed();\n $this->makeUser();\n }\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + callees(graph, "tests/UserTest.php", "testSeed"), + ["App\\Tests::CreatesUsers::makeUser"], + "{}", + graph.report() + ); + } + + /// Upstream #2152: a PHP call through a receiver nothing typed takes the + /// one project method of that name only when the receiver is named after + /// its class, or after a class that inherits it (`$page->save()` → Page + /// extends Entity). Upstream's fixture. + #[test] + fn pr2152_a_php_untyped_receiver_keeps_a_unique_method_only_when_named_for_it() { + let project = Project::new() + .file( + "src/Models/Entity.php", + "save();\n $avatars->assignToUser($user);\n } catch (\\Exception $e) {\n return $e->getMessage();\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let mut targets: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.file_path == "src/Controller.php") + }) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .collect(); + targets.sort(); + let report = graph.report(); + assert!( + targets.contains(&"App\\Models::Entity::save".to_string()), + "{targets:?}\n{report}" + ); + assert!( + targets.contains(&"App\\Uploads::UserAvatars::assignToUser".to_string()), + "{targets:?}\n{report}" + ); + assert!( + !targets.contains(&"App\\Helper::ProgressBar::getMessage".to_string()), + "{targets:?}\n{report}" + ); + } + + /// `kind qualified_name` of every non-`contains`, non-`imports` edge + /// target out of `file`'s nodes, as `source kind target`, sorted + /// (upstream's `edgesFrom`). + fn edges_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind != "contains" && e.kind != "imports") + .filter_map(|e| { + let source = graph.node(&e.source).filter(|n| n.file_path == file)?; + let target = graph.node(&e.target)?; + Some(format!( + "{} {} {}", + source.name, e.kind, target.qualified_name + )) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2103 (its PHP half): PHP has no implicit `$this`, so a call + /// written without a receiver is a function call: BookStack's every + /// `return redirect(…)` went to `ApiDocsController::redirect`, every + /// `return view(…)` to a `$view` field. `$this->m()` and a project helper + /// function still bind. Upstream's fixture. + #[test] + fn pr2103_a_bare_php_call_is_a_function_call() { + let project = Project::new() + .file( + "app/Http/Controllers/BookController.php", + " $book]);\n }\n public function store() {\n return redirect()->back()->withErrors(['name' => 'required']);\n }\n}\n", + ) + .file( + "app/Http/Controllers/DocsController.php", + "setTitle(format_title('x'));\n }\n protected function setTitle($t) {}\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let bound: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.file_path == "app/Http/Controllers/BookController.php") + }) + .filter_map(|e| graph.node(&e.target)) + .map(|n| format!("{} {}", n.kind, n.name)) + .collect(); + for wrong in ["method redirect", "field view", "class View"] { + assert!(!bound.contains(&wrong.to_string()), "{bound:?}\n{report}"); + } + assert_eq!( + callees(graph, "app/Http/Controllers/PageController.php", "show"), + vec![ + "PageController::setTitle".to_string(), + "format_title".to_string() + ], + "{report}" + ); + } + + /// Upstream #2103's form: `\\helper()` calls the global function `helper`, + /// never a method of the name (KEEP-RUST: the extractor keeps the `\\` in + /// the name). + #[test] + fn pr2103_a_php_function_called_from_the_global_namespace_is_that_function() { + let project = Project::new() + .file("app/helpers.php", "` in a PHP array is a bare call, + /// not a member call (`'total' => count($items)` went to a same-named + /// method: 166 wrong edges on BookStack). Upstream's fixture. + #[test] + fn pr2141_a_php_call_after_an_array_arrow_is_a_function_call() { + let project = Project::new() + .file( + "app/CommentTree.php", + " count($items), 'by' => user()];\n}\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + edges_from(graph, "app/Controller.php"), + vec!["summary calls user".to_string()], + "{}", + graph.report() + ); + } + + /// Upstream #2141 (a): PHP names functions in any case, so a call written + /// in another case than its function (`formatprice(10)` for + /// `FormatPrice`) passes the name pre-filter and reaches it. Upstream's + /// fixture. + #[test] + fn pr2141_a_php_call_in_another_case_reaches_its_function() { + let project = Project::new() + .file( + "src/helpers.php", + "::new(), + "{report}" + ); + } + + /// Upstream #2286 for a class body's trait list: `use Shared, Sub\Logs;` + /// names `App\Shared` and `App\Sub\Logs`, never another namespace's traits + /// of those names (the extractor records each entry as written). + #[test] + fn pr2286_a_php_trait_list_names_each_trait_as_written() { + let project = Project::new() + .file( + "app/Shared.php", + "::new(), + "{}", + graph.report() + ); + } +} + +mod lua { + use super::support::{Graph, Project}; + + /// `file: qualified_name` of every call edge out of `file`'s nodes, sorted + /// (upstream's `callsFrom`). + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|n| format!("{}: {}", n.file_path, n.qualified_name)) + .collect(); + out.sort(); + out + } + + /// Upstream #2143: a Lua `local` belongs to its chunk, another file can + /// never name it; a global function stays reachable. kong's spec helpers' + /// `local it = it` took busted's `it(…)` in every other spec file. + /// Upstream's fixture. + #[test] + fn pr2143_a_lua_local_is_invisible_outside_its_file() { + let project = Project::new() + .file( + "spec/helpers.lua", + "local it = it\nlocal ipairs = ipairs\nlocal function helper() return 1 end\nfunction global_helper() return 2 end\nreturn {}\n", + ) + .file( + "spec/plugin_spec.lua", + "describe(\"plugin\", function()\n it(\"works\", function()\n for _, v in ipairs({ 1 }) do helper(v) end\n global_helper()\n end)\nend)\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_from(graph, "spec/plugin_spec.lua"), + vec!["spec/helpers.lua: global_helper".to_string()], + "{}", + graph.report() + ); + } + + /// Upstream #2202: a bare Lua call reaches neither a table's method nor, + /// for a Lua global, a project namesake; a function's own alias of a host + /// table's member still reaches the method of the table named after its + /// holder. Upstream's fixture. + #[test] + fn pr2202_a_bare_lua_call_is_luas_global_never_a_tables_method() { + let project = Project::new() + .file( + "lua/app/linked_list.lua", + "local LinkedList = {}\n\nfunction LinkedList:ipairs()\n return function() end\nend\n\nreturn LinkedList\n", + ) + .file( + "lua/app/response.lua", + "local _RESPONSE = {}\n\nfunction _RESPONSE.error(status)\n return status\nend\n\nreturn _RESPONSE\n", + ) + .file( + "lua/app/request.lua", + "local _REQUEST = {}\n\nfunction _REQUEST.get_query()\n return {}\nend\n\nreturn _REQUEST\n", + ) + .file( + "lua/app/handler.lua", + "local M = {}\n\nfunction M.run(items)\n for _, v in ipairs(items) do\n if not v then\n error(\"missing\")\n end\n end\n local get_query = kong.request.get_query\n return get_query()\nend\n\nreturn M\n", + ) + .index(); + let graph = project.graph(); + let calls = calls_from(graph, "lua/app/handler.lua"); + let report = graph.report(); + assert!( + !calls.iter().any(|c| c.ends_with("LinkedList::ipairs")), + "{calls:?}\n{report}" + ); + assert!( + !calls.iter().any(|c| c.ends_with("_RESPONSE::error")), + "{calls:?}\n{report}" + ); + assert!( + calls.iter().any(|c| c.ends_with("_REQUEST::get_query")), + "{calls:?}\n{report}" + ); + } + + /// Upstream #2202, the method half on its own: a bare call named like no + /// Lua global (`render(…)` on a parameter) is no exact match for a + /// table's method of its name, here two of them, the nearer beside the + /// caller. A lone one is still the last-resort guess (fuzzy, 0.5), as + /// upstream's fuzzy matcher has no Lua rule. + #[test] + fn pr2202_a_bare_lua_call_never_reaches_a_tables_method_of_its_name() { + let project = Project::new() + .file( + "lua/app/component.lua", + "local Component = {}\n\nfunction Component:render()\n return 1\nend\n\nreturn Component\n", + ) + .file( + "lib/other/pure.lua", + "local PureComponent = {}\n\nfunction PureComponent:render()\n return 2\nend\n\nreturn PureComponent\n", + ) + .file( + "lua/app/forward.lua", + "local function forward(render)\n return render()\nend\n\nreturn forward\n", + ) + .index(); + let graph = project.graph(); + assert_eq!( + calls_from(graph, "lua/app/forward.lua"), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2188: a bare Lua call through a `local` alias reaches what + /// the alias names: a module's member, also one the module hands out + /// under another name or re-exports from a module of its own; the + /// standard library and an outside module are no edge. Upstream's + /// fixture. + #[test] + fn pr2188_a_bare_lua_call_follows_a_local_alias() { + let project = Project::new() + .file( + "kong/tools/string.lua", + "local _M = {}\n\nfunction _M.splitn(value)\n return value\nend\n\nreturn _M\n", + ) + .file( + "kong/pdk/phases.lua", + "local function check_phase(accepted)\n return accepted\nend\n\nreturn {\n check = check_phase,\n}\n", + ) + .file( + "kong/handler.lua", + "local type = type\nlocal fmt = string.format\nlocal splitn = require(\"kong.tools.string\").splitn\nlocal phase_checker = require \"kong.pdk.phases\"\nlocal check_phase = phase_checker.check\nlocal cjson_encode = require(\"cjson\").encode\n\nlocal function run(v)\n check_phase(\"access\")\n if type(v) ~= \"string\" then\n error(fmt(\"bad %s\", v))\n end\n return splitn(v), cjson_encode(v)\nend\n\nreturn { run = run }\n", + ) + .file( + "spec/internal/misc.lua", + "local function unindent(s)\n return s\nend\n\nreturn { unindent = unindent }\n", + ) + .file( + "spec/helpers.lua", + "local misc = require(\"spec.internal.misc\")\n\nreturn {\n unindent = misc.unindent,\n}\n", + ) + .file( + "spec/handler_spec.lua", + "local helpers = require \"spec.helpers\"\nlocal unindent = helpers.unindent\n\ndescribe(\"handler\", function()\n it(\"runs\", function()\n unindent(\"a\")\n end)\nend)\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + calls_from(graph, "kong/handler.lua"), + vec![ + "kong/pdk/phases.lua: check_phase".to_string(), + "kong/tools/string.lua: _M::splitn".to_string(), + ], + "{report}" + ); + assert_eq!( + calls_from(graph, "spec/handler_spec.lua"), + vec!["spec/internal/misc.lua: unindent".to_string()], + "{report}" + ); + } +} + +mod r { + use super::support::{Graph, Project}; + + /// `name@file` of every call edge out of `file`'s nodes, sorted + /// (upstream's fixture helper). + fn targets(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|n| format!("{}@{}", n.name, n.file_path)) + .collect(); + out.sort(); + out + } + + /// Upstream #2144: testthat runs each test file in an environment of its + /// own, so a variable one test assigns is invisible to the package and to + /// other tests; a `helper-*.R` (or `setup-*.R`) file is sourced for every + /// test and stays shared, and a package function a factory makes stays + /// callable. Upstream's fixture, plus a second test file calling a name + /// the first assigns at its top level. + #[test] + fn pr2144_an_r_test_files_top_level_variables_stay_in_that_file() { + let project = Project::new() + .file("DESCRIPTION", "Package: demo\n") + .file( + "R/geom-point.R", + "geom_point <- make_constructor(GeomPoint)\ntable_of <- function() {\n c(\"a\", \"b\")\n}\n", + ) + .file( + "tests/testthat/helper-s3.R", + "quux <- structure(list(), class = \"quux\")\n", + ) + .file( + "tests/testthat/setup-data.R", + "rows <- data.frame(x = 1)\n", + ) + .file( + "tests/testthat/test-scales.R", + "c <- ggplot(df, aes(x, y))\nfixture <- data.frame(y = 2)\ntest_that(\"works\", {\n geom_point()\n quux()\n})\n", + ) + .file( + "tests/testthat/test-other.R", + "test_that(\"other\", {\n fixture()\n rows()\n})\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + targets(graph, "R/geom-point.R"), + Vec::::new(), + "{report}" + ); + assert_eq!( + targets(graph, "tests/testthat/test-scales.R"), + vec![ + "geom_point@R/geom-point.R".to_string(), + "quux@tests/testthat/helper-s3.R".to_string(), + ], + "{report}" + ); + assert_eq!( + targets(graph, "tests/testthat/test-other.R"), + vec!["rows@tests/testthat/setup-data.R".to_string()], + "{report}" + ); + } + + /// Upstream #2239: R looks a call's name up among functions only, so a + /// data binding that shadows a base function (`c <- data_frame(b = 3)`) + /// is never what `c(1, 2)` calls; a project binding a factory makes + /// (`geom_point <- make_constructor(…)`) stays. Upstream's fixture. + #[test] + fn pr2239_an_r_call_skips_a_data_binding_shadowing_a_base_function() { + let project = Project::new() + .file("DESCRIPTION", "Package: ggplot2\n") + .file( + "R/geom-point.R", + "geom_point <- make_constructor(GeomPoint, position = \"identity\")\n", + ) + .file( + "tests/testthat/test-facet-wrap.R", + "a <- data_frame(a = 1:3)\nc <- data_frame(b = 3)\n\ntest_that(\"facets\", {\n p <- geom_point()\n expect_equal(c(1, 2), a$a)\n})\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let names: Vec = targets(graph, "tests/testthat/test-facet-wrap.R") + .into_iter() + .map(|t| t.split('@').next().unwrap_or_default().to_string()) + .collect(); + assert!(!names.contains(&"c".to_string()), "{names:?}\n{report}"); + assert!( + names.contains(&"geom_point".to_string()), + "{names:?}\n{report}" + ); + } + + /// Upstream #2177, the R part: a plain call is a function call, never an + /// object's method: base R's `range(x)` is no ggproto Coord's `range`. + /// Upstream's fixture. + #[test] + fn pr2177_a_bare_r_call_is_a_function_not_an_objects_method() { + let project = Project::new() + .file( + "R/coord.R", + "Coord <- ggproto(\"Coord\",\n range = function(panel_params) {\n list(x = panel_params$x$dimension())\n }\n)\n\nscale_limits <- function(x) {\n range(x)\n}\n", + ) + .index(); + let graph = project.graph(); + let calls: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.file_path == "R/coord.R") + }) + .filter_map(|e| graph.node(&e.target)) + .map(|n| n.qualified_name.clone()) + .filter(|q| q.ends_with("range")) + .collect(); + assert_eq!(calls, Vec::::new(), "{}", graph.report()); + } +} + +mod dart_scope { + use super::support::{Graph, Project}; + + /// `from -> Owner::name` of every call edge out of `file`, sorted + /// (upstream's fixture helper). + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| { + let source = graph.node(&e.source)?; + let target = graph.node(&e.target)?; + Some(format!("{} -> {}", source.name, target.qualified_name)) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2145: a bare Dart call reaches a method only through the + /// class it is written in (its members, what it extends, mixes in or + /// implements, or an extension on one of those), the nearest one winning; + /// outside any class, none. Upstream's fixture, plus an override in + /// another file nearer than the method it overrides beside the call, and + /// a chain whose links are written below its start. + #[test] + fn pr2145_a_bare_dart_call_stays_in_the_class_hierarchy() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "lib/handler.dart", + "class TestHandler {\n void expect(Object value) {}\n void test(String name) {}\n}\n\nabstract class HttpHeaders {\n void add(String name, Object value);\n void clear();\n}\n", + ) + .file( + "lib/bloc.dart", + "abstract class Emittable {\n void emit(S state);\n}\n\nabstract class BlocBase implements Emittable {\n @override\n void emit(S state) {}\n}\n\nabstract class Cubit extends BlocBase {}\n\nclass CounterCubit extends Cubit {\n void increment() => emit(1);\n}\n", + ) + .file( + "lib/notifier.dart", + "class Listenable {}\n\nmixin SingleMixin on Listenable {\n void refresh() {}\n}\n\nclass ListNotifier extends Listenable {}\n\nclass ListNotifierSingle = ListNotifier with SingleMixin;\n\nclass RxList extends ListNotifierSingle {\n void add(Object item) {\n refresh();\n }\n}\n", + ) + .file( + "lib/element.dart", + "abstract class AnyNotifier {}\n\nextension NotifierX on AnyNotifier {\n void requireElement() {}\n}\n\nclass Notifier<\n // the state\n S\n> //\n extends\n AnyNotifier<\n S\n > {\n void read() {\n requireElement();\n }\n}\n", + ) + .file( + "lib/list_ext.dart", + "extension ListExtension on List {\n void addIf(bool condition, E item) {\n if (condition) add(item);\n }\n}\n", + ) + .file( + "lib/login.dart", + "class LoginState {\n LoginState withEmail(String e) => this;\n LoginState withPassword(String p) => this;\n}\n\nLoginState build() => LoginState().withEmail('a').withPassword('b');\n\nLoginState spread() => LoginState()\n .withEmail('a')\n .withPassword('b');\n", + ) + .file( + "test/handler_test.dart", + "import 'package:test/test.dart';\n\nvoid main() {\n test('works', () {\n expect(1, 1);\n });\n}\n", + ) + .file( + "lib/override.dart", + "class Root {\n void notify(int x) {}\n}\n\nclass Leaf extends Middle {\n void run() {\n notify(1);\n }\n}\n", + ) + .file( + "lib/middle.dart", + "class Middle extends Root {\n @override\n void notify(int x) {}\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + calls_from(graph, "test/handler_test.dart"), + Vec::::new(), + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/bloc.dart"), + vec!["increment -> BlocBase::emit".to_string()], + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/notifier.dart"), + vec!["add -> SingleMixin::refresh".to_string()], + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/element.dart"), + vec!["read -> NotifierX::requireElement".to_string()], + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/list_ext.dart"), + Vec::::new(), + "{report}" + ); + assert!( + calls_from(graph, "lib/login.dart") + .contains(&"build -> LoginState::withPassword".to_string()), + "{report}" + ); + // A chain spread over lines keeps its links' methods too. + let login = calls_from(graph, "lib/login.dart"); + for link in ["withEmail", "withPassword"] { + assert!( + login.contains(&format!("spread -> LoginState::{link}")), + "{login:?}\n{report}" + ); + } + assert_eq!( + calls_from(graph, "lib/override.dart"), + vec!["run -> Middle::notify".to_string()], + "{report}" + ); + } + + /// A call whose receiver's name begins with the called name is no bare + /// call: this extractor records `ticker.tick()` written in another call's + /// arguments at the chain's start, `ticker`, so the receiver-less reader + /// takes the name as a whole word (KEEP-RUST adaptation of #2145's + /// reader, found by W4-DART-02's precision run on bloc's flutter_timer + /// tests). + #[test] + fn pr2145_a_call_whose_receiver_begins_with_its_name_is_no_bare_call() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file("lib/ticker.dart", "class Ticker {\n void tick() {}\n}\n") + .file( + "lib/main.dart", + "import 'ticker.dart';\n\nvoid run(Ticker ticker) {\n when(() => ticker.tick()).then();\n}\n\nvoid when(Object f) {}\n", + ) + .index(); + let graph = project.graph(); + assert!( + calls_from(graph, "lib/main.dart").contains(&"run -> Ticker::tick".to_string()), + "{}", + graph.report() + ); + } + + /// Upstream #2207: a Dart core method name on an untyped receiver is the + /// core type's unless the receiver names the owner, and an extension's + /// owner is the type it is `on`: getx's `ext.endsWith(".avi")` is no + /// `extension RxStringExt on Rx`'s. Upstream's fixture. + #[test] + fn pr2207_a_dart_core_method_name_on_an_untyped_receiver_is_the_core_types() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "lib/rx_string.dart", + "class Rx {\n Rx(this.value);\n T value;\n}\n\nextension RxStringExt on Rx {\n bool endsWith(String other) => value.endsWith(other);\n}\n\nextension RxnStringExt on Rx {\n bool endsWith(String other) => false;\n}\n", + ) + .file( + "lib/get_utils.dart", + "class GetUtils {\n static bool isVideo(String filePath) {\n var ext = filePath.toLowerCase();\n return ext.endsWith(\".avi\");\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let targets: Vec = calls_from(graph, "lib/get_utils.dart"); + assert!( + !targets.iter().any(|t| t.ends_with("StringExt::endsWith")), + "{targets:?}\n{}", + graph.report() + ); + } + + /// Upstream #2237: an unnamed `extension on X` is never `X` (here the + /// extractor names it ``) and applies only in its own + /// library: flutter_test's `find.text(…)` in another package is no + /// `extension on TaskStatus { String get text }`. Upstream's fixture, + /// its second check asked of the extension's file; plus a bare call of + /// such an extension's member from a subclass in another file, which the + /// class-hierarchy scope (#2145) alone would admit. + #[test] + fn pr2237_an_unnamed_dart_extension_applies_only_in_its_library() { + let project = Project::new() + .file("pubspec.yaml", "name: bloc_lint\n") + .file( + "lib/src/rules/avoid_build_context_extensions.dart", + "import 'package:analyzer/dart/ast/token.dart';\n\nextension on Token {\n bool get isBlocType => lexeme.endsWith('Bloc');\n}\n", + ) + .file( + "lib/src/linter.dart", + "import 'package:analyzer/dart/ast/token.dart';\n\nclass Linter {\n void visit(Token beginToken) {}\n}\n", + ) + .file( + "examples/visualizer/lib/timeline_page.dart", + "enum TaskStatus { queued, done }\n\nextension on TaskStatus {\n String get text => 'x';\n}\n\nString label(TaskStatus status) => status.text;\n", + ) + .file( + "examples/counter/test/counter_page_test.dart", + "import 'package:flutter_test/flutter_test.dart';\n\nvoid main() {\n testWidgets('shows', (tester) async {\n expect(find.text('0'), findsOneWidget);\n });\n}\n", + ) + .file( + "lib/src/foo.dart", + "class Foo {}\n\nextension on Foo {\n void helper() {}\n}\n\nclass Near extends Foo {\n void run() {\n helper();\n }\n}\n", + ) + .file( + "lib/src/bar.dart", + "import 'foo.dart';\n\nclass Bar extends Foo {\n void run() {\n helper();\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let targets_from = |file: &str| -> Vec { + graph + .edges + .iter() + .filter(|e| e.kind != "contains") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| graph.node(&e.target)) + .map(|t| format!("{}:{}", t.file_path, t.qualified_name)) + .collect() + }; + let report = graph.report(); + assert!( + !targets_from("lib/src/linter.dart") + .iter() + .any(|t| t.starts_with("lib/src/rules/avoid_build_context_extensions.dart:")), + "{report}" + ); + assert!( + !targets_from("examples/counter/test/counter_page_test.dart") + .iter() + .any(|t| t.starts_with("examples/visualizer/lib/timeline_page.dart:")), + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/src/foo.dart"), + vec!["run -> ::helper".to_string()], + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/src/bar.dart"), + Vec::::new(), + "{report}" + ); + } +} + +/// Upstream #2386, #2388 and #2391: a Dart library is a file and its parts; +/// a name written without a receiver reaches only what that library declares +/// or what a library it imports exports, and an `import`, `export` or `part` +/// links the file its URI names. +mod dart_libraries { + use super::support::{self, Graph, Project}; + + /// ` -> :` for every + /// edge out of `file` but `contains`, sorted (upstream's `edgesFrom`, + /// without the reference name, which an edge does not keep here). + fn edges_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind != "contains") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| { + let target = graph.node(&e.target)?; + Some(format!( + "{} {} -> {}:{}", + e.kind, + e.line.unwrap_or_default(), + target.file_path, + target.qualified_name + )) + }) + .collect(); + out.sort(); + out + } + + /// ` -> ` for every `imports` edge out of `file`, a file by + /// its path and anything else by kind, file and name (upstream's + /// `importsFrom`), sorted. + fn imports_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "imports") + .filter(|e| graph.node(&e.source).is_some_and(|n| n.file_path == file)) + .filter_map(|e| { + let target = graph.node(&e.target)?; + let named = if target.kind == "file" { + target.file_path.clone() + } else { + format!("{} {}#{}", target.kind, target.file_path, target.name) + }; + Some(format!("{} -> {named}", e.line.unwrap_or_default())) + }) + .collect(); + out.sort(); + out + } + + /// The URIs of `file`'s imports left unresolved, sorted. + fn unresolved_imports_of(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .unresolved + .iter() + .filter(|r| r.file_path == file && r.reference_kind == "imports") + .map(|r| r.reference_name.clone()) + .collect(); + out.sort(); + out + } + + /// ` | ` of each import node in `file`, sorted. + fn import_nodes_in(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .nodes + .iter() + .filter(|n| n.file_path == file && n.kind == "import") + .map(|n| format!("{} | {}", n.name, n.signature.clone().unwrap_or_default())) + .collect(); + out.sort(); + out + } + + /// Upstream #2386: a receiver-less Dart call, constructor call or type + /// name links only to a top-level declaration its library can see — its + /// own (the file and its parts) or one a library it imports without a + /// prefix exports, through `export` chains and `show` / `hide` — and a + /// type written through an import prefix (`p.Report`) is what that import + /// exports. A library's own declaration shadows an imported one, and a + /// bare call never reaches another type's enum constant or static + /// constant. Upstream's fixture. Its field-type case (`final Box + /// box;`, #2360) and its typed receiver's call into an unnamed extension + /// in another part (`s.area()`) came with W4-DART-03; the unnamed + /// extension's reach across its library is checked through a bare call + /// too. + #[test] + fn pr2386_a_bare_dart_name_reaches_only_what_its_library_can_see() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "test/integration/annotated.dart", + "part 'annotated.g.dart';\n\nString family(Object ref, int id) => 'family $id';\n", + ) + .file( + "test/integration/annotated.g.dart", + "part of 'annotated.dart';\n\nString annotatedGenerated(Object ref) => family(ref, 1);\n", + ) + .file( + "test/integration/async.dart", + "import 'dart:async';\n\npart 'async.g.dart';\n\nFuture family(Object ref, int first) async => '$first';\n\nclass GenericClass {}\n", + ) + .file( + "test/integration/async.g.dart", + "part of 'async.dart';\n\nFuture asyncGenerated(Object ref) {\n return family(ref, 2);\n}\n\nfinal GenericClass generic = GenericClass();\n", + ) + .file("test/integration/annotated_types.dart", "class GenericClass {}\n") + .file( + "test/third_party/fake_async.dart", + "T fakeAsync(T Function(Object async) callback) => callback(Object());\n", + ) + .file( + "test/src/core/devtool_test.dart", + "import 'package:fake_async/fake_async.dart';\n\nvoid main() {\n fakeAsync((async) {});\n}\n", + ) + .file("test/old/utils.dart", "List errorsOf(void Function() cb) => [];\n") + .file("test/src/utils.dart", "List errorsOf(void Function() cb) => [];\n") + .file( + "test/feature/uni_directional_test.dart", + "import '../src/utils.dart';\n\nvoid main() {\n errorsOf(() {});\n}\n", + ) + .file("packages/kit/pubspec.yaml", "name: kit\n") + .file( + "packages/kit/lib/kit.dart", + "export 'src/impl.dart'\n show\n // the public API\n shown,\n Widget;\nexport 'src/cond_stub.dart' if (dart.library.io) 'src/cond_io.dart';\n", + ) + .file( + "packages/kit/lib/src/impl.dart", + "int shown() => 1;\nint notShown() => 2;\nclass Widget {}\n", + ) + .file("packages/kit/lib/src/cond_stub.dart", "int platformName() => 0;\n") + .file("packages/kit/lib/src/cond_io.dart", "int platformName() => 1;\n") + .file( + "packages/kit/lib/src/prefixed.dart", + "int viaPrefix() => 3;\nclass Report {}\n", + ) + .file( + "packages/kit/lib/src/hidden.dart", + "int hiddenOne() => 4;\nint keptOne() => 5;\n", + ) + .file( + "lib/main.dart", + "import 'package:kit/kit.dart';\nimport 'package:kit/src/prefixed.dart' as p;\nimport 'package:kit/src/hidden.dart' hide hiddenOne;\n\nvoid run(p.Report report) {\n shown();\n notShown();\n platformName();\n viaPrefix();\n hiddenOne();\n keptOne();\n Widget();\n}\n", + ) + .file("lib/a_report.dart", "class Report {}\n") + .file( + "lib/named.dart", + "library app.named;\n\npart 'named_part.dart';\n\nint namedHelper() => 6;\n", + ) + .file( + "lib/named_part.dart", + "part of app.named;\n\nint usesNamed() => namedHelper();\n", + ) + .file("lib/other_named.dart", "int namedHelper() => 7;\n") + .file("lib/a_util.dart", "int helper() => 1;\n") + .file( + "lib/feature.dart", + "import 'a_util.dart';\n\npart 'feature_part.dart';\n\nint runFeature() => helper();\n", + ) + .file("lib/feature_part.dart", "part of 'feature.dart';\n\nint helper() => 2;\n") + .file( + "lib/kinds.dart", + "enum Level { info, warning }\n\nclass TypeChecker {\n static const allOf = 'any';\n}\n", + ) + .file( + "lib/check.dart", + "import 'package:mason_logger/mason_logger.dart';\nimport 'package:matcher/matcher.dart';\n\nvoid check(Object value) {\n info('checking');\n expect(value, allOf([isNotNull]));\n}\n", + ) + .file( + "lib/colors.dart", + "import 'package:flutter/material.dart';\n\nextension on Color {\n Color brighten() => this;\n}\n", + ) + .file("lib/models/box.dart", "class Box {\n T? value;\n}\n") + .file( + "lib/widgets/box_ext.dart", + "import '../models/box.dart';\n\nextension on Box {\n bool get isEmpty => value == null;\n}\n", + ) + .file( + "lib/widgets/panel.dart", + "import '../models/box.dart';\nimport 'box_ext.dart';\n\nclass Panel {\n final Box box;\n Panel(this.box);\n}\n", + ) + .file( + "lib/shapes/shapes.dart", + "part 'area_ext.dart';\npart 'use.dart';\n\nclass Shape {}\n", + ) + .file( + "lib/shapes/area_ext.dart", + "part of 'shapes.dart';\n\nextension on Shape {\n int area() => 1;\n}\n", + ) + .file( + "lib/shapes/use.dart", + "part of 'shapes.dart';\n\nint total(Shape s) => s.area();\n\nclass Square extends Shape {\n int twice() => area() * 2;\n}\n", + ) + .file( + "lib/circle.dart", + "import 'shapes/shapes.dart';\n\nclass Circle extends Shape {\n int twice() => area() * 2;\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + + // A generated part calls and names its own library's declarations. + let generated = edges_from(graph, "test/integration/async.g.dart"); + assert!( + generated.contains(&"calls 4 -> test/integration/async.dart:family".to_string()), + "{generated:?}\n{report}" + ); + assert!( + !generated.iter().any(|e| e.contains("annotated")), + "{generated:?}\n{report}" + ); + let generic: Vec<&String> = generated + .iter() + .filter(|e| e.contains("GenericClass")) + .collect(); + assert!(!generic.is_empty(), "{generated:?}\n{report}"); + assert!( + generic + .iter() + .all(|e| e.ends_with("test/integration/async.dart:GenericClass")), + "{generated:?}\n{report}" + ); + // A package function no imported library declares links nothing. + assert!( + !edges_from(graph, "test/src/core/devtool_test.dart") + .iter() + .any(|e| e.contains("fake_async")), + "{report}" + ); + // The imported library, not a namesake no import names. + assert!( + edges_from(graph, "test/feature/uni_directional_test.dart") + .contains(&"calls 4 -> test/src/utils.dart:errorsOf".to_string()), + "{report}" + ); + // Export chains, `show`, `hide` and import prefixes. + let main = edges_from(graph, "lib/main.dart"); + for wanted in [ + "calls 6 -> packages/kit/lib/src/impl.dart:shown", + "calls 11 -> packages/kit/lib/src/hidden.dart:keptOne", + "instantiates 12 -> packages/kit/lib/src/impl.dart:Widget", + ] { + assert!( + main.contains(&wanted.to_string()), + "{wanted}\n{main:?}\n{report}" + ); + } + assert!( + main.iter() + .any(|e| e.starts_with("calls 8 -> packages/kit/lib/src/cond_") + && e.ends_with(".dart:platformName")), + "{main:?}\n{report}" + ); + assert!( + !main.iter().any(|e| e.ends_with(":notShown") + || e.ends_with(":viaPrefix") + || e.ends_with(":hiddenOne")), + "{main:?}\n{report}" + ); + assert_eq!( + main.iter() + .filter(|e| e.ends_with(":Report")) + .cloned() + .collect::>(), + vec!["references 5 -> packages/kit/lib/src/prefixed.dart:Report".to_string()], + "{report}" + ); + // A part names its library by the library's name. + assert!( + edges_from(graph, "lib/named_part.dart") + .contains(&"calls 3 -> lib/named.dart:namedHelper".to_string()), + "{report}" + ); + // The library's own declaration shadows the imported one. + assert_eq!( + edges_from(graph, "lib/feature.dart") + .into_iter() + .filter(|e| e.ends_with(":helper")) + .collect::>(), + vec!["calls 5 -> lib/feature_part.dart:helper".to_string()], + "{report}" + ); + // No bare call reaches another type's enum constant or static constant. + assert_eq!( + edges_from(graph, "lib/check.dart") + .into_iter() + .filter(|e| !e.starts_with("imports ")) + .collect::>(), + Vec::::new(), + "{report}" + ); + // An unnamed extension on Color is no Color, nor a generic one on + // `Box` a `Box` (a field's type, #2360). + assert!( + !edges_from(graph, "lib/colors.dart") + .iter() + .any(|e| e.contains("Color")), + "{report}" + ); + assert_eq!( + edges_from(graph, "lib/widgets/panel.dart") + .into_iter() + .filter(|e| e.ends_with(":Box")) + .collect::>(), + vec!["references 5 -> lib/models/box.dart:Box".to_string()], + "{report}" + ); + // An unnamed extension applies in every file of its library, and in + // no other library. + for edge in [ + "calls 3 -> lib/shapes/area_ext.dart:::area", + "calls 6 -> lib/shapes/area_ext.dart:::area", + ] { + assert!( + edges_from(graph, "lib/shapes/use.dart").contains(&edge.to_string()), + "{edge}\n{report}" + ); + } + assert!( + !edges_from(graph, "lib/circle.dart") + .iter() + .any(|e| e.ends_with("::area")), + "{report}" + ); + } + + /// Upstream #2386: a type written through an import prefix is what that + /// import exports, though a library imported without a prefix exports a + /// type of that name too; the type written without the prefix is that + /// library's. + #[test] + fn pr2386_a_prefixed_dart_type_is_what_its_prefix_imports() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file("lib/reports.dart", "class Report {}\n") + .file("lib/other_reports.dart", "class Report {}\n") + .file( + "lib/use.dart", + "import 'reports.dart';\nimport 'other_reports.dart' as p;\n\nvoid run(p.Report a, Report b) {}\n", + ) + .index(); + let graph = project.graph(); + let mut columns: Vec<(i64, String)> = graph + .edges + .iter() + .filter(|e| e.kind == "references") + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.file_path == "lib/use.dart") + }) + .filter_map(|e| { + let target = graph.node(&e.target)?; + Some((e.col.unwrap_or_default(), target.file_path.clone())) + }) + .collect(); + columns.sort(); + assert_eq!( + columns, + vec![ + (11, "lib/other_reports.dart".to_string()), + (21, "lib/reports.dart".to_string()), + ], + "{}", + graph.report() + ); + } + + /// Upstream #2386: a Dart file in no package — a mason brick's template, + /// whose `package:{{name}}/…` imports no pubspec.yaml explains — is + /// resolved as before, by name. + #[test] + fn pr2386_dart_files_outside_any_package_resolve_by_name() { + let project = Project::new() + .file( + "bricks/feature/__brick__/helper.dart", + "int helper() => 1;\n", + ) + .file( + "bricks/feature/__brick__/page.dart", + "import 'package:{{name}}/helper.dart';\n\nint page() => helper();\n", + ) + .index(); + let graph = project.graph(); + assert!( + edges_from(graph, "bricks/feature/__brick__/page.dart") + .contains(&"calls 3 -> bricks/feature/__brick__/helper.dart:helper".to_string()), + "{}", + graph.report() + ); + } + + /// Upstream #2388: an `import` or `export` links the one project file its + /// URI names — `package:/` is `/lib/` of the + /// package whose pubspec.yaml says `name: `, anything else a path + /// from the importing file — or nothing: a `dart:` library, a package + /// outside the project, a file the project does not have. Same-named + /// packages are told apart by the importer's own package, then by its + /// `path:` dependency. Upstream's fixture. + #[test] + fn pr2388_a_dart_import_links_the_file_its_uri_names() { + let project = Project::new() + .file("packages/riverpod/pubspec.yaml", "name: riverpod\n") + .file( + "packages/riverpod/lib/riverpod.dart", + "export 'src/core/foundation.dart';\nexport 'package:meta/meta.dart' show internal;\n", + ) + .file("packages/riverpod/lib/src/core/foundation.dart", "class ProviderBase {}\n") + .file( + "packages/riverpod/lib/src/core/container.dart", + "import 'dart:async';\n\nimport 'package:meta/meta.dart';\n\nimport 'foundation.dart';\n\nclass Container extends ProviderBase {}\n", + ) + .file("examples/pub/pubspec.yaml", "name: pub\n") + .file( + "examples/pub/lib/pub_repository.dart", + "import 'package:flutter/foundation.dart';\nimport 'package:riverpod/riverpod.dart';\nimport 'package:riverpod/src/core/foundation.dart' as core;\n\nclass PubRepository {}\n", + ) + .file("packages/bloc_tools/pubspec.yaml", "name: bloc_tools\n") + .file( + "packages/bloc_tools/lib/src/command_runner.dart", + "import 'package:args/command_runner.dart';\n\nclass BlocToolsCommandRunner {}\n", + ) + .file("examples/flutter_counter/pubspec.yaml", "name: flutter_counter\n") + .file( + "examples/flutter_counter/integration_test/app_test.dart", + "import 'package:integration_test/integration_test.dart';\n\nvoid main() {}\n", + ) + .file( + "examples/flutter_counter/test_driver/integration_test.dart", + "import 'package:integration_test/integration_test_driver.dart';\n\nFuture main() async {}\n", + ) + .file("pubspec.yaml", "name: app\n") + .file("test/src/utils.dart", "int errorsOf() => 0;\n") + .file("test/old/utils.dart", "int errorsOf() => 0;\n") + .file( + "test/feature/uni_directional_test.dart", + "import '../src/utils.dart';\n\nvoid main() {}\n", + ) + .file("website/docs/cancel/detail_screen/codegen.dart", "class DetailScreen {}\n") + .file( + "website/i18n/it/cancel/home_screen.dart", + "import 'detail_screen/codegen.dart';\n\nclass HomeScreen {}\n", + ) + .file( + "examples/login_a/pubspec.yaml", + "name: login_a\ndependencies:\n auth_repo:\n # the app's own copy\n path: packages/auth_repo\n bloc: ^9.0.0\n", + ) + .file("examples/login_a/packages/auth_repo/pubspec.yaml", "name: auth_repo\n") + .file( + "examples/login_a/packages/auth_repo/lib/auth_repo.dart", + "class AuthRepo {}\n", + ) + .file( + "examples/login_a/lib/app.dart", + "import 'package:auth_repo/auth_repo.dart';\n\nclass AppA {}\n", + ) + .file( + "examples/login_b/pubspec.yaml", + "name: login_b\ndependencies:\n auth_repo: { path: \"packages/auth_repo\" }\n", + ) + .file("examples/login_b/packages/auth_repo/pubspec.yaml", "name: auth_repo\n") + .file( + "examples/login_b/packages/auth_repo/lib/auth_repo.dart", + "class AuthRepo {}\n", + ) + .file( + "examples/login_b/lib/app.dart", + "import 'package:auth_repo/auth_repo.dart';\n\nclass AppB {}\n", + ) + .file("examples/login_c/pubspec.yaml", "name: login_c\n") + .file( + "examples/login_c/lib/app.dart", + "import 'package:auth_repo/auth_repo.dart';\n\nclass AppC {}\n", + ) + .file("packages/a/example/pubspec.yaml", "name: example\n") + .file( + "packages/a/example/lib/main.dart", + "import 'package:example/src/screen.dart';\n\nvoid main() {}\n", + ) + .file("packages/a/example/lib/src/screen.dart", "class ScreenA {}\n") + .file("packages/b/example/pubspec.yaml", "name: example\n") + .file("packages/b/example/lib/src/screen.dart", "class ScreenB {}\n") + .file( + "packages/riverpod/lib/src/missing_user.dart", + "import 'package:riverpod/src/core/foundation.g.dart';\nimport 'package:riverpod/src/missing_user.dart';\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let none = Vec::::new(); + assert_eq!( + imports_from(graph, "examples/pub/lib/pub_repository.dart"), + vec![ + "2 -> packages/riverpod/lib/riverpod.dart".to_string(), + "3 -> packages/riverpod/lib/src/core/foundation.dart".to_string(), + ], + "{report}" + ); + assert_eq!( + unresolved_imports_of(graph, "examples/pub/lib/pub_repository.dart"), + vec!["package:flutter/foundation.dart".to_string()], + "{report}" + ); + for file in [ + "packages/bloc_tools/lib/src/command_runner.dart", + "examples/flutter_counter/integration_test/app_test.dart", + "examples/flutter_counter/test_driver/integration_test.dart", + "website/i18n/it/cancel/home_screen.dart", + "packages/riverpod/lib/src/missing_user.dart", + "examples/login_c/lib/app.dart", + ] { + assert_eq!(imports_from(graph, file), none, "{file}\n{report}"); + } + assert_eq!( + imports_from(graph, "packages/riverpod/lib/src/core/container.dart"), + vec!["5 -> packages/riverpod/lib/src/core/foundation.dart".to_string()], + "{report}" + ); + assert_eq!( + unresolved_imports_of(graph, "packages/riverpod/lib/src/core/container.dart"), + vec![ + "dart:async".to_string(), + "package:meta/meta.dart".to_string() + ], + "{report}" + ); + assert_eq!( + imports_from(graph, "packages/riverpod/lib/riverpod.dart"), + vec!["1 -> packages/riverpod/lib/src/core/foundation.dart".to_string()], + "{report}" + ); + assert_eq!( + imports_from(graph, "test/feature/uni_directional_test.dart"), + vec!["1 -> test/src/utils.dart".to_string()], + "{report}" + ); + assert_eq!( + imports_from(graph, "examples/login_a/lib/app.dart"), + vec!["1 -> examples/login_a/packages/auth_repo/lib/auth_repo.dart".to_string()], + "{report}" + ); + assert_eq!( + imports_from(graph, "examples/login_b/lib/app.dart"), + vec!["1 -> examples/login_b/packages/auth_repo/lib/auth_repo.dart".to_string()], + "{report}" + ); + assert_eq!( + imports_from(graph, "packages/a/example/lib/main.dart"), + vec!["1 -> packages/a/example/lib/src/screen.dart".to_string()], + "{report}" + ); + } + + /// Upstream #2391: `part '';` mints an import node and links the + /// library's file to the part's by the URI rules an import follows, so a + /// change to a part reaches what depends on its library; `part of` + /// records nothing. Upstream's fixture (its viewer rails are W7's). + #[test] + fn pr2391_a_dart_library_links_the_files_its_parts_name() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "lib/src/framework.dart", + "import 'dart:async';\n\nimport 'package:meta/meta.dart';\n\npart 'core/element.dart';\npart \"core/container.dart\";\npart 'framework.g.dart';\n", + ) + .file( + "lib/src/core/element.dart", + "part of '../framework.dart';\n\nclass Element {}\n", + ) + .file( + "lib/src/core/container.dart", + "part of '../framework.dart';\n\nclass Container {}\n", + ) + .file( + "lib/src/framework.g.dart", + "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'framework.dart';\n\nString _$frameworkHash() => 'abc';\n", + ) + .file("lib/app.dart", "export 'src/framework.dart';\n") + .file( + "test/framework_test.dart", + "import 'package:app/app.dart';\n\nvoid main() {}\n", + ) + .file("packages/kit/pubspec.yaml", "name: kit\n") + .file("packages/kit/lib/kit.dart", "part 'package:kit/src/widgets.dart';\n") + .file( + "packages/kit/lib/src/widgets.dart", + "part of 'package:kit/kit.dart';\n\nclass Widget {}\n", + ) + .file("lib/legacy/models.dart", "library app.models;\n\npart 'user.dart';\n") + .file("lib/legacy/user.dart", "part of app.models;\n\nclass User {}\n") + .file("examples/a/pubspec.yaml", "name: a\n") + .file("examples/a/lib/main.dart", "part 'main.g.dart';\n\nvoid main() {}\n") + .file("examples/b/pubspec.yaml", "name: b\n") + .file("examples/b/lib/main.dart", "part 'main.g.dart';\n\nvoid main() {}\n") + .file("examples/b/lib/main.g.dart", "part of 'main.dart';\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + let none = Vec::::new(); + assert_eq!( + imports_from(graph, "lib/src/framework.dart"), + vec![ + "5 -> lib/src/core/element.dart".to_string(), + "6 -> lib/src/core/container.dart".to_string(), + "7 -> lib/src/framework.g.dart".to_string(), + ], + "{report}" + ); + assert_eq!( + unresolved_imports_of(graph, "lib/src/framework.dart"), + vec![ + "dart:async".to_string(), + "package:meta/meta.dart".to_string() + ], + "{report}" + ); + assert_eq!( + import_nodes_in(graph, "lib/src/framework.dart"), + vec![ + "core/container.dart | part \"core/container.dart\";".to_string(), + "core/element.dart | part 'core/element.dart';".to_string(), + "dart:async | import 'dart:async';".to_string(), + "framework.g.dart | part 'framework.g.dart';".to_string(), + "package:meta/meta.dart | import 'package:meta/meta.dart';".to_string(), + ], + "{report}" + ); + assert_eq!( + imports_from(graph, "packages/kit/lib/kit.dart"), + vec!["1 -> packages/kit/lib/src/widgets.dart".to_string()], + "{report}" + ); + assert_eq!( + imports_from(graph, "lib/legacy/models.dart"), + vec!["3 -> lib/legacy/user.dart".to_string()], + "{report}" + ); + for part in [ + "lib/src/core/element.dart", + "lib/src/framework.g.dart", + "packages/kit/lib/src/widgets.dart", + "lib/legacy/user.dart", + ] { + assert_eq!(imports_from(graph, part), none, "{part}\n{report}"); + assert_eq!(unresolved_imports_of(graph, part), none, "{part}\n{report}"); + assert_eq!(import_nodes_in(graph, part), none, "{part}\n{report}"); + } + assert_eq!( + imports_from(graph, "examples/a/lib/main.dart"), + none, + "{report}" + ); + assert_eq!( + unresolved_imports_of(graph, "examples/a/lib/main.dart"), + vec!["main.g.dart".to_string()], + "{report}" + ); + assert_eq!( + imports_from(graph, "examples/b/lib/main.dart"), + vec!["1 -> examples/b/lib/main.g.dart".to_string()], + "{report}" + ); + // A change to a part reaches its library and what imports it. + let output = support::codegraph( + project.root(), + ["affected", "lib/src/core/element.dart", "-p", "."], + ); + let affected: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("affected output"); + let mut files: Vec = affected["affectedFiles"] + .as_array() + .expect("affectedFiles") + .iter() + .filter_map(|f| f.as_str().map(str::to_string)) + .collect(); + files.sort(); + assert_eq!( + files, + vec![ + "lib/app.dart".to_string(), + "lib/src/framework.dart".to_string(), + "test/framework_test.dart".to_string(), + ], + "{affected}\n{report}" + ); + } +} + +/// W4-DART-03: Dart getter reads, the types a declaration or body names, and +/// initializers (upstream #2360, #2367). +mod dart_reads { + use super::support::{Graph, NodeRow, Project}; + + /// The node with qualified name `qualified`, in `file` when one is given. + #[track_caller] + fn node<'g>(graph: &'g Graph, file: Option<&str>, qualified: &str) -> &'g NodeRow { + let found: Vec<&NodeRow> = graph + .nodes + .iter() + .filter(|n| n.qualified_name == qualified && file.is_none_or(|f| n.file_path == f)) + .collect(); + assert_eq!(found.len(), 1, "one node {qualified}\n{}", graph.report()); + found[0] + } + + /// ` ` of each `calls`, `references` or + /// `instantiates` edge into the node `qualified`, sorted (upstream's + /// `callersOf`). + fn callers_of(graph: &Graph, qualified: &str) -> Vec { + let target = node(graph, None, qualified).id.clone(); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| { + e.target == target + && matches!(e.kind.as_str(), "calls" | "references" | "instantiates") + }) + .filter_map(|e| { + Some(format!( + "{} {}", + e.kind, + graph.node(&e.source)?.qualified_name + )) + }) + .collect(); + out.sort(); + out + } + + /// ` ` of each `kind` edge into the + /// node `qualified`, sorted and de-duplicated (upstream's `referrers` and + /// `sources`). + fn sources(graph: &Graph, file: Option<&str>, qualified: &str, kind: &str) -> Vec { + let target = node(graph, file, qualified).id.clone(); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.target == target && e.kind == kind) + .filter_map(|e| { + let source = graph.node(&e.source)?; + Some(format!("{} {}", source.kind, source.qualified_name)) + }) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// The qualified names of the targets of `from`'s `kind` edges, sorted + /// and de-duplicated (upstream's `targets`). + fn targets(graph: &Graph, file: &str, qualified: &str, kind: &str) -> Vec { + let source = node(graph, Some(file), qualified).id.clone(); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.source == source && e.kind == kind) + .filter_map(|e| Some(graph.node(&e.target)?.qualified_name.clone())) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// Upstream #2338 (`dart-getter-reads.test.ts`, its fixture): a getter + /// read runs the getter, so it links, as a call, the getter the + /// receiver's type reaches — declared on it, inherited, or added by an + /// extension, on an enum too — through a parameter, a local, a field or a + /// type named directly; a field read, an outside type's getter, an + /// untyped receiver and another library's unnamed extension link + /// nothing. A member an extension adds to an enum is reached by a call. + #[test] + fn pr2360_a_dart_getter_read_links_the_getter_its_receiver_reaches() { + let project = Project::new() + .file("pubspec.yaml", "name: repro\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\n") + .file( + "lib/shape.dart", + "enum Shape { circle, square }\n\nextension ShapeInfo on Shape {\n String get label => name.toUpperCase();\n String shout() => name.toUpperCase();\n}\n\nclass Box {\n const Box(this.size);\n final int size;\n int get area => size * size;\n int grow() => size + 1;\n}\n\nextension BoxActions on Box {\n int twice() => size * 2;\n}\n", + ) + .file( + "lib/use.dart", + "import 'shape.dart';\n\nString a(Shape s) => s.label; // getter, extension on enum\nString b(Shape s) => s.shout(); // method, extension on enum\nint c(Box x) => x.area; // getter, plain class\nint d(Box x) => x.grow(); // method, plain class\nint e(Box x) => x.twice(); // method, extension on class\n", + ) + .file( + "lib/render.dart", + "import 'shape.dart';\n\nclass Square extends Box {\n const Square(int size) : super(size);\n static Square get unit => const Square(1);\n}\n\nclass Renderer {\n final Box box;\n final Shape shape;\n Renderer(this.box, this.shape);\n\n int paint(Box other) {\n final local = Box(2);\n return other.area + local.area;\n }\n\n int viaField() {\n return box.area;\n }\n\n String tag() {\n final text = shape.label;\n return text + shape.shout();\n }\n}\n\nint inherited(Square s) {\n return s.area;\n}\n\nSquare staticGetter() => Square.unit;\n", + ) + .file( + "lib/other.dart", + "class Panel {\n int get size => 0;\n int get length => 0;\n int get grow => 0;\n}\n\nextension on Box {\n int get hidden => 0;\n}\n", + ) + .file( + "lib/negatives.dart", + "import 'shape.dart';\n\nint fieldRead(Box x) => x.size; // a field, not Panel's getter\nint outside(List xs) => xs.length; // a getter of a type outside the project\nint untyped(dynamic d) => d.area; // nothing says what d is\nint unnamed(Box x) => x.hidden; // an unnamed extension in another library\nint Function() tearOff(Box x) => x.grow; // a method read as a value, not run\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + + // Reads on a plain class, through a parameter, a local and a field, + // and an inherited getter. + assert_eq!( + callers_of(graph, "Box::area"), + [ + "calls Renderer::paint", + "calls Renderer::paint", + "calls Renderer::viaField", + "calls c", + "calls inherited", + ], + "{report}" + ); + // A getter an extension on an enum adds. + assert_eq!( + callers_of(graph, "ShapeInfo::label"), + ["calls Renderer::tag", "calls a"], + "{report}" + ); + // A static getter read through its class. + assert_eq!( + callers_of(graph, "Square::unit"), + ["calls staticGetter"], + "{report}" + ); + // A field, an outside type, an untyped receiver, an unnamed extension + // of another library: nothing. + let negatives: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.file_path == "lib/negatives.dart") + }) + .map(|e| graph.describe_edge(e)) + .collect(); + assert_eq!(negatives, Vec::::new(), "{report}"); + assert_eq!( + callers_of(graph, "Panel::size"), + Vec::::new(), + "{report}" + ); + assert_eq!( + callers_of(graph, "Panel::length"), + Vec::::new(), + "{report}" + ); + // A method an extension on an enum adds, and the class's own methods + // and extensions. + assert_eq!( + callers_of(graph, "ShapeInfo::shout"), + ["calls Renderer::tag", "calls b"], + "{report}" + ); + assert_eq!(callers_of(graph, "Box::grow"), ["calls d"], "{report}"); + assert_eq!( + callers_of(graph, "BoxActions::twice"), + ["calls e"], + "{report}" + ); + } + + /// Upstream #2327 (`dart-type-positions.test.ts`, its fixture): the type an + /// extension is `on`, a field's type (an enum's too), a top-level + /// variable's (the file's), a constant's initializer and every type a + /// body names (locals, generic arguments, casts, type tests, catch + /// clauses) are references; a constructor call instantiates without one, + /// and an import prefix names nothing. As amended by #2367: a `static + /// const` field's initializer constructs from its constant. + #[test] + fn pr2360_the_types_a_dart_declaration_or_body_names_are_referenced() { + let project = Project::new() + .file("pubspec.yaml", "name: repro\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\n") + .file("lib/report.dart", "class Report {\n final int score;\n const Report(this.score);\n}\n") + .file("lib/a_parameter.dart", "import 'report.dart';\n\nint score(Report r) => r.score;\n") + .file("lib/b_return_generic.dart", "import 'report.dart';\n\nFuture load() async => null;\n") + .file("lib/c_extension_on.dart", "import 'report.dart';\n\nextension ReportX on Report {\n bool get high => score > 5;\n}\n") + .file("lib/d_field_type.dart", "import 'report.dart';\n\nclass Holder {\n final Report report;\n const Holder(this.report);\n}\n") + .file("lib/e_toplevel_initializer_generic.dart", "import 'report.dart';\n\nclass Family {\n const Family();\n}\n\nfinal reportProvider = Family();\n") + .file("lib/f_expression_generic.dart", "import 'report.dart';\n\nvoid run() {\n Future.value(null);\n}\n") + .file("lib/model.dart", "class Score {\n const Score(this.value);\n final int value;\n}\n\nclass ScoreError implements Exception {}\n") + .file("lib/node.dart", "class Node {\n Node? next;\n}\n") + .file( + "lib/more.dart", + "import 'model.dart' as m;\nimport 'model.dart' as M;\nimport 'model.dart';\n\nextension ScoresX on List {\n int get total => length;\n}\n\nenum Grade {\n a(null);\n const Grade(this.best);\n final Score? best;\n}\n\nclass Board {\n Score? top;\n final List all = [];\n static const Score zero = Score(0);\n var cache = {};\n late final m.Score prefixed;\n late final M.Score upper;\n}\n\nScore? current;\nfinal scores = [];\n\nint body(Object o) {\n final Score s = o as Score;\n if (o is Score) {}\n final made = Score(1);\n final built = const Score(2);\n try {} on ScoreError catch (_) {}\n return s.value + made.value + built.value;\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + + assert_eq!( + sources(graph, None, "Report", "references"), + [ + "class Holder", + "class ReportX", + "constant reportProvider", + "function load", + "function run", + "function score", + ], + "{report}" + ); + assert_eq!( + sources(graph, None, "Score", "references"), + [ + "class Board", + "class ScoresX", + "constant scores", + "enum Grade", + "file lib/more.dart", + "function body", + ], + "{report}" + ); + assert_eq!( + sources(graph, None, "ScoreError", "references"), + ["function body"], + "{report}" + ); + // `Score(1)` and `const Score(2)` instantiate, and reference nothing of + // their own; nor does the import prefix `m`. + let body = node(graph, None, "body").id.clone(); + let score = node(graph, None, "Score").id.clone(); + let mut lines: Vec = graph + .edges + .iter() + .filter(|e| e.source == body && e.target == score && e.kind == "references") + .filter_map(|e| e.line) + .collect(); + lines.sort_unstable(); + lines.dedup(); + // Upstream's lines 26 and 27: this fixture adds an uppercase prefix's + // import and field above them. + assert_eq!(lines, [28, 29], "{report}"); + let mut built: Vec = graph + .edges + .iter() + .filter(|e| e.source == body && e.target == score && e.kind == "instantiates") + .filter_map(|e| e.line) + .collect(); + built.sort_unstable(); + assert_eq!(built, [30, 31], "{report}"); + assert_eq!( + sources(graph, None, "Score", "instantiates"), + ["constant Board::zero", "function body"], + "{report}" + ); + assert!( + !graph + .unresolved + .iter() + .any(|r| r.file_path == "lib/more.dart" + && matches!(r.reference_name.as_str(), "m" | "M")), + "{report}" + ); + // A class whose field holds its own type names itself: no self-edge. + assert_eq!( + sources(graph, None, "Node", "references"), + Vec::::new(), + "{report}" + ); + } + + /// Upstream #2367 (`dart-initializer-calls.test.ts`, its fixture): a + /// `final` / `const` initializer is code its constant runs, a field's or a + /// `var`'s is its class's or the file's, a local declaration's second + /// variable stays its function's, and a function passed as a value is + /// captured once. A call through a type's static constant calls that + /// constant. KEEP-RUST: this grammar ends an arrow function in front of + /// the call its body makes (`(ref) => createDio()`), which reads as the + /// call its body ends with. + #[test] + fn pr2367_a_dart_initializer_belongs_to_its_constant_class_or_file() { + let project = Project::new() + .file("pubspec.yaml", "name: app\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\n") + .file( + "lib/riverpod.dart", + "class Ref {\n T watch(Object provider) => throw 0;\n T read(Object provider) => throw 0;\n}\n\nclass Provider {\n Provider(T Function(Ref ref) create);\n\n static const autoDispose = AutoDisposeProviderBuilder();\n}\n\nclass AutoDisposeProviderBuilder {\n const AutoDisposeProviderBuilder();\n\n Provider call(T Function(Ref ref) create) => Provider(create);\n}\n\nclass ProviderFamilyBuilder {\n Object autoDispose() => this;\n}\n\nclass StateNotifierProvider {\n StateNotifierProvider(N Function(Ref ref) create);\n}\n\nclass FutureProvider {\n FutureProvider(Future Function(Ref ref) create);\n\n static const family = FutureProviderFamilyBuilder();\n}\n\nclass FutureProviderFamilyBuilder {\n const FutureProviderFamilyBuilder();\n\n FutureProvider call(Future Function(Ref ref, A arg) create) => throw 0;\n}\n", + ) + .file( + "lib/builders.dart", + "class AutoDisposeFutureProviderBuilder {\n Object family() => this;\n}\n", + ) + .file( + "lib/data.dart", + "class Dio {}\n\nclass Api {\n Api(Dio dio);\n}\n\nclass Repository {\n Repository(Dio dio);\n}\n\nclass Counter {\n Counter(Api api);\n}\n\nDio createDio() => Dio();\nvoid register(void Function() cb) {}\nint start() => 0;\n", + ) + .file( + "lib/providers.dart", + "import 'riverpod.dart';\nimport 'data.dart';\n\nfinal dioProvider = Provider((ref) => createDio());\nfinal apiProvider = Provider((ref) => Api(ref.watch(dioProvider)));\nfinal repoProvider = Provider((ref) => Repository(ref.watch(dioProvider)));\nfinal counterProvider = StateNotifierProvider((ref) => Counter(ref.read(apiProvider)));\n", + ) + .file( + "lib/more.dart", + "import 'riverpod.dart';\nimport 'data.dart';\nimport 'providers.dart';\n\nfinal typedProvider = Provider((Ref ref) => ref.read(dioProvider));\nfinal blockProvider = Provider((ref) {\n final dio = createDio();\n return Repository(dio);\n});\nvoid onTap() {}\nfinal wired = register(onTap);\nfinal handler = onTap;\nvar cache = createDio();\nvar registry = {};\nlate final lazyRepo = Repository(createDio());\nfinal autoProvider = Provider.autoDispose((ref) => createDio());\nfinal userProvider = FutureProvider.family((ref, int id) async => Api(createDio()));\n\nProvider makeAuto() => Provider.autoDispose((ref) => Dio());\n\nclass Service {\n static final instance = Service._(createDio());\n static var shared = Api(createDio());\n final repo = Repository(Dio());\n final Dio dio;\n Service._(this.dio);\n\n int run() {\n var total = 0;\n for (var i = 0, j = start(); i < j; i++) {\n total += i;\n }\n return total;\n }\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let providers = "lib/providers.dart"; + let more = "lib/more.dart"; + + // The providers construct what they build and call what they build it with. + assert_eq!( + targets(graph, providers, "dioProvider", "calls"), + ["createDio"], + "{report}" + ); + // The call is written where the arrow's tail starts (KEEP-RUST: this + // grammar ends the arrow in front of the call its body makes). + let dio = node(graph, Some(providers), "dioProvider").id.clone(); + let at: Vec<(Option, Option)> = graph + .edges + .iter() + .filter(|e| e.source == dio && e.kind == "calls") + .map(|e| (e.line, e.col)) + .collect(); + assert_eq!(at, [(Some(4), Some(38))], "{report}"); + // A `var`'s initializer is walked as a body, for the file: the types + // it names too. + assert!( + sources(graph, None, "Dio", "references").contains(&"file lib/more.dart".to_string()), + "{report}" + ); + assert_eq!( + targets(graph, providers, "apiProvider", "instantiates"), + ["Api", "Provider"], + "{report}" + ); + assert_eq!( + targets(graph, providers, "repoProvider", "instantiates"), + ["Provider", "Repository"], + "{report}" + ); + assert_eq!( + targets(graph, providers, "counterProvider", "instantiates"), + ["Counter", "StateNotifierProvider"], + "{report}" + ); + // A typed closure parameter types its receiver; a block-bodied closure is walked whole. + assert_eq!( + targets(graph, more, "typedProvider", "calls"), + ["Ref::read"], + "{report}" + ); + assert_eq!( + targets(graph, more, "blockProvider", "calls"), + ["createDio"], + "{report}" + ); + assert_eq!( + targets(graph, more, "blockProvider", "instantiates"), + ["Provider", "Repository"], + "{report}" + ); + // A function passed as a value, or held by a constant, is the + // constant's reference, captured once. + assert_eq!( + targets(graph, more, "wired", "calls"), + ["register"], + "{report}" + ); + assert_eq!( + sources(graph, Some(more), "onTap", "references"), + ["constant handler", "constant wired"], + "{report}" + ); + let on_tap = node(graph, Some(more), "onTap").id.clone(); + assert_eq!( + graph + .edges + .iter() + .filter(|e| e.target == on_tap && e.kind == "references") + .count(), + 2, + "{report}" + ); + // `var` and `late final` mint no node: the file runs them. + assert_eq!( + targets(graph, more, more, "calls"), + ["createDio"], + "{report}" + ); + assert_eq!( + targets(graph, more, more, "instantiates"), + ["Repository"], + "{report}" + ); + // A `static final` is the class's constant; a `static var` and an + // instance field mint no node, so they are the class's. + assert_eq!( + targets(graph, more, "Service::instance", "calls"), + ["Service::_", "createDio"], + "{report}" + ); + assert_eq!( + targets(graph, more, "Service", "calls"), + ["createDio"], + "{report}" + ); + assert_eq!( + targets(graph, more, "Service", "instantiates"), + ["Api", "Dio", "Repository"], + "{report}" + ); + // `Provider.autoDispose(…)` calls the static constant, whose own + // initializer constructs the builder, and not a same-named method of + // another type, in its file or not. + let auto_dispose = node(graph, Some("lib/riverpod.dart"), "Provider::autoDispose"); + assert_eq!(auto_dispose.kind, "constant", "{report}"); + assert_eq!( + targets(graph, more, "autoProvider", "calls"), + ["Provider::autoDispose", "createDio"], + "{report}" + ); + assert_eq!( + targets(graph, more, "makeAuto", "calls"), + ["Provider::autoDispose"], + "{report}" + ); + assert_eq!( + targets( + graph, + "lib/riverpod.dart", + "Provider::autoDispose", + "instantiates" + ), + ["AutoDisposeProviderBuilder"], + "{report}" + ); + assert_eq!( + sources(graph, None, "ProviderFamilyBuilder::autoDispose", "calls"), + Vec::::new(), + "{report}" + ); + assert_eq!( + targets(graph, more, "userProvider", "calls"), + ["FutureProvider::family", "createDio"], + "{report}" + ); + assert_eq!( + sources( + graph, + None, + "AutoDisposeFutureProviderBuilder::family", + "calls" + ), + Vec::::new(), + "{report}" + ); + // Each provider and declaration above is a caller of what it builds. + assert_eq!( + sources(graph, None, "createDio", "calls"), + [ + "class Service", + "constant Service::instance", + "constant autoProvider", + "constant blockProvider", + "constant dioProvider", + "constant userProvider", + "file lib/more.dart", + ], + "{report}" + ); + assert_eq!( + sources(graph, None, "Repository", "instantiates"), + [ + "class Service", + "constant blockProvider", + "constant repoProvider", + "file lib/more.dart", + ], + "{report}" + ); + // A local declaration's second variable is its function's, once. + assert_eq!( + targets(graph, more, "Service::run", "calls"), + ["start"], + "{report}" + ); + let run = node(graph, Some(more), "Service::run").id.clone(); + assert_eq!( + graph + .edges + .iter() + .filter(|e| e.source == run && e.kind == "calls") + .count(), + 1, + "{report}" + ); + assert_eq!( + sources(graph, None, "start", "calls"), + ["method Service::run"], + "{report}" + ); + } + + /// KEEP-RUST (W4-DART-03): what this grammar writes differently from + /// upstream's. A `new` or `const` call writes its class, and a named + /// constructor, in its `type` (`const Foo.named()`, a prefixed + /// `const p.Foo()`), and calls them; an arrow function ended in front of + /// the call or member access its body makes reads as the tail's: its + /// receiver typed (`(Ref ref) => ref.watch(x)` is `Ref`'s `watch`, and + /// `ref.read(x)` no namesake's `read` when `Ref` declares none), its + /// static read the type's, its member read the getter's. + #[test] + fn keep_rust_dart_constructor_calls_and_arrow_tails_read_as_written() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "lib/kit.dart", + "class Foo {\n const Foo();\n}\n\nclass Ref {\n int watch(Object p) => 0;\n}\n\nclass Box {\n int read() => 1;\n int get area => 1;\n}\n\nenum Mode { dark }\n", + ) + .file( + "lib/use.dart", + "import 'kit.dart';\nimport 'kit.dart' as p;\n\nvoid build() {\n final a = new Foo();\n final b = const p.Foo();\n final c = new Foo.named();\n final d = (Ref ref) => ref.watch(1);\n final g = (Ref ref) => ref.read(1);\n final e = (Object x) => Mode.dark;\n final f = (Box b) => b.area;\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let mut built: Vec<(Option, String)> = graph + .edges + .iter() + .filter(|e| e.kind == "instantiates") + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.qualified_name == "build") + }) + .filter_map(|e| Some((e.line, graph.node(&e.target)?.qualified_name.clone()))) + .collect(); + built.sort(); + assert_eq!( + built, + [(Some(5), "Foo".to_string()), (Some(6), "Foo".to_string())], + "{report}" + ); + assert!( + graph + .unresolved + .iter() + .any(|r| r.reference_name == "Foo.named" && r.reference_kind == "calls"), + "{report}" + ); + assert_eq!( + targets(graph, "lib/use.dart", "build", "calls"), + ["Box::area", "Ref::watch"], + "{report}" + ); + assert!( + targets(graph, "lib/use.dart", "build", "references").contains(&"Mode".to_string()), + "{report}" + ); + } + + /// Upstream #2338's `dartMemberOf`: an unnamed extension applies only in + /// its own library, so for a read in another one it is no candidate at + /// all, and a named extension's getter of the same rank answers, though + /// the unnamed one sits nearer the read (the tie would go to it). + #[test] + fn pr2360_an_unnamed_extension_of_another_library_is_passed_over_for_a_named_one() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file("lib/box.dart", "class Box {}\n") + .file( + "lib/a/other.dart", + "import '../box.dart';\n\nextension on Box {\n int get hidden => 0;\n}\n", + ) + .file( + "lib/b/named.dart", + "import '../box.dart';\n\nextension BoxHidden on Box {\n int get hidden => 1;\n}\n", + ) + .file("lib/a/use.dart", "import '../box.dart';\n\nint read(Box x) => x.hidden;\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + targets(graph, "lib/a/use.dart", "read", "calls"), + ["BoxHidden::hidden"], + "{report}" + ); + } +} + +/// W4-DART-03b: a later link of a Dart call chain calls a member of what the +/// chain before it evaluates to (upstream #2376, #2383). +mod dart_chains { + use super::support::{Graph, Project}; + + /// ` -> ` for every `calls` edge out + /// of a file's nodes, sorted (upstream's `callsFrom`). + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter_map(|e| { + let source = graph.node(&e.source)?; + (source.file_path == file).then_some(())?; + Some(format!( + "{} -> {}", + source.name, + graph.node(&e.target)?.qualified_name + )) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2376 (`dart-chain-links.test.ts`, its fixture): a later link + /// of a chain calls a member of what the chain before it evaluates to, + /// typed from the head and each link's declared type, or nothing: a + /// Stream's `transform` is no project method of that name, + /// `X.autoDispose.family(…)` is the `family` of the builder X's constant + /// holds, a fluent chain stays on the type each link returns, `super.m()` + /// is the superclass's, `context.read()` hands back a T, a + /// parenthesized receiver is what its last operand is, and a getter an + /// annotated extension adds is in the chain. + #[test] + fn pr2376_a_dart_chain_link_calls_a_member_of_what_the_chain_before_it_is() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file("lib/pipe.dart", "class BlocPipe {\n Object? transform(Object? value) => value;\n}\n") + .file("lib/bloc.dart", "import 'dart:async';\n\nclass Bloc {\n Stream transformEvents(Stream events, Stream Function(E) mapper) {\n return events\n .map(mapper)\n .transform(const _FlatMap());\n }\n}\n\nclass _FlatMap {\n const _FlatMap();\n}\n") + .file("lib/builders.dart", "class FutureProviderBuilder {\n const FutureProviderBuilder();\n\n FutureProviderFamilyBuilder get family => const FutureProviderFamilyBuilder();\n}\n\nclass FutureProviderFamilyBuilder {\n const FutureProviderFamilyBuilder();\n\n Object call(Object create) => create;\n}\n\nclass StateProviderBuilder {\n const StateProviderBuilder();\n\n StateProviderFamilyBuilder get family => const StateProviderFamilyBuilder();\n}\n\nclass StateProviderFamilyBuilder {\n const StateProviderFamilyBuilder();\n\n Object call(Object create) => create;\n}\n") + .file("lib/providers.dart", "import 'builders.dart';\n\nclass FutureProvider {\n static const autoDispose = FutureProviderBuilder();\n}\n\nclass StateProvider {\n static const autoDispose = StateProviderBuilder();\n}\n") + .file("lib/app.dart", "import 'providers.dart';\n\nfinal users = FutureProvider.autoDispose.family((ref, id) => 0);\nfinal counts = StateProvider.autoDispose\n .family((ref, id) => 0);\n") + .file("lib/sign_up/sign_up_state.dart", "class SignUpState {\n const SignUpState();\n\n SignUpState withEmail(String email) => this;\n SignUpState withPassword(String password) => this;\n}\n") + .file("test/sign_up/login_state.dart", "class LoginState {\n const LoginState();\n\n LoginState withEmail(String email) => this;\n LoginState withPassword(String password) => this;\n}\n") + .file("test/sign_up/sign_up_state_test.dart", "import 'package:app/sign_up/sign_up_state.dart';\n\nvoid main() {\n final constructed = SignUpState().withEmail('a').withPassword('b');\n final constant = const SignUpState().withEmail('a');\n}\n") + .file("lib/counter.dart", "abstract class BlocEventSink {\n void add(E event);\n}\n\nabstract class BlocBase {\n Future close() async {}\n}\n\nabstract class Bloc extends BlocBase implements BlocEventSink {\n @override\n void add(E event) {}\n\n @override\n Future close() async {\n await super.close();\n }\n}\n\nclass CounterBloc extends Bloc {\n void increment() {}\n}\n") + .file("lib/counter_view.dart", "import 'package:flutter/widgets.dart';\nimport 'counter.dart';\n\nclass CounterView {\n void onTap(BuildContext context) {\n context.read().add(1);\n }\n}\n") + .file("lib/todo.dart", "class Todo {\n const Todo();\n\n Todo copyWith({String? title}) => this;\n}\n") + .file("lib/edit_todo.dart", "import 'todo.dart';\n\nclass EditTodoState {\n const EditTodoState({this.initialTodo});\n\n final Todo? initialTodo;\n\n EditTodoState copyWith({Todo? initialTodo}) => this;\n}\n\nclass EditTodoBloc {\n EditTodoState state = const EditTodoState();\n\n void submit() {\n final todo = (state.initialTodo ?? const Todo()).copyWith(title: 'x');\n }\n}\n") + .file("lib/container.dart", "import 'package:meta/meta.dart';\n\nclass ProviderPointerManager {\n void readPointer(Object provider) {}\n}\n\nclass ProviderContainer {\n final ProviderPointerManager _pointerManager = ProviderPointerManager();\n}\n\n@internal\nextension ProviderContainerTest on ProviderContainer {\n ProviderPointerManager get pointerManager => _pointerManager;\n}\n\nvoid probe(ProviderContainer container) {\n container.pointerManager.readPointer(1);\n}\n") + .file("lib/other_manager.dart", "class OtherManager {\n void readPointer(Object provider) {}\n}\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + calls_from(graph, "lib/bloc.dart"), + Vec::::new(), + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/app.dart"), + [ + "counts -> StateProviderBuilder::family", + "users -> FutureProviderBuilder::family", + ], + "{report}" + ); + assert_eq!( + calls_from(graph, "test/sign_up/sign_up_state_test.dart"), + [ + "main -> SignUpState::withEmail", + "main -> SignUpState::withEmail", + "main -> SignUpState::withPassword", + ], + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/counter.dart"), + ["close -> BlocBase::close"], + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/counter_view.dart"), + ["onTap -> Bloc::add"], + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/edit_todo.dart"), + ["submit -> Todo::copyWith"], + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/container.dart"), + [ + "probe -> ProviderContainerTest::pointerManager", + "probe -> ProviderPointerManager::readPointer", + ], + "{report}" + ); + } + + /// Upstream #2383 (`dart-generic-factory-chain.test.ts`, its fixtures): a + /// call chained on a generic static factory calls a member of the type + /// the factory's type argument names, whether the factory is the + /// project's (`static T of(…)`, its own type parameter given by the + /// call) or a package's (an outside `of(…)` hands back its T), wrapped, + /// through an import prefix, through `?.`, or inherited; never a member + /// found by its name, and nothing without a type argument. KEEP-RUST: the + /// extractor records every such link by its bare name, so upstream's + /// fallback in `matchDottedCallChain` has no counterpart: the chain is + /// typed at the call site either way. + #[test] + fn pr2383_a_chain_on_a_generic_lookup_calls_a_member_of_its_type_argument() { + let library = Project::new() + .file("pubspec.yaml", "name: app\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\n") + .file("lib/bloc_provider.dart", "class BlocProvider {\n const BlocProvider();\n\n static T of(Object context, {bool listen = false}) => throw 0;\n\n static T? maybeOf(Object context, {bool listen = false}) => null;\n}\n") + .file("lib/bloc.dart", "abstract class Bloc {\n void add(E event) {}\n}\n") + .file("lib/counter/counter_cubit.dart", "class CounterCubit {\n void increment() {}\n}\n") + .file("lib/counter/counter_bloc.dart", "import '../bloc.dart';\n\nclass CounterBloc extends Bloc {}\n") + .file("lib/counter/step_counter.dart", "class StepCounter {\n void increment() {}\n void decrement() {}\n void saveTodo(Object todo) {}\n}\n") + .file("lib/counter/counter_page.dart", "import '../bloc_provider.dart';\nimport 'counter_cubit.dart';\nimport 'counter_cubit.dart' as cubits;\nimport 'counter_bloc.dart';\n\nclass Button {\n const Button({Object? onPressed});\n}\n\nclass CounterPage {\n void tap(Object context) {\n BlocProvider.of(context, listen: false).increment();\n }\n\n void tapWrapped(Object context) {\n BlocProvider.of(context, listen: false)\n .increment();\n }\n\n void tapPrefixed(Object context) {\n BlocProvider.of(context, listen: false).increment();\n }\n\n void tapMaybe(Object context) {\n BlocProvider.maybeOf(context, listen: false)?.increment();\n }\n\n void tapBloc(Object context) {\n BlocProvider.of(context, listen: false).add(1);\n }\n\n void tapMissing(Object context) {\n BlocProvider.of(context, listen: false).decrement();\n }\n\n void tapInferred(Object context) {\n BlocProvider.of(context, listen: false).increment();\n }\n\n // Parsed as two comparisons, so increment arrives by its bare name.\n void tapRecovered(Object context) {\n BlocProvider.of(context).increment();\n }\n\n Object build(Object context) => Button(\n onPressed: () => BlocProvider.of(context, listen: false).increment(),\n );\n}\n") + .index(); + let graph = library.graph(); + let report = graph.report(); + assert_eq!( + calls_from(graph, "lib/counter/counter_page.dart"), + [ + "build -> BlocProvider::of", + "build -> CounterCubit::increment", + "tap -> BlocProvider::of", + "tap -> CounterCubit::increment", + "tapBloc -> Bloc::add", + "tapBloc -> BlocProvider::of", + "tapInferred -> BlocProvider::of", + "tapMaybe -> BlocProvider::maybeOf", + "tapMaybe -> CounterCubit::increment", + "tapMissing -> BlocProvider::of", + "tapPrefixed -> BlocProvider::of", + "tapPrefixed -> CounterCubit::increment", + "tapRecovered -> BlocProvider::of", + "tapRecovered -> CounterCubit::increment", + "tapWrapped -> BlocProvider::of", + "tapWrapped -> CounterCubit::increment", + ], + "{report}" + ); + let app = Project::new() + .file("pubspec.yaml", "name: app\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\n") + .file("lib/counter/counter_cubit.dart", "class CounterCubit {\n void increment() {}\n}\n") + .file("lib/counter/step_counter.dart", "class StepCounter {\n void increment() {}\n void decrement() {}\n void saveTodo(Object todo) {}\n}\n") + .file("lib/todos/todos_repository.dart", "class TodosRepository {\n void saveTodo(Object todo) {}\n}\n") + .file("lib/counter/counter_page.dart", "import 'package:flutter_bloc/flutter_bloc.dart';\nimport '../todos/todos_repository.dart';\nimport 'counter_cubit.dart';\n\nclass CounterPage {\n void tap(Object context) {\n BlocProvider.of(context, listen: false).increment();\n }\n\n void save(Object context, Object todo) {\n RepositoryProvider.of(context, listen: false).saveTodo(todo);\n }\n\n void tapInferred(Object context) {\n BlocProvider.of(context, listen: false).increment();\n }\n\n // Parsed as two comparisons, so increment arrives by its bare name.\n void tapRecovered(Object context) {\n BlocProvider.of(context).increment();\n }\n}\n") + .index(); + let graph = app.graph(); + let report = graph.report(); + assert_eq!( + calls_from(graph, "lib/counter/counter_page.dart"), + [ + "save -> TodosRepository::saveTodo", + "tap -> CounterCubit::increment", + "tapRecovered -> CounterCubit::increment", + ], + "{report}" + ); + } + + /// KEEP-RUST: each piece of evidence a chain's typing reads, as upstream + /// #2376 reads it: an import prefix before the head names a library; a + /// `const` / `new` head's named constructor is its own link, and one the + /// index holds no node for constructs its type; a call through a type + /// reaches only its static members (a factory, not an instance getter of + /// its name); a called value runs its type's `call`; a lookup its type + /// declares answers for itself, its type argument does not; a return type + /// that is a type parameter of the declaring class says nothing; an + /// initializer's type arguments end nothing; a constant's declared type + /// wins; an uppercase constant is a value; an abstract getter's type and a + /// member-level initializer type a field, a method body's local of its + /// name does not; no call runs a setter; an enum value is its enum; a + /// `toString()` is a String; `(o as T)` is a T; a method named without a + /// call is no value; a `catch` variable says no type; a bare call in a + /// class is a member of it first; a closure's untyped parameter hides a + /// field of its name. And the reading of a reference placed where its + /// chain starts: a receiver-less call of the name inside the chain's + /// arguments is that call's own; an arrow this grammar ended in front of + /// a call (`() => a ?? b.load()`, recorded by the bare name where the + /// arrow's tail starts) is a chain link written after `b.`, which links + /// nothing rather than a namesake. + #[test] + fn keep_rust_dart_chain_typing_reads_the_evidence_upstream_reads() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file("lib/types.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n}\n\nclass Repo {\n Item load() => Item();\n static Repo create() => Repo();\n}\n\nclass Konst {\n const Konst();\n const Konst.named();\n factory Konst.build() => const Konst();\n Item make() => Item();\n}\n\nclass Res {\n Res();\n Item get error => Item();\n Other other() => Other();\n\n factory Res.error() => Res();\n}\n\nclass Family {\n Item call() => Item();\n}\n\nclass Builder {\n Family get family => Family();\n}\n\nclass Store {\n read() => null;\n}\n\nclass Holder {\n Item get held => throw 0;\n}\n\nclass Pair {\n static Pair of() => throw 0;\n Item first() => Item();\n}\n\nabstract class Src {\n Item get item;\n}\n\nclass Late {\n void m() {\n final cache = Other();\n }\n\n final cache = Cache();\n}\n\nclass Cache {\n Item get() => Item();\n}\n\nclass Slot {\n set value(int v) {}\n}\n\nclass Box {\n Slot wrap() => Slot();\n}\n\nclass A {\n B next() => B();\n}\n\nclass B {\n void m() {}\n}\n\nclass Field {\n final A a = A();\n\n void viaClosure() {\n [1].forEach((a) => a.next().m());\n }\n\n Item make() => Item();\n\n void viaMember() {\n make().touch();\n }\n}\n\nenum Mode { dark }\n\nextension ModeX on Mode {\n Item label() => Item();\n}\n\nextension StringX on String {\n Item asItem() => Item();\n}\n\nRepo make() => Repo();\n\nObject makeAny() => Object();\n\nfinal Repo declared = makeAny();\n\nfinal pair = Pair.of();\n\nfinal Cfg = Repo();\n\nItem wrapIt(Object o) => Item();\n\nvoid touch() {}\n") + .file( + "lib/lib.dart", + "import 'types.dart';\nimport 'other.dart';\n\npart 'part.dart';\n\nItem mk2() => Item();\n", + ) + .file("lib/other.dart", "import 'types.dart';\n\nOther mk2() => Other();\n") + .file("lib/part.dart", "part of 'lib.dart';\n\nvoid inPart() {\n mk2().save();\n}\n") + .file("lib/use.dart", "import 'types.dart';\nimport 'types.dart' as t;\n\nvoid prefixedChain() {\n t.Repo.create().load().save();\n}\n\nvoid prefixedFactory() {\n t.Konst.build();\n}\n\nvoid namedConst() {\n const Konst.named().make().save();\n Konst.named().make().save();\n Konst.build().make().save();\n}\n\nvoid staticAgainstInstance() {\n Res.error().other().save();\n}\n\nvoid calledValues(Family fam, Builder b) {\n fam().save();\n b.family().save();\n}\n\nvoid declaredLookup(Store store) {\n store.read().load();\n}\n\nvoid ownersParameter(Holder holder) {\n holder.held.save();\n}\n\nvoid typeArgumentsInInitializer() {\n pair.first().save();\n}\n\nvoid declaredConstant() {\n declared.load().save();\n}\n\nvoid uppercaseConstant() {\n Cfg.load().save();\n}\n\nvoid abstractGetter(Src s) {\n s.item.save();\n}\n\nvoid memberLevelField(Late late) {\n late.cache.get().save();\n}\n\nvoid noSetterCall(Box box) {\n box.wrap().value(1);\n}\n\nvoid enumValue() {\n Mode.dark.label().save();\n}\n\nvoid toStringIsAString() {\n make().toString().asItem().save();\n}\n\nvoid cast(Object o) {\n (o as Item?)!.touch();\n}\n\nvoid tearOff(Item item) {\n item.touch.save();\n}\n\nvoid caught(Repo r) {\n try {} catch (r) {\n r.load().save();\n }\n}\n\nvoid innerFirst() {\n wrapIt(touch()).touch();\n}\n\nvoid misparsedArrow(Repo? a, Repo b) {\n final f = () => a ?? b.load();\n}\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + let from = |file: &str, caller: &str| -> Vec { + calls_from(graph, file) + .into_iter() + .filter(|edge| edge.starts_with(&format!("{caller} -> "))) + .collect() + }; + let use_from = |caller: &str| from("lib/use.dart", caller); + assert_eq!( + use_from("prefixedChain"), + [ + "prefixedChain -> Item::save", + "prefixedChain -> Repo::create", + "prefixedChain -> Repo::load" + ], + "{report}" + ); + // `const Konst.named();` is indexed since upstream #2380, so both + // calls of it link the constructor, the chains typed as before. + assert_eq!( + use_from("namedConst"), + [ + "namedConst -> Item::save", + "namedConst -> Item::save", + "namedConst -> Item::save", + "namedConst -> Konst::build", + "namedConst -> Konst::make", + "namedConst -> Konst::make", + "namedConst -> Konst::make", + "namedConst -> Konst::named", + "namedConst -> Konst::named" + ], + "{report}" + ); + assert_eq!( + use_from("staticAgainstInstance"), + [ + "staticAgainstInstance -> Other::save", + "staticAgainstInstance -> Res::error", + "staticAgainstInstance -> Res::other" + ], + "{report}" + ); + assert_eq!( + use_from("calledValues"), + [ + "calledValues -> Builder::family", + "calledValues -> Item::save", + "calledValues -> Item::save" + ], + "{report}" + ); + assert_eq!( + use_from("declaredLookup"), + ["declaredLookup -> Store::read"], + "{report}" + ); + assert_eq!( + use_from("ownersParameter"), + ["ownersParameter -> Holder::held"], + "{report}" + ); + assert_eq!( + use_from("typeArgumentsInInitializer"), + [ + "typeArgumentsInInitializer -> Item::save", + "typeArgumentsInInitializer -> Pair::first" + ], + "{report}" + ); + // The call through the constant itself is the plain path's, which + // reads no other file's constant; the link after it is typed by it. + assert_eq!( + use_from("declaredConstant"), + ["declaredConstant -> Item::save"], + "{report}" + ); + // The call through the constant itself is the plain path's, which + // reads no other file's constant; the link after it is typed by it. + assert_eq!( + use_from("uppercaseConstant"), + ["uppercaseConstant -> Item::save"], + "{report}" + ); + assert_eq!( + use_from("abstractGetter"), + ["abstractGetter -> Item::save"], + "{report}" + ); + assert_eq!( + use_from("memberLevelField"), + [ + "memberLevelField -> Cache::get", + "memberLevelField -> Item::save" + ], + "{report}" + ); + assert_eq!( + use_from("noSetterCall"), + ["noSetterCall -> Box::wrap"], + "{report}" + ); + assert_eq!( + use_from("enumValue"), + ["enumValue -> Item::save", "enumValue -> ModeX::label"], + "{report}" + ); + assert_eq!( + use_from("toStringIsAString"), + [ + "toStringIsAString -> Item::save", + "toStringIsAString -> StringX::asItem", + "toStringIsAString -> make" + ], + "{report}" + ); + // A named factory reached through a prefixed type is its static + // member, by the `Konst.build(` its head writes. + assert_eq!( + use_from("prefixedFactory"), + ["prefixedFactory -> Konst::build"], + "{report}" + ); + // A part's library holds the function its call names. + assert_eq!( + from("lib/part.dart", "inPart"), + ["inPart -> Item::save", "inPart -> mk2"], + "{report}" + ); + assert_eq!(use_from("cast"), ["cast -> Item::touch"], "{report}"); + assert_eq!(use_from("tearOff"), Vec::::new(), "{report}"); + // `r.load()` is the plain path's (it reads the parameter); the link + // after it reads the `catch` variable, which says no type. + assert_eq!(use_from("caught"), ["caught -> Repo::load"], "{report}"); + assert_eq!( + use_from("innerFirst"), + [ + "innerFirst -> Item::touch", + "innerFirst -> touch", + "innerFirst -> wrapIt" + ], + "{report}" + ); + assert_eq!(use_from("misparsedArrow"), Vec::::new(), "{report}"); + assert_eq!( + from("lib/types.dart", "viaClosure"), + ["viaClosure -> A::next"], + "{report}" + ); + assert_eq!( + from("lib/types.dart", "viaMember"), + ["viaMember -> Field::make", "viaMember -> Item::touch"], + "{report}" + ); + } + + /// KEEP-RUST: a later link is read back after the place the extractor + /// put it, in the encoding the extractor wrote. A link after a receiver + /// no chain reads (`fs[0]().m()`) links nothing, not a namesake of the + /// type around it; a bare link and a link past a segment at one place + /// (`x.s.save().save()`) are each their own; an uppercase constant heads + /// a chain as the value it holds, not as a type. + #[test] + fn keep_rust_dart_chain_links_are_read_back_as_the_extractor_recorded_them() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "lib/types.dart", + "class Item {\n void save() {}\n}\n\nclass Other {\n Item save() => Item();\n}\n\nclass Holder {\n Other get s => Other();\n}\n\nclass Repo {\n Item load() => Item();\n}\n\nfinal Instance = Repo();\n", + ) + .file( + "lib/use.dart", + "import 'types.dart';\n\nclass Box {\n void m() {}\n\n void unreadable(List fs) {\n fs[0]().m();\n }\n}\n\nvoid encodings(Holder x) {\n x.s.save().save();\n}\n\nvoid uppercase() {\n Instance.load().save();\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let calls = calls_from(graph, "lib/use.dart"); + assert!( + !calls.contains(&"unreadable -> Box::m".to_string()), + "{report}" + ); + assert!( + calls.contains(&"encodings -> Other::save".to_string()) + && calls.contains(&"encodings -> Item::save".to_string()), + "{report}" + ); + assert!( + calls.contains(&"uppercase -> Item::save".to_string()), + "{report}" + ); + } + + /// KEEP-RUST: the extractor keeps a member chain's last segment + /// (`x.a.m()` is `a.m`, #1496), records a call through `?.` as it + /// records one through `.` (`y?.m()` is `y.m`, `make()?.load()` is + /// `load`), and places every reference of a chain where the chain starts: + /// the link is the call written after that position as the reference + /// records it, on its line or below it, so a receiver-less call of the + /// name before it (`touch(); r.load().touch();`) is not it. One chain's + /// links of a name carry one reference, which links only what all of them + /// reach (`b.add().add()` does; `p.flip().flip().flip()`, a Q's then a + /// P's, links nothing). A field's initializer types it (`this._cache`), + /// a static method's and a top-level constant's declared types type the + /// links after them, and an extension on a type parameter of its own + /// applies to a project type. The extractor now reads a method's return + /// type and `static` through the signature this grammar wraps them in. + #[test] + fn keep_rust_dart_chain_links_recorded_past_a_member_or_through_null_aware_calls() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file("lib/types.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Repo {\n Item load() => Item();\n static Repo create() => Repo();\n}\n\nclass A {\n void m() {}\n B next() => B();\n}\n\nclass B {\n void m() {}\n A next() => A();\n}\n\nclass P {\n Q flip() => Q();\n}\n\nclass Q {\n P flip() => P();\n}\n\nclass Builder {\n Builder add() => this;\n}\n\nclass Cache {\n Item get() => Item();\n}\n\nclass X {\n final A a;\n final _cache = Cache();\n X(this.a);\n\n void viaThis() {\n this.a.m();\n this._cache.get().save();\n }\n}\n\nclass Y {\n void m() {}\n X go(Object o) => X(A());\n}\n\nclass W {\n final B a;\n W(this.a);\n}\n\nclass Base {\n void dispose() {}\n}\n\nclass Child extends Base {\n @override\n void dispose() {\n super.dispose();\n }\n}\n\nextension ObjectX on T {\n T also() => this;\n}\n\nfinal repo = Repo();\n\nRepo make() => Repo();\n\nvoid go() {}\n\nvoid touch() {}\n\nclass Konst {\n const Konst();\n}\n") + .file( + "lib/prefixed.dart", + "import 'types.dart' as t;\n\nvoid prefixed() {\n final k = const t.Konst();\n final n = new t.Konst();\n}\n", + ) + .file("lib/use.dart", "import 'types.dart';\n\nvoid viaMember(X x, X? n) {\n x.a.m();\n x.a?.m();\n n?.a.m();\n}\n\nvoid twoChains(Y y, W w) {\n y.go(1).a.m();\n w.a.m();\n}\n\nvoid nullAware(Y? y, Object? u) {\n y?.m();\n make()?.load().save();\n u?.unknown();\n}\n\nvoid multiLine(Repo r) {\n r\n .load()\n .save();\n}\n\nvoid nested(Y y) {\n y.go(go()).a.m();\n}\n\nvoid sameLine(Repo r) {\n touch(); r.load().touch();\n}\n\nvoid repeated(A a, P p, Builder b) {\n a.next().next().m();\n p.flip().flip().flip();\n b.add().add().add();\n}\n\nvoid statics() {\n Repo.create().load().save();\n}\n\nvoid topLevel() {\n repo.load().touch().save();\n}\n\nvoid universal(Item item) {\n item.touch().also().save();\n}\n\nvoid selfNamed() {\n final self = Item();\n self.touch().save();\n}\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + let from = |caller: &str| -> Vec { + calls_from(graph, "lib/use.dart") + .into_iter() + .filter(|edge| edge.starts_with(&format!("{caller} -> "))) + .collect() + }; + assert_eq!( + from("viaMember"), + [ + "viaMember -> A::m", + "viaMember -> A::m", + "viaMember -> A::m" + ], + "{report}" + ); + // Each chain's link is its own: `w.a.m()` below is another chain's, + // through a W's B. + assert_eq!( + from("twoChains"), + [ + "twoChains -> A::m", + "twoChains -> B::m", + "twoChains -> Y::go" + ], + "{report}" + ); + assert_eq!( + from("nullAware"), + [ + "nullAware -> Item::save", + "nullAware -> Repo::load", + "nullAware -> Y::m", + "nullAware -> make" + ], + "{report}" + ); + assert!( + graph + .unresolved + .iter() + .any(|r| r.reference_name == "u.unknown"), + "{report}" + ); + assert_eq!( + from("multiLine"), + ["multiLine -> Item::save", "multiLine -> Repo::load"], + "{report}" + ); + assert_eq!( + from("nested"), + ["nested -> A::m", "nested -> Y::go", "nested -> go"], + "{report}" + ); + assert_eq!( + from("sameLine"), + [ + "sameLine -> Item::touch", + "sameLine -> Repo::load", + "sameLine -> touch" + ], + "{report}" + ); + assert_eq!( + from("repeated"), + [ + "repeated -> A::m", + "repeated -> A::next", + "repeated -> B::next", + // `b.add` and both links: one site, one edge. + "repeated -> Builder::add", + "repeated -> P::flip", + ], + "{report}" + ); + assert_eq!( + from("statics"), + [ + "statics -> Item::save", + "statics -> Repo::create", + "statics -> Repo::load" + ], + "{report}" + ); + assert_eq!( + from("topLevel"), + [ + "topLevel -> Item::save", + "topLevel -> Item::touch", + "topLevel -> Repo::load" + ], + "{report}" + ); + // `also` returns its extension's own `T`: what it is called on says + // nothing more, so `save` links nothing. + assert_eq!( + from("universal"), + ["universal -> Item::touch", "universal -> ObjectX::also"], + "{report}" + ); + // A Dart `self` is a variable like any other, not the object as in + // Python or Rust: `self.touch()` is a call through it, and the link + // after it is typed by its declaration. + assert_eq!( + from("selfNamed"), + ["selfNamed -> Item::save", "selfNamed -> Item::touch"], + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/types.dart"), + [ + "dispose -> Base::dispose", + "viaThis -> A::m", + "viaThis -> Cache::get", + "viaThis -> Item::save", + ], + "{report}" + ); + // A constructor called through an import prefix is no chain link: it + // instantiates what that import exports. + let mut built: Vec<(Option, String)> = graph + .edges + .iter() + .filter(|e| e.kind == "instantiates") + .filter(|e| { + graph + .node(&e.source) + .is_some_and(|n| n.qualified_name == "prefixed") + }) + .filter_map(|e| Some((e.line, graph.node(&e.target)?.qualified_name.clone()))) + .collect(); + built.sort(); + assert_eq!( + built, + [ + (Some(4), "Konst".to_string()), + (Some(5), "Konst".to_string()) + ], + "{report}" + ); + let create = graph + .nodes + .iter() + .find(|n| n.qualified_name == "Repo::create") + .expect("Repo::create"); + assert!(create.is_static, "{report}"); + assert_eq!(create.return_type.as_deref(), Some("Repo"), "{report}"); + let touch = graph + .nodes + .iter() + .find(|n| n.qualified_name == "Item::touch") + .expect("Item::touch"); + assert!(!touch.is_static, "{report}"); + assert_eq!(touch.return_type.as_deref(), Some("Item"), "{report}"); + } +} + +mod dart_local_scope { + use super::support::{Graph, Project}; + + /// `:` of each call or construction out of + /// the node `qualified` of `file`, sorted (upstream's `calls`). + fn calls(graph: &Graph, file: &str, qualified: &str) -> Vec { + let node = graph + .nodes + .iter() + .find(|n| n.file_path == file && n.qualified_name == qualified) + .unwrap_or_else(|| panic!("{file} {qualified}\n{}", graph.report())); + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.source == node.id && (e.kind == "calls" || e.kind == "instantiates")) + .filter_map(|e| { + Some(format!( + "{}:{}", + graph.node(&e.target)?.qualified_name, + e.line? + )) + }) + .collect(); + out.sort(); + out + } + + /// KEEP-RUST: the extractor records a chain's later link where the chain + /// starts, so whether a call has a receiver is read as the bare-call + /// rules read it: `repo.build().create()` calls `Builder.create`, though + /// the local it initializes, written nearer the chain's start, is named + /// `create` too. A local function is no member: inside a method it is + /// nearer than the method the class inherits, and a spread's `...items()` + /// calls its parameter (W4-DART-03c). + #[test] + fn keep_rust_a_chain_link_and_a_local_function_are_read_as_written() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "lib/repo.dart", + "class Item {}\n\nclass Builder {\n Item create() => Item();\n}\n\nclass Repo {\n Builder build() => Builder();\n}\n\nvoid make(Repo repo) {\n final create = repo.build().create();\n print(create);\n}\n\nclass Base {\n void local() {}\n}\n\nclass Sub extends Base {\n void m() {\n void local() {}\n local();\n }\n}\n\nList items() => [];\n\nList spread(List Function() items) => [...items()];\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + calls(graph, "lib/repo.dart", "make"), + ["Builder::create:12", "Repo::build:12"], + "{report}" + ); + assert_eq!( + calls(graph, "lib/repo.dart", "Sub::m"), + ["Sub::m::local:23"], + "{report}" + ); + // A spread's call has no receiver: the parameter it calls. + assert_eq!( + calls(graph, "lib/repo.dart", "spread"), + Vec::::new(), + "{report}" + ); + } + + /// Upstream #2379 (`dart-local-call-scope.test.ts`, its fixture): a bare + /// Dart call to a name a parameter or local around it binds calls that + /// parameter or local, never a namesake elsewhere — riverpod's + /// `create(ref, arg)`, a closure's parameter, a loop variable, a test's + /// mock, a bloc handler's `emit` parameter — while calls the binding does + /// not cover, `this.` calls and calls to a local function link as before. + /// KEEP-RUST: the extractor qualifies a local function by the function + /// it is declared in (`outer::helper`, `outer::make`; upstream's are + /// `helper`, `make`), and attributes a call in a local function's body to + /// the function around it too. + #[test] + fn pr2379_a_dart_call_to_a_parameter_or_local_calls_that() { + let project = Project::new() + .file("pubspec.yaml", "name: app\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\n") + .file("doc/mutation.dart", "class Mutation {\n Object call(String key) => key;\n}\n\nfinal create = Mutation();\nfinal createTodo = create('create_todo');\n") + .file("lib/family.dart", "class Ref {}\n\nclass Override {\n Override(Object Function(Ref ref) build);\n}\n\nmixin FunctionalFamilyOverride {\n ArgT get argument => throw 0;\n\n Override overrideWith(CreatedT Function(Ref ref, ArgT arg) create) {\n return Override((ref) => create(ref, argument) as Object);\n }\n}\n\nclass Cache {\n final _values = {};\n\n T upsert(Object key, T Function() create) {\n final existing = _values[key];\n if (existing != null) return existing;\n final created = create();\n _values[key] = created;\n return created;\n }\n}\n\nclass Holder {\n void create() {}\n\n void run(void Function() create) {\n this.create();\n create();\n }\n}\n\nenum Kind { set, list }\n\nR pick({required R Function() set, required R Function() list}) => set();\n\nclass Box {}\n\nBox? find() => null;\n\nvoid check() {\n final Box? box = find();\n print(box);\n Box();\n}\n") + .file("test/matrix.dart", "typedef Factory = Object Function(Object Function(Object ref, Object? arg) create, {String? name});\n\nfinal factories = [\n (create, {name}) => create(Object(), name),\n];\n\nfinal generic = (T Function() create) => create();\n") + .file("test/other_test.dart", "void listener() {}\n\nvoid fn() {}\n") + .file("test/listener_test.dart", "class Listener {\n void call(T? previous, T next) {}\n}\n\nvoid main() {\n final listener = Listener();\n listener(0, 1);\n\n void Function() build = () {};\n build();\n\n for (final fn in []) {\n fn();\n }\n\n final (create, _) = (() => 0, 1);\n create();\n}\n") + .file("lib/bloc.dart", "abstract class Emitter {\n void call(S state);\n}\n\nclass Bloc {\n void emit(S state) {}\n\n void on(void Function(E event, Emitter emit) handler) {}\n}\n\nclass CounterBloc extends Bloc {\n CounterBloc() {\n on((event, emit) => emit(1));\n }\n\n void onReset(String event, Emitter emit) {\n emit(0);\n }\n\n void bump() => emit(2);\n}\n") + .file("lib/util.dart", "void notify(int value) {}\n") + .file("lib/wire.dart", "import 'util.dart';\n\nvoid wire(List values) {\n values.forEach((notify) => print(notify));\n notify(1);\n for (final notify in values) {\n print(notify);\n }\n notify(2);\n}\n\nint outer(int Function() make) {\n int helper() {\n int make() => 2;\n return make();\n }\n\n return helper() + make();\n}\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + let none = Vec::::new(); + + // Only the docs example's own file calls into it. + let docs: Vec<&str> = graph + .nodes + .iter() + .filter(|n| n.file_path == "doc/mutation.dart") + .map(|n| n.id.as_str()) + .collect(); + let mut callers: Vec<&str> = graph + .edges + .iter() + .filter(|e| { + (e.kind == "calls" || e.kind == "instantiates") && docs.contains(&e.target.as_str()) + }) + .filter_map(|e| Some(graph.node(&e.source)?.file_path.as_str())) + .collect(); + callers.dedup(); + assert_eq!(callers, ["doc/mutation.dart"], "{report}"); + + // A parameter: a method's, a function-typed one inside a closure, + // closures' in initializers; one may be named `set`. + assert_eq!( + calls( + graph, + "lib/family.dart", + "FunctionalFamilyOverride::overrideWith" + ), + ["Override:11"], + "{report}" + ); + assert_eq!( + calls(graph, "lib/family.dart", "Cache::upsert"), + none, + "{report}" + ); + assert_eq!(calls(graph, "lib/family.dart", "pick"), none, "{report}"); + assert_eq!( + calls(graph, "test/matrix.dart", "factories"), + none, + "{report}" + ); + assert_eq!( + calls(graph, "test/matrix.dart", "generic"), + none, + "{report}" + ); + + // A parameter shadows a method of the class, which `this.` still + // reaches. + assert_eq!( + calls(graph, "lib/family.dart", "Holder::run"), + ["Holder::create:31"], + "{report}" + ); + + // A local: a mock, a function-typed variable, a loop variable, a + // destructured one. + assert_eq!( + calls(graph, "test/listener_test.dart", "main"), + ["Listener:6"], + "{report}" + ); + let other: Vec<&str> = graph + .nodes + .iter() + .filter(|n| n.file_path == "test/other_test.dart") + .map(|n| n.id.as_str()) + .collect(); + assert!( + !graph + .edges + .iter() + .any(|e| e.kind == "calls" && other.contains(&e.target.as_str())), + "{report}" + ); + + // A handler's `Emitter` parameter is not `Bloc.emit`; a bare call of + // the inherited method is. + assert_eq!( + calls(graph, "lib/bloc.dart", "CounterBloc"), + ["Bloc::on:13"], + "{report}" + ); + assert_eq!( + calls(graph, "lib/bloc.dart", "CounterBloc::onReset"), + none, + "{report}" + ); + assert_eq!( + calls(graph, "lib/bloc.dart", "CounterBloc::bump"), + ["Bloc::emit:20"], + "{report}" + ); + + // A sibling closure's parameter and a finished loop's variable cover + // nothing after them. + assert_eq!( + calls(graph, "lib/wire.dart", "wire"), + ["notify:5", "notify:9"], + "{report}" + ); + + // A local function is nearer than the parameter it shadows, inside + // its block only. + assert_eq!( + calls(graph, "lib/wire.dart", "outer::helper"), + ["outer::make:15"], + "{report}" + ); + let outer = calls(graph, "lib/wire.dart", "outer"); + assert!(outer.contains(&"outer::helper:18".to_string()), "{report}"); + assert!(!outer.contains(&"outer::make:18".to_string()), "{report}"); + + // A nullable typed local's type is not a binding: `Box()` still + // constructs `Box`. + assert_eq!( + calls(graph, "lib/family.dart", "check"), + ["Box:47", "find:45"], + "{report}" + ); + } +} + +mod dart_constructors { + use super::support::{Graph, NodeRow, Project}; + + fn find<'g>(graph: &'g Graph, file: &str, qualified: &str) -> Option<&'g NodeRow> { + graph + .nodes + .iter() + .find(|n| n.file_path == file && n.qualified_name == qualified) + } + + fn node<'g>(graph: &'g Graph, file: &str, qualified: &str) -> &'g NodeRow { + find(graph, file, qualified) + .unwrap_or_else(|| panic!("{file} {qualified}\n{}", graph.report())) + } + + /// The qualified names `from`'s `kind` edges reach, deduplicated and + /// sorted (upstream's `targets`). + fn targets(graph: &Graph, from: &NodeRow, kind: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.source == from.id && e.kind == kind) + .filter_map(|e| Some(graph.node(&e.target)?.qualified_name.clone())) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// ` ` of every edge but `contains` into + /// `to`, deduplicated and sorted (upstream's `sources`). + fn sources(graph: &Graph, to: &NodeRow) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.target == to.id && e.kind != "contains") + .filter_map(|e| { + Some(format!( + "{} {}", + e.kind, + graph.node(&e.source)?.qualified_name + )) + }) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// KEEP-RUST (W4-DART-03c): this grammar puts the annotations of a + /// member with a body inside the declaration its node is made of, so a + /// member's node starts at its annotations where upstream's starts at its + /// signature. Whether a member is a constructor, a getter or a setter is + /// read past them: riverpod's `@visibleForTesting factory + /// ProviderContainer.test(…)` is a constructor its 1269 calls reach, an + /// annotated getter is read, and an annotated setter is no call target. + #[test] + fn keep_rust_a_dart_members_head_is_read_past_its_annotations() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "lib/container.dart", + "class Item {\n void save() {}\n}\n\nclass ProviderContainer {\n ProviderContainer();\n\n /// For tests.\n @visibleForTesting\n // ignore: invalid_use\n factory ProviderContainer.test({int retry = 0}) {\n return ProviderContainer();\n }\n\n @override\n @protected\n Item get item => Item();\n}\n\nclass Slot {\n @override\n set value(int v) {}\n}\n\nclass Box {\n Slot wrap() => Slot();\n}\n", + ) + .file( + "lib/use.dart", + "import 'container.dart';\n\nvoid made() {\n ProviderContainer.test();\n}\n\nvoid read(ProviderContainer container) {\n container.item.save();\n}\n\nvoid write(Box box) {\n box.wrap().value(1);\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let use_calls = |name: &str| targets(graph, node(graph, "lib/use.dart", name), "calls"); + assert_eq!(use_calls("made"), ["ProviderContainer::test"], "{report}"); + assert_eq!( + use_calls("read"), + ["Item::save", "ProviderContainer::item"], + "{report}" + ); + assert_eq!(use_calls("write"), ["Box::wrap"], "{report}"); + } + + /// A constructor is torn off through its class, never by its name alone + /// (upstream #2380): a function value `internal` passed on, which no + /// local binds, is not `ChangeNotifierProvider.internal`. + #[test] + fn pr2380_a_dart_constructor_is_no_bare_function_value() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "lib/provider.dart", + "class ChangeNotifierProvider {\n const ChangeNotifierProvider.internal();\n\n void wire() => register(internal);\n}\n\nvoid register(Object Function() make) {}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let internal = node( + graph, + "lib/provider.dart", + "ChangeNotifierProvider::internal", + ); + assert_eq!(sources(graph, internal), Vec::::new(), "{report}"); + // The function value is recorded, and left unresolved. + assert!( + graph + .unresolved + .iter() + .any(|r| r.reference_name == "internal" && r.line == 4), + "{report}" + ); + assert_eq!( + targets( + graph, + node(graph, "lib/provider.dart", "ChangeNotifierProvider::wire"), + "calls" + ), + ["register"], + "{report}" + ); + } + + /// Upstream #2380 (`dart-const-constructors.test.ts`, its fixture): named + /// `const` constructors and redirecting factories are methods named by the + /// constructor, returning their class, with a parameters-only signature + /// (the unnamed ones stay unindexed, and a redirect's target names + /// nothing); a redirecting factory refers to the class it constructs; and + /// a call through a type's name reaches that type's own constructor or + /// static member, type arguments or not, or nothing: never another type's + /// member of that name, a supertype's, a same-file look-alike's, an + /// instance getter sharing a factory's name, or a project method sharing + /// an SDK call's name. A constructor is never reached by a bare name, the + /// last link of a chain or an annotation. KEEP-RUST: the extractor keeps + /// `BlocProvider.value(…)` by its member at the chain's start, and + /// the chain reader types `BlocProvider` as the type (W4-DART-03b) + /// where upstream records `BlocProvider.value`; the edges are the same. + #[test] + fn pr2380_a_dart_call_through_a_type_reaches_that_types_own_constructor() { + let project = Project::new() + .file("pubspec.yaml", "name: app\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\n") + .file("lib/bloc_provider.dart", "class BlocProvider {\n const BlocProvider({required T Function() create}) : _create = create, _value = null;\n\n const BlocProvider.value({required T value}) : this._(value: value);\n\n const BlocProvider._({T? value}) : _create = null, _value = value;\n\n final T Function()? _create;\n final T? _value;\n\n void handle(Object value) => accept(value);\n}\n\nvoid accept(Object o) {}\n") + .file("lib/repository_provider.dart", "class RepositoryProvider {\n RepositoryProvider({required T Function() create});\n\n RepositoryProvider.value({required T value});\n\n static T of(Object context) => throw 0;\n}\n") + .file("lib/new_car_state.dart", "class NewCarState {\n const NewCarState._({this.brands = const []});\n\n const NewCarState.initial() : this._();\n\n const factory NewCarState.loading() = _Loading;\n\n const factory NewCarState() = _Loading.named;\n\n const factory NewCarState.fromJson(Map json) = _Loading.fromJson;\n\n final List brands;\n}\n\nclass _Loading extends NewCarState {\n const _Loading() : super._();\n\n const _Loading.named() : super._();\n\n const _Loading.fromJson(Map json) : super._();\n}\n") + .file("lib/profile_wizard_state.dart", "class ProfileWizardState {\n ProfileWizardState.initial() : step = 0;\n\n final int step;\n\n Object fromJson(Object json) => json;\n}\n") + .file("lib/family.dart", "class EmptyFamily extends Family {\n EmptyFamily._() : super._();\n}\n\nclass Family {\n const Family._();\n\n static Family make() => Family._();\n}\n") + .file("lib/sub.dart", "abstract class Base {\n const Base();\n\n factory Base.named() => const _BaseImpl();\n}\n\nclass _BaseImpl extends Base {\n const _BaseImpl();\n}\n\nclass Sub extends Base {\n const Sub();\n\n const factory Sub.named() = _SubImpl;\n}\n\nclass _SubImpl extends Sub {\n const _SubImpl();\n}\n") + .file("lib/analysis_options.dart", "class AnalysisOptions {\n const AnalysisOptions(this.path);\n\n factory AnalysisOptions.parse(String path) => AnalysisOptions(path);\n\n final String path;\n}\n") + .file("lib/linter.dart", "extension on Uri {\n String get canonicalizedPath => path;\n}\n") + .file("lib/async_value.dart", "sealed class AsyncValue {\n const AsyncValue._();\n\n Object? get error => null;\n\n const factory AsyncValue.error(Object error) = AsyncError;\n}\n\nclass AsyncError extends AsyncValue {\n const AsyncError(this.error) : super._();\n\n @override\n final Object error;\n}\n\nObject fail(Object e) => AsyncValue.error(e);\n") + .file("lib/change_notifier_provider.dart", "class ChangeNotifierProvider {\n const ChangeNotifierProvider.internal();\n}\n") + .file("lib/scope.dart", "import 'package:meta/meta.dart';\n\n@internal\nObject guarded() => 0;\n") + .file("lib/app.dart", "import 'bloc_provider.dart';\nimport 'repository_provider.dart';\nimport 'new_car_state.dart';\nimport 'sub.dart';\n\nObject build(Object counter) => BlocProvider.value(value: counter);\nObject typed(Object counter) => BlocProvider.value(value: counter);\nObject typedRepository(Object repository) => RepositoryProvider.value(value: repository);\nNewCarState start() => NewCarState.initial();\nNewCarState load() => NewCarState.loading();\nSub sub() => Sub.named();\nObject find(Object context) => BlocProvider.of(context);\nUri link() => Uri.parse('https://example.com');\nObject tail(dynamic box) => box.inner.value(1);\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + let none = Vec::::new(); + + let value = node(graph, "lib/bloc_provider.dart", "BlocProvider::value"); + assert_eq!(value.kind, "method", "{report}"); + assert_eq!( + value.return_type.as_deref(), + Some("BlocProvider"), + "{report}" + ); + assert_eq!( + value.signature.as_deref(), + Some("({required T value})"), + "{report}" + ); + node(graph, "lib/bloc_provider.dart", "BlocProvider::_"); + assert!( + find( + graph, + "lib/bloc_provider.dart", + "BlocProvider::BlocProvider" + ) + .is_none(), + "{report}" + ); + node(graph, "lib/new_car_state.dart", "NewCarState::initial"); + let loading = node(graph, "lib/new_car_state.dart", "NewCarState::loading"); + assert_eq!(loading.signature.as_deref(), Some("()"), "{report}"); + assert!( + find(graph, "lib/new_car_state.dart", "NewCarState::NewCarState").is_none(), + "{report}" + ); + assert!( + find(graph, "lib/new_car_state.dart", "NewCarState::named").is_none(), + "{report}" + ); + node(graph, "lib/new_car_state.dart", "_Loading::named"); + node(graph, "lib/sub.dart", "Sub::named"); + // A redirecting factory refers to the class it constructs, and not to + // that class's constructor as if it were a type. + assert_eq!( + targets(graph, loading, "references"), + ["_Loading"], + "{report}" + ); + assert_eq!( + targets( + graph, + node(graph, "lib/new_car_state.dart", "NewCarState::fromJson"), + "references" + ), + ["_Loading"], + "{report}" + ); + assert_eq!( + sources( + graph, + node( + graph, + "lib/profile_wizard_state.dart", + "ProfileWizardState::fromJson" + ) + ), + none, + "{report}" + ); + + // Each call reaches the constructor of the type it names, type + // arguments or not... + let app = |name: &str| node(graph, "lib/app.dart", name); + assert_eq!( + targets(graph, app("build"), "calls"), + ["BlocProvider::value"], + "{report}" + ); + assert_eq!( + targets(graph, app("typed"), "calls"), + ["BlocProvider::value"], + "{report}" + ); + assert_eq!( + targets(graph, app("typedRepository"), "calls"), + ["RepositoryProvider::value"], + "{report}" + ); + assert_eq!( + targets(graph, app("start"), "calls"), + ["NewCarState::initial"], + "{report}" + ); + assert_eq!( + targets(graph, app("load"), "calls"), + ["NewCarState::loading"], + "{report}" + ); + assert_eq!( + targets(graph, app("sub"), "calls"), + ["Sub::named"], + "{report}" + ); + assert_eq!( + targets( + graph, + node(graph, "lib/family.dart", "Family::make"), + "calls" + ), + ["Family::_"], + "{report}" + ); + // A constructor, not the instance getter that shares its name. + let factory = graph + .nodes + .iter() + .find(|n| { + n.file_path == "lib/async_value.dart" + && n.qualified_name == "AsyncValue::error" + && n.signature.as_deref() == Some("(Object error)") + }) + .unwrap_or_else(|| panic!("the error factory\n{report}")); + let fail = node(graph, "lib/async_value.dart", "fail"); + let called: Vec<&str> = graph + .edges + .iter() + .filter(|e| e.source == fail.id && e.kind == "calls") + .map(|e| e.target.as_str()) + .collect(); + assert_eq!(called, [factory.id.as_str()], "{report}"); + // ...and never another type's member of that name. + assert_eq!( + sources( + graph, + node( + graph, + "lib/repository_provider.dart", + "RepositoryProvider::value" + ) + ), + ["calls typedRepository"], + "{report}" + ); + assert_eq!( + sources( + graph, + node( + graph, + "lib/profile_wizard_state.dart", + "ProfileWizardState::initial" + ) + ), + none, + "{report}" + ); + assert_eq!( + sources(graph, node(graph, "lib/family.dart", "EmptyFamily::_")), + none, + "{report}" + ); + assert_eq!( + sources(graph, node(graph, "lib/sub.dart", "Base::named")), + none, + "{report}" + ); + // A member the named type does not declare is outside the project. + assert_eq!(targets(graph, app("find"), "calls"), none, "{report}"); + assert_eq!( + sources( + graph, + node( + graph, + "lib/repository_provider.dart", + "RepositoryProvider::of" + ) + ), + none, + "{report}" + ); + assert_eq!(targets(graph, app("link"), "calls"), none, "{report}"); + assert_eq!( + sources( + graph, + node(graph, "lib/analysis_options.dart", "AnalysisOptions::parse") + ), + none, + "{report}" + ); + + // A constructor is only ever reached through its class: a parameter + // that shares its name, the last link of a chain, or an annotation is + // not it. + assert_eq!(targets(graph, app("tail"), "calls"), none, "{report}"); + assert_eq!( + sources(graph, value), + ["calls build", "calls typed"], + "{report}" + ); + assert_eq!( + sources( + graph, + node( + graph, + "lib/change_notifier_provider.dart", + "ChangeNotifierProvider::internal" + ) + ), + none, + "{report}" + ); + } + + /// KEEP-RUST (W4d end review): a named constructor is reached through + /// its class, never through a value of it. Upstream's member lookup reads + /// one as an instance member, so `x.value()` on a parameter typed `Foo`, + /// a local initialized from a call, or the last link of a chain typed + /// `Foo` called `Foo.value`'s constructor over the `value()` an extension + /// on `Foo` brings; with no such member it links nothing, also through the + /// matcher's fallbacks that read a receiver `bar` as the class `Bar` or + /// score a receiver's words (`barValue`). + #[test] + fn keep_rust_a_dart_value_never_calls_a_named_constructor_of_its_type() { + let project = Project::new() + .file("pubspec.yaml", "name: app\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\n") + .file("lib/foo.dart", "class Foo {\n const Foo.value(this.v);\n\n final int v;\n}\n\nFoo build() => const Foo.value(1);\n\nextension FooX on Foo {\n int value() => v;\n}\n") + .file("lib/bar.dart", "class Bar {\n const Bar.empty();\n}\n\nBar make() => const Bar.empty();\n") + .file("lib/use.dart", "import 'foo.dart';\nimport 'bar.dart';\n\nint viaParameter(Foo x) => x.value();\n\nint viaLocal() {\n final Foo y = build();\n return y.value();\n}\n\nint viaChain() => build().value();\n\nFoo viaType() => Foo.value(2);\n\nvoid emptyParameter(Bar b) => b.empty();\n\nvoid emptyChain() => make().empty();\n\nvoid emptyNamed(Bar bar) => bar.empty();\n\nvoid emptyWord(Bar barValue) => barValue.empty();\n\nvoid emptyUntyped(dynamic bar) => bar.empty();\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + let none = Vec::::new(); + let usage = |name: &str| node(graph, "lib/use.dart", name); + for caller in ["viaParameter", "viaLocal", "viaChain"] { + assert!( + targets(graph, usage(caller), "calls").contains(&"FooX::value".to_string()), + "{caller}\n{report}" + ); + } + // Through its class, the constructor is still what the call means. + assert_eq!( + targets(graph, usage("viaType"), "calls"), + ["Foo::value"], + "{report}" + ); + assert_eq!( + sources(graph, node(graph, "lib/foo.dart", "Foo::value")), + ["calls build", "calls viaType"], + "{report}" + ); + // No member of that name: nothing to call, whatever the receiver is + // named (`bar` reads as the class `Bar`, `barValue` shares its word) + // or however little is known of it. + for caller in ["emptyParameter", "emptyNamed", "emptyWord", "emptyUntyped"] { + assert_eq!( + targets(graph, usage(caller), "calls"), + none, + "{caller}\n{report}" + ); + } + assert_eq!( + sources(graph, node(graph, "lib/bar.dart", "Bar::empty")), + ["calls make"], + "{report}" + ); + assert!( + !targets(graph, usage("emptyChain"), "calls").contains(&"Bar::empty".to_string()), + "{report}" + ); + } +} + +mod dart_prefixes { + use super::support::{Graph, Project}; + + /// ` -> :` for every call + /// or construction out of `file`'s nodes, sorted (upstream's `callsFrom`, + /// without the reference name: KEEP-RUST, a chain's link is recorded by + /// its member where the chain starts). + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls" || e.kind == "instantiates") + .filter_map(|e| { + let source = graph.node(&e.source)?; + (source.file_path == file).then_some(())?; + let target = graph.node(&e.target)?; + Some(format!( + "{} {} -> {}:{}", + e.kind, e.line?, target.file_path, target.qualified_name + )) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2389 (`dart-import-prefix-calls.test.ts`, its fixture): a + /// Dart call through an import prefix calls a top-level declaration the + /// libraries imported with that prefix export, `export` chains, `show` + /// and `hide` followed and a part taking its library's prefixes, or + /// nothing in the project (a package outside the repository, a name not + /// shown or hidden, a deferred library's `loadLibrary`); a constructor + /// call constructs the prefixed library's class, and a chain headed by + /// the prefix (`p.Box.create()`, `p.Box.named()`) is on the type the + /// prefix brings in. Never another library's namesake or a method by the + /// receiver's name; a parameter named like the prefix is the parameter. + /// KEEP-RUST: `const kit.Box.named()` is read as the named constructor it + /// calls (W4-DART-03a), so it links `Box.named` of the type kit brings + /// in, where upstream records `kit.named` and links nothing. + #[test] + fn pr2389_a_dart_call_through_an_import_prefix_reaches_what_that_import_exports() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file("lib/legacy.dart", "class Http {\n Future get(Uri url) async => '';\n}\n\nclass Client {\n Client();\n static Client fromEnv() => Client();\n}\n\nclass Formatter {\n String describe(Object value) => '';\n}\n\nclass Loader {\n int load() => 0;\n}\n\nclass Report {\n Report.empty();\n static Report blank() => Report.empty();\n}\n\nclass Box {\n const Box.named();\n static Box create() => const Box.named();\n}\n\nString describe(Object value) => 'legacy';\nint shown() => 0;\n") + .file("lib/src/format.dart", "String describe(Object value) => '$value';\n\nclass Report {\n Report();\n Report.empty();\n static Report blank() => Report();\n}\n") + .file("lib/src/tools.dart", "int secret() => 1;\nint open() => 2;\n") + .file("lib/lazy.dart", "int load() => 1;\n") + .file("packages/kit/pubspec.yaml", "name: kit\n") + .file("packages/kit/lib/kit.dart", "export 'src/impl.dart' show shown, Widget, Box, named;\n") + .file("packages/kit/lib/src/impl.dart", "int shown() => 1;\nint notShown() => 2;\nint named() => 3;\n\nclass Widget {\n Widget();\n}\n\nclass Box {\n const Box.named();\n static Box create() => const Box.named();\n}\n") + .file("lib/net.dart", "import 'package:http/http.dart' as http;\n\nFuture fetch() async {\n await http.get(Uri.parse('https://example.com'));\n http.Client();\n http.Client.fromEnv();\n}\n") + .file("lib/app.dart", "import 'package:kit/kit.dart' as kit;\nimport 'src/format.dart' as fmt;\nimport 'src/tools.dart' as tools hide secret;\nimport 'lazy.dart' deferred as lazy;\n\npart 'app_part.dart';\n\nString run(Object value) {\n fmt.Report();\n fmt.Report.empty();\n fmt.Report.blank();\n kit.shown();\n kit.notShown();\n kit.Widget();\n tools.open();\n tools.secret();\n final box = kit.Box.named();\n kit.Box.create();\n const kit.Box.named();\n return fmt.describe(value);\n}\n\nFuture boot() async {\n await lazy.loadLibrary();\n return lazy.load();\n}\n") + .file("lib/app_part.dart", "part of 'app.dart';\n\nString again(Object value) => fmt.describe(value);\n") + .file("lib/shadow.dart", "import 'package:http/http.dart' as http;\nimport 'legacy.dart';\n\nFuture viaParameter(Http http) => http.get(Uri());\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + calls_from(graph, "lib/net.dart"), + Vec::::new(), + "{report}" + ); + let app = calls_from(graph, "lib/app.dart"); + for edge in [ + "calls 20 -> lib/src/format.dart:describe", + "calls 25 -> lib/lazy.dart:load", + "calls 12 -> packages/kit/lib/src/impl.dart:shown", + "instantiates 14 -> packages/kit/lib/src/impl.dart:Widget", + "calls 15 -> lib/src/tools.dart:open", + "instantiates 9 -> lib/src/format.dart:Report", + "calls 10 -> lib/src/format.dart:Report::empty", + "calls 11 -> lib/src/format.dart:Report::blank", + "calls 17 -> packages/kit/lib/src/impl.dart:Box::named", + "calls 18 -> packages/kit/lib/src/impl.dart:Box::create", + "calls 19 -> packages/kit/lib/src/impl.dart:Box::named", + ] { + assert!(app.contains(&edge.to_string()), "{edge}\n{report}"); + } + // Not shown by kit.dart, hidden from `tools`, the deferred library's + // own `loadLibrary`: lines 13, 16 and 24. + assert!( + !app.iter().any(|edge| [" 13 ", " 16 ", " 24 "] + .iter() + .any(|line| edge.contains(line))), + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/app_part.dart"), + ["calls 3 -> lib/src/format.dart:describe"], + "{report}" + ); + assert!( + !app.iter().any(|edge| edge.contains("lib/legacy.dart")), + "{report}" + ); + assert_eq!( + calls_from(graph, "lib/shadow.dart"), + ["calls 4 -> lib/legacy.dart:Http::get"], + "{report}" + ); + } + + /// A chain headed by an import prefix (upstream #2389's + /// `typeDartPrefixedHead`) is typed from what the prefix brings in: a + /// function's declared return type, a constant's type; a member or a + /// parameter named like the prefix hides it, there, as it hides a plain + /// prefixed call. A null-aware `fmt?.describe(…)` is written on no + /// prefix, whatever `fmt` names. + #[test] + fn pr2389_a_prefixed_chain_is_typed_from_what_its_prefix_brings_in() { + let project = Project::new() + .file("pubspec.yaml", "name: app\n") + .file( + "lib/src/format.dart", + "class Item {\n void save() {}\n}\n\nItem make() => Item();\n\nconst config = Item();\n\nString describe(Object value) => '';\n", + ) + .file( + "lib/legacy.dart", + "class Other {\n void save() {}\n}\n\nOther make() => Other();\n\nclass Fmt {\n String describe(Object value) => '';\n\n Other make() => Other();\n}\n", + ) + .file( + "lib/app.dart", + "import 'src/format.dart' as fmt;\nimport 'legacy.dart';\n\nvoid viaFunction() {\n fmt.make().save();\n}\n\nvoid viaConstant() {\n fmt.config.save();\n}\n\nvoid viaParameter(Fmt fmt) {\n fmt.describe(1);\n}\n\nclass Holder {\n final Fmt fmt = Fmt();\n\n void viaMember() {\n fmt.describe(2);\n }\n}\n\nvoid viaParameterChain(Fmt fmt) {\n fmt.make().save();\n}\n\nvoid viaNullableParameter(Fmt? fmt) {\n fmt?.describe(3);\n}\n", + ) + .index(); + let graph = project.graph(); + let report = graph.report(); + let calls = calls_from(graph, "lib/app.dart"); + for edge in [ + "calls 5 -> lib/src/format.dart:make", + "calls 5 -> lib/src/format.dart:Item::save", + "calls 9 -> lib/src/format.dart:Item::save", + "calls 13 -> lib/legacy.dart:Fmt::describe", + "calls 20 -> lib/legacy.dart:Fmt::describe", + "calls 25 -> lib/legacy.dart:Fmt::make", + "calls 25 -> lib/legacy.dart:Other::save", + ] { + assert!(calls.contains(&edge.to_string()), "{edge}\n{report}"); + } + assert!( + !calls + .iter() + .any(|edge| edge.contains("Other") && !edge.starts_with("calls 25 ")), + "{report}" + ); + assert!( + !calls + .iter() + .any(|edge| edge == "calls 25 -> lib/src/format.dart:Item::save"), + "{report}" + ); + assert!( + !calls + .iter() + .any(|edge| edge.contains("format.dart:describe")), + "{report}" + ); + } + + /// ` -> :` + /// for each `decorates` edge, sorted (upstream's `decorations`). + fn decorations(graph: &Graph) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "decorates") + .filter_map(|e| { + let from = graph.node(&e.source)?; + let to = graph.node(&e.target)?; + Some(format!( + "{} -> {} {}:{}", + from.qualified_name, to.kind, to.file_path, to.qualified_name + )) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2390 (`dart-annotation-targets.test.ts`, its fixture): a + /// Dart annotation is a constant expression, so it links what it is + /// written as: a constant or (called) a class its library sees, through + /// its import prefix; a type's static constant, enum value or (called) + /// named constructor; on a member, a static constant of the type around + /// it. Never a method, getter or function: `@riverpod` is + /// riverpod_annotation's constant, not the analyzer's extension getter, + /// and `@meta.protected` / `@meta.immutable` are package:meta's. KEEP-RUST: + /// this grammar writes a dotted annotation's name as a `qualified` node, + /// recorded by its last name as upstream records it. + #[test] + fn pr2390_a_dart_annotation_links_what_it_is_written_as() { + let project = Project::new() + .file("packages/riverpod_annotation/pubspec.yaml", "name: riverpod_annotation\n") + .file("packages/riverpod_annotation/lib/riverpod_annotation.dart", "export 'src/riverpod_annotation.dart';\n") + .file("packages/riverpod_annotation/lib/src/riverpod_annotation.dart", "final class Riverpod {\n const Riverpod({this.keepAlive = false});\n\n const Riverpod.forever() : keepAlive = true;\n\n static const scoped = Riverpod();\n\n final bool keepAlive;\n}\n\nconst riverpod = Riverpod();\n\nenum Retry { never, always }\n") + .file("packages/riverpod_analyzer_utils/pubspec.yaml", "name: riverpod_analyzer_utils\n") + .file("packages/riverpod_analyzer_utils/lib/src/nodes.dart", "part 'nodes/annotation.dart';\n\nconst _ast = Object();\n\nclass AnnotatedNode {}\n\nbool protected(Object node) => true;\n") + .file("packages/riverpod_analyzer_utils/lib/src/nodes/annotation.dart", "part of '../nodes.dart';\n\n@_ast\nextension RiverpodAnnotatedAnnotatedNodeOfX on AnnotatedNode {\n Object? get riverpod => null;\n}\n") + .file("examples/counter/pubspec.yaml", "name: counter\n") + .file("examples/counter/lib/counter.dart", "import 'package:meta/meta.dart' as meta;\nimport 'package:riverpod_annotation/riverpod_annotation.dart';\n\n@riverpod\nint counter(Object ref) => 0;\n\n@riverpod\n// expect_lint: functional_ref\nint linted(Object ref) => 0;\n\n@Riverpod(keepAlive: true)\nclass Todos {\n static const marker = Riverpod();\n\n @marker\n void add() {}\n\n @meta.protected\n void reset() {}\n}\n\n@Riverpod.forever()\nint kept(Object ref) => 0;\n\n@Riverpod.scoped\nint scopedCounter(Object ref) => 0;\n\n@Retry.always\nint retried(Object ref) => 0;\n\n@meta.immutable\nclass Frozen {}\n") + .file("examples/counter/lib/immutable.dart", "const immutable = Object();\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + let annotation = "packages/riverpod_annotation/lib/src/riverpod_annotation.dart"; + let app = "examples/counter/lib/counter.dart"; + let expected: Vec = [ + "RiverpodAnnotatedAnnotatedNodeOfX -> constant packages/riverpod_analyzer_utils/lib/src/nodes.dart:_ast".to_string(), + format!("Todos -> class {annotation}:Riverpod"), + format!("Todos::add -> constant {app}:Todos::marker"), + format!("counter -> constant {annotation}:riverpod"), + format!("kept -> method {annotation}:Riverpod::forever"), + format!("linted -> constant {annotation}:riverpod"), + format!("retried -> enum_member {annotation}:Retry::always"), + format!("scopedCounter -> constant {annotation}:Riverpod::scoped"), + ] + .into_iter() + .collect(); + let mut expected = expected; + expected.sort(); + assert_eq!(decorations(graph), expected, "{report}"); + // The dotted ones are recorded, and left to package:meta. + for name in ["protected", "immutable"] { + assert!( + graph + .unresolved + .iter() + .any(|r| r.reference_kind == "decorates" && r.reference_name == name), + "{name}\n{report}" + ); + } + } +} + +mod solidity_scope { + use super::support::{Graph, Project}; + + /// The qualified names of every call edge out of `file`'s nodes, sorted + /// (upstream's `callsFrom`). + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter_map(|e| { + let source = graph.node(&e.source)?; + (source.file_path == file).then_some(())?; + Some(graph.node(&e.target)?.qualified_name.clone()) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2178 (a) (`solidity-erlang-call-scope.test.ts`, its + /// Solidity fixture): a Solidity call written without a receiver reaches + /// a function of the contract around it or of one it inherits through + /// its `is` clause, never a sibling's override: OpenZeppelin's + /// `_msgSender()` in a Context-derived Governor went to ERC2771Context's. + /// The files sit in OpenZeppelin's directories: side by side, as + /// upstream's fixture has them, the ranking already picks Context's. + #[test] + fn pr2178_a_bare_solidity_call_reaches_the_inherited_function_not_a_sibling_override() { + let project = Project::new() + .file("contracts/utils/Context.sol", "pragma solidity ^0.8.20;\n\nabstract contract Context {\n function _msgSender() internal view virtual returns (address) {\n return msg.sender;\n }\n}\n") + .file("contracts/metatx/ERC2771Context.sol", "pragma solidity ^0.8.20;\n\nimport {Context} from \"../utils/Context.sol\";\n\nabstract contract ERC2771Context is Context {\n function _msgSender() internal view virtual override returns (address) {\n return msg.sender;\n }\n}\n") + .file("contracts/governance/Governor.sol", "pragma solidity ^0.8.20;\n\nimport {Context} from \"../utils/Context.sol\";\n\nabstract contract Governor is Context {\n function castVote() public returns (address) {\n return _msgSender();\n }\n}\n") + .index(); + let graph = project.graph(); + assert_eq!( + calls_from(graph, "contracts/governance/Governor.sol"), + ["Context::_msgSender"], + "{}", + graph.report() + ); + } + + /// The `is` clause is read as written: constructor arguments dropped, a + /// base named through an import alias by its last name, a head spread + /// over lines with comments in it, the chain followed through the bases' + /// own heads; a contract it does not inherit is passed over even in the + /// same file. A call with a receiver, a library function called through + /// its library (`MathLib.add(…)`) and a free function are not judged; + /// outside any contract a bare call reaches no member. + /// KEEP-RUST: a `//` comment in the head drops only the rest of its own + /// line (upstream joins the head's lines before dropping it, so the + /// bases written below it are lost). + #[test] + fn keep_rust_a_solidity_is_clause_is_read_as_written() { + let project = Project::new() + .file("contracts/Base.sol", "pragma solidity ^0.8.20;\n\nabstract contract Ownable {\n function _checkOwner() internal view virtual {}\n}\n\nabstract contract Pausable {\n function _requireNotPaused() internal view virtual {}\n}\n\nabstract contract Nonces {\n function _useNonce(address owner) internal virtual returns (uint256) {\n return 0;\n }\n}\n\nabstract contract Middle is Nonces {}\n\nlibrary MathLib {\n function add(uint256 a, uint256 b) internal pure returns (uint256) {\n return a + b;\n }\n}\n\nfunction freeHelper() pure returns (uint256) {\n return 3;\n}\n") + .file("contracts/Vault.sol", "pragma solidity ^0.8.20;\n\nimport \"./Base.sol\" as B;\n\ncontract Sibling {\n function _checkOwner() internal view {}\n function _requireNotPaused() internal view {}\n function _useNonce(address owner) internal returns (uint256) {\n return 1;\n }\n function helper() public pure returns (uint256) {\n return 2;\n }\n}\n\ncontract Vault is\n Ownable(msg.sender), // the owner\n B.Pausable,\n /* nonces */ Middle\n{\n Sibling sibling;\n\n function run() public returns (uint256) {\n _checkOwner();\n _requireNotPaused();\n _useNonce(msg.sender);\n sibling.helper();\n MathLib.add(1, 2);\n return freeHelper();\n }\n}\n\nfunction loose() pure returns (uint256) {\n return helper();\n}\n") + .index(); + let graph = project.graph(); + assert_eq!( + calls_from(graph, "contracts/Vault.sol"), + [ + "MathLib::add", + "Nonces::_useNonce", + "Ownable::_checkOwner", + "Pausable::_requireNotPaused", + "Sibling::helper", + "freeHelper", + ], + "{}", + graph.report() + ); + } +} + +mod erlang_imports { + use super::support::{Graph, Project}; + + /// The qualified names of every call edge out of `file`'s nodes, sorted + /// (upstream's `callsFrom`). + fn calls_from(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|e| e.kind == "calls") + .filter_map(|e| { + let source = graph.node(&e.source)?; + (source.file_path == file).then_some(())?; + Some(graph.node(&e.target)?.qualified_name.clone()) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2178 (b) (`solidity-erlang-call-scope.test.ts`, its Erlang + /// fixture): another module's function is called bare only through + /// `-import(Mod, [f/N])`: cowboy's `-import(req_SUITE, [do_get/3])` went + /// to compress_SUITE's `do_get/3`. + #[test] + fn pr2178_a_bare_erlang_call_reaches_the_module_it_is_imported_from() { + let project = Project::new() + .file("test/req_SUITE.erl", "-module(req_SUITE).\n-export([do_get/3]).\n\ndo_get(Path, Headers, Config) -> {Path, Headers, Config}.\n") + .file("test/compress_SUITE.erl", "-module(compress_SUITE).\n-export([do_get/3]).\n\ndo_get(Path, Headers, Config) -> {Path, Headers, Config}.\n") + .file("test/static_handler_SUITE.erl", "-module(static_handler_SUITE).\n-export([etag/1]).\n-import(req_SUITE, [do_get/3]).\n\netag(Config) -> do_get(\"/etag\", [], Config).\n") + .index(); + let graph = project.graph(); + assert_eq!( + calls_from(graph, "test/static_handler_SUITE.erl"), + ["req_SUITE::do_get/3"], + "{}", + graph.report() + ); + } + + /// An import list is read as Erlang writes it: a quoted atom is one name, + /// whatever comma or slash it holds, and a `%` comment between entries is + /// no entry (plan gate round 1). + #[test] + fn keep_rust_an_erlang_import_list_with_quoted_atoms_and_comments() { + let project = Project::new() + .file("test/req_SUITE.erl", "-module(req_SUITE).\n-export(['foo,bar'/1, 'a/b'/2, do_get/3]).\n\n'foo,bar'(X) -> X.\n'a/b'(X, Y) -> {X, Y}.\ndo_get(Path, Headers, Config) -> {Path, Headers, Config}.\n") + .file("test/compress_SUITE.erl", "-module(compress_SUITE).\n-export(['foo,bar'/1, 'a/b'/2, do_get/3]).\n\n'foo,bar'(X) -> X.\n'a/b'(X, Y) -> {X, Y}.\ndo_get(Path, Headers, Config) -> {Path, Headers, Config}.\n") + .file("test/quoted_SUITE.erl", "-module(quoted_SUITE).\n-export([run/1]).\n-import(req_SUITE, ['foo,bar'/1, % the odd one, not/9\n 'a/b'/2,\n do_get/3]).\n\nrun(C) -> 'foo,bar'(C), 'a/b'(C, C), do_get(\"/\", [], C).\n") + .index(); + let graph = project.graph(); + assert_eq!( + calls_from(graph, "test/quoted_SUITE.erl"), + [ + "req_SUITE::a/b/2", + "req_SUITE::do_get/3", + "req_SUITE::foo,bar/1" + ], + "{}", + graph.report() + ); + } + + /// With no `-import`, another module's function is never guessed; a + /// function a `.hrl` declares is (a module includes it); an import list + /// spread over lines, with quoted atoms, names its functions; a module + /// the project lacks links nothing; a call inside a `.hrl` reaches what + /// it reached before. + #[test] + fn keep_rust_an_erlang_import_list_and_headers_are_read_as_written() { + let project = Project::new() + .file("test/req_SUITE.erl", "-module(req_SUITE).\n-export([do_get/3, do_put/2]).\n\ndo_get(Path, Headers, Config) -> {Path, Headers, Config}.\ndo_put(Path, Config) -> {Path, Config}.\n") + .file("test/compress_SUITE.erl", "-module(compress_SUITE).\n-export([do_get/3, do_put/2, map/2]).\n\ndo_get(Path, Headers, Config) -> {Path, Headers, Config}.\ndo_put(Path, Config) -> {Path, Config}.\nmap(F, L) -> {F, L}.\n") + .file("src/fmt_util.erl", "-module(fmt_util).\n-export([describe/1]).\n\ndescribe(Value) -> Value.\n") + .file("include/helpers.hrl", "helper(Config) -> describe(Config).\n") + .file("test/plain_SUITE.erl", "-module(plain_SUITE).\n-export([etag/1]).\n-include(\"../include/helpers.hrl\").\n\netag(Config) -> do_get(\"/etag\", [], Config), helper(Config).\n") + .file("test/multi_SUITE.erl", "-module(multi_SUITE).\n-export([put/1, mapped/1]).\n-import(req_SUITE,\n [do_get/3,\n 'do_put'/2]).\n-import(lists, [map/2]).\n\nput(Config) -> do_put(\"/put\", Config).\nmapped(L) -> map(fun(X) -> X end, L).\n") + .index(); + let graph = project.graph(); + let report = graph.report(); + assert_eq!( + calls_from(graph, "test/plain_SUITE.erl"), + ["helper/1"], + "{report}" + ); + assert_eq!( + calls_from(graph, "test/multi_SUITE.erl"), + ["req_SUITE::do_put/2"], + "{report}" + ); + assert_eq!( + calls_from(graph, "include/helpers.hrl"), + ["fmt_util::describe/1"], + "{report}" + ); + } +} + +mod shopify_themes { + use super::support::{Graph, Project}; + + /// A JSON template naming one section `type` (upstream's `jsonTemplate`). + fn json_template(section: &str) -> String { + format!("{{\"sections\":{{\"main\":{{\"type\":\"{section}\"}}}},\"order\":[\"main\"]}}") + } + + /// The indexed JSON files, sorted (upstream's `indexedJson`). + fn indexed_json(graph: &Graph) -> Vec { + let mut out: Vec = graph + .files + .iter() + .map(|file| file.path.clone()) + .filter(|path| path.ends_with(".json")) + .collect(); + out.sort(); + out + } + + /// The other files `file`'s nodes link to, sorted and deduplicated + /// (upstream's `getFileDependencies`). + fn links_of(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind != "contains") + .filter_map(|edge| { + let source = graph.node(&edge.source)?; + let target = graph.node(&edge.target)?; + (source.file_path == file && target.file_path != file) + .then(|| target.file_path.clone()) + }) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// The other files whose nodes link to `file`, sorted and deduplicated + /// (upstream's `getFileDependents`). + fn dependents_of(graph: &Graph, file: &str) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind != "contains") + .filter_map(|edge| { + let source = graph.node(&edge.source)?; + let target = graph.node(&edge.target)?; + (target.file_path == file && source.file_path != file) + .then(|| source.file_path.clone()) + }) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// Upstream #2393: JSON in `templates/` or `sections/` folders that no + /// Shopify theme holds (jasontaylordev/CleanArchitecture's .NET project + /// template, a CMS's content) is not Liquid, even when a Liquid file is + /// named like one of its "types". + #[test] + fn pr2393_json_under_templates_or_sections_outside_a_theme_is_left_out() { + let project = Project::new() + .file( + "templates/ca-use-case/.template.config/dotnetcli.host.json", + "{\"symbolInfo\":{\"UseCase\":{\"longName\":\"name\"}}}", + ) + .file( + "templates/ca-use-case/.template.config/template.json", + "{\"identity\":\"CleanArchitecture.UseCase\",\"sections\":{\"main\":{\"type\":\"hero\"}}}", + ) + .file("sections/home.json", &json_template("hero")) + .file("sections/hero.liquid", "

      {{ page.title }}

      \n") + .index(); + let graph = project.graph(); + assert_eq!( + indexed_json(graph), + Vec::::new(), + "{}", + graph.report() + ); + assert!( + graph + .files + .iter() + .any(|file| file.path == "sections/hero.liquid"), + "the Liquid file itself is still indexed:\n{}", + graph.report() + ); + assert_eq!( + dependents_of(graph, "sections/hero.liquid"), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2393: a theme in a subdirectory with no `layout/theme.liquid` + /// committed is marked by its `config/settings_schema.json`; the same + /// JSON under `tools/templates/` is in no theme. + #[test] + fn pr2393_a_settings_schema_marks_a_theme_in_a_subdirectory() { + let project = Project::new() + .file( + "theme/config/settings_schema.json", + "[{\"name\":\"theme_info\"}]", + ) + .file( + "theme/sections/main-product.liquid", + "
      {{ product.title }}
      \n", + ) + .file( + "theme/templates/product.json", + &json_template("main-product"), + ) + .file( + "tools/templates/product.json", + &json_template("main-product"), + ) + .index(); + let graph = project.graph(); + assert_eq!( + indexed_json(graph), + ["theme/templates/product.json"], + "{}", + graph.report() + ); + assert_eq!( + dependents_of(graph, "theme/sections/main-product.liquid"), + ["theme/templates/product.json"], + "{}", + graph.report() + ); + } + + /// Upstream #2393: JSON stored as Liquid leaves once its folder is no + /// longer a theme, and comes back when it is one again, without a + /// re-index (a full sync here; the scoped sync is the sync matrix's). + #[test] + fn pr2393_json_templates_leave_and_return_with_the_theme_marker() { + let mut project = Project::new() + .file("layout/theme.liquid", "{{ content_for_layout }}\n") + .file( + "sections/main-product.liquid", + "
      {{ product.title }}
      \n", + ) + .file("templates/page.json", &json_template("main-product")) + .file("templates/product.json", &json_template("main-product")) + .index(); + assert_eq!( + indexed_json(project.graph()), + ["templates/page.json", "templates/product.json"], + "{}", + project.graph().report() + ); + + project.remove("layout/theme.liquid"); + let graph = project.sync(); + assert_eq!( + indexed_json(graph), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + dependents_of(graph, "sections/main-product.liquid"), + Vec::::new(), + "{}", + graph.report() + ); + + project.write("config/settings_schema.json", "[]"); + let graph = project.sync(); + assert_eq!( + indexed_json(graph), + ["templates/page.json", "templates/product.json"], + "{}", + graph.report() + ); + assert_eq!( + dependents_of(graph, "sections/main-product.liquid"), + ["templates/page.json", "templates/product.json"], + "{}", + graph.report() + ); + } + + /// A theme at the project root, a theme in a folder marked only by its + /// settings schema, and one in a folder with no 404 section and no price + /// snippet of its own: the shape of panoply/syncify's example themes + /// (upstream's `severalThemes`). + fn several_themes() -> Project { + project_of(&several_themes_files()) + } + + /// The files of [`several_themes`], by path. + fn several_themes_files() -> Vec<(&'static str, String)> { + vec![ + ( + "layout/theme.liquid", + "{% section 'header' %}{{ content_for_layout }}\n".to_string(), + ), + ( + "sections/header.liquid", + "{% render 'price' %}\n".to_string(), + ), + ("sections/main.liquid", "
      \n".to_string()), + ( + "snippets/price.liquid", + "{{ product.price | money }}\n".to_string(), + ), + ("straps/dusk/config/settings_schema.json", "[]".to_string()), + ( + "straps/dusk/sections/404.liquid", + "

      {{ 'templates.404.title' | t }}

      \n".to_string(), + ), + ("straps/dusk/templates/404.json", json_template("404")), + ( + "examples/tailwind/layout/theme.liquid", + "{% section 'header' %}{{ content_for_layout }}\n".to_string(), + ), + ( + "examples/tailwind/sections/header.liquid", + "{% liquid\n render 'price'\n render 'icon'\n%}\n".to_string(), + ), + ( + "examples/tailwind/sections/main.liquid", + "
      \n".to_string(), + ), + ( + "examples/tailwind/snippets/icon.liquid", + "\n".to_string(), + ), + ( + "examples/tailwind/templates/index.json", + json_template("main"), + ), + ("examples/tailwind/templates/404.json", json_template("404")), + // Liquid in no theme: an app's theme extension. + ( + "extensions/reviews/blocks/stars.liquid", + "{% render 'star' %}\n".to_string(), + ), + ( + "extensions/reviews/snippets/star.liquid", + "\n".to_string(), + ), + ] + } + + /// A project of `files`, not yet indexed. + fn project_of(files: &[(&str, String)]) -> Project { + files.iter().fold(Project::new(), |project, (path, text)| { + project.file(path, text) + }) + } + + /// Every edge by its ends, kind, site and how it resolved, sorted: what a + /// sync has to match a fresh index of the same files on. + fn edge_set(graph: &Graph) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .map(|edge| { + format!( + "{} {} -> {} @{:?}:{:?} {}", + edge.kind, edge.source, edge.target, edge.line, edge.col, edge.metadata + ) + }) + .collect(); + out.sort(); + out + } + + /// Upstream #2396: a `{% render %}`, a `{% section %}` or a JSON + /// template's section `type` is looked up in the theme the file belongs + /// to, never in another one. What the theme lacks stays unlinked; + /// outside any theme, a path resolves as before. + #[test] + fn pr2396_a_themes_sections_and_snippets_link_only_inside_that_theme() { + let project = several_themes().index(); + let graph = project.graph(); + let expected: [(&str, &[&str]); 8] = [ + ("layout/theme.liquid", &["sections/header.liquid"]), + ("sections/header.liquid", &["snippets/price.liquid"]), + ( + "straps/dusk/templates/404.json", + &["straps/dusk/sections/404.liquid"], + ), + ( + "examples/tailwind/layout/theme.liquid", + &["examples/tailwind/sections/header.liquid"], + ), + ( + "examples/tailwind/templates/index.json", + &["examples/tailwind/sections/main.liquid"], + ), + ("examples/tailwind/templates/404.json", &[]), + ( + "examples/tailwind/sections/header.liquid", + &["examples/tailwind/snippets/icon.liquid"], + ), + ( + "extensions/reviews/blocks/stars.liquid", + &["extensions/reviews/snippets/star.liquid"], + ), + ]; + for (file, links) in expected { + assert_eq!(links_of(graph, file), links, "{file}\n{}", graph.report()); + } + } + + /// Upstream #2396: sync resolves again what named a deleted file, and + /// another theme's 404 section is no stand-in for the theme's own. + #[test] + fn pr2396_a_themes_references_stay_inside_it_when_its_section_is_deleted() { + let mut project = several_themes() + .file("examples/tailwind/sections/404.liquid", "

      404

      \n") + .index(); + assert_eq!( + links_of(project.graph(), "examples/tailwind/templates/404.json"), + ["examples/tailwind/sections/404.liquid"], + "{}", + project.graph().report() + ); + + project.remove("examples/tailwind/sections/404.liquid"); + let graph = project.sync(); + assert_eq!( + links_of(graph, "examples/tailwind/templates/404.json"), + Vec::::new(), + "{}", + graph.report() + ); + } + + /// Upstream #2403: a section or snippet added after the files naming it + /// links them on the next sync, in its own theme only, as a fresh index + /// of the same files does; a deleted snippet's references wait for it + /// the same way. (The port retries a parked path reference by its stem, + /// which an added file's import path keys carry, since W5-01.) + #[test] + fn pr2403_a_section_or_snippet_that_appears_later_links_the_files_naming_it() { + let later: [(&str, &str); 4] = [ + ("sections/404.liquid", "

      404

      \n"), + ("snippets/price.liquid", "{{ product.price | money }}\n"), + ("examples/tailwind/sections/404.liquid", "

      404

      \n"), + ("extensions/reviews/snippets/star.liquid", "\n"), + ]; + let naming = [ + "templates/404.json", + "sections/header.liquid", + "examples/tailwind/templates/404.json", + "examples/tailwind/sections/header.liquid", + "extensions/reviews/blocks/stars.liquid", + ]; + let links = |graph: &Graph| -> Vec<(&str, Vec)> { + naming + .iter() + .map(|file| (*file, links_of(graph, file))) + .collect() + }; + let mut initial: Vec<(&str, String)> = several_themes_files() + .into_iter() + .filter(|(path, _)| !later.iter().any(|(added, _)| added == path)) + .collect(); + initial.push(("templates/404.json", json_template("404"))); + let mut project = project_of(&initial).index(); + assert_eq!( + links(project.graph()), + [ + ("templates/404.json", vec![]), + ("sections/header.liquid", vec![]), + ("examples/tailwind/templates/404.json", vec![]), + ( + "examples/tailwind/sections/header.liquid", + vec!["examples/tailwind/snippets/icon.liquid".to_string()], + ), + ("extensions/reviews/blocks/stars.liquid", vec![]), + ], + "{}", + project.graph().report() + ); + + for (path, text) in later { + project.write(path, text); + } + let synced = project.sync().clone(); + assert_eq!( + links(&synced), + [ + ( + "templates/404.json", + vec!["sections/404.liquid".to_string()], + ), + ( + "sections/header.liquid", + vec!["snippets/price.liquid".to_string()], + ), + ( + "examples/tailwind/templates/404.json", + vec!["examples/tailwind/sections/404.liquid".to_string()], + ), + // The root theme's new price snippet is not this theme's. + ( + "examples/tailwind/sections/header.liquid", + vec!["examples/tailwind/snippets/icon.liquid".to_string()], + ), + ( + "extensions/reviews/blocks/stars.liquid", + vec!["extensions/reviews/snippets/star.liquid".to_string()], + ), + ], + "{}", + synced.report() + ); + let mut every_file = initial.clone(); + every_file.extend(later.iter().map(|(path, text)| (*path, text.to_string()))); + let fresh = project_of(&every_file).index(); + assert_eq!( + edge_set(&synced), + edge_set(fresh.graph()), + "the synced graph is a fresh index's\nsynced:\n{}\nfresh:\n{}", + synced.report(), + fresh.graph().report() + ); + + project.remove("snippets/price.liquid"); + let graph = project.sync(); + assert_eq!( + links_of(graph, "sections/header.liquid"), + Vec::::new(), + "{}", + graph.report() + ); + project.write("snippets/price.liquid", later[1].1); + let graph = project.sync(); + assert_eq!( + links_of(graph, "sections/header.liquid"), + ["snippets/price.liquid"], + "{}", + graph.report() + ); + } + + /// Upstream #2403 (`sync-path-reference-retry.test.ts`): a theme's + /// `{% render %}` and a JSON template's section link the snippet and the + /// section a sync adds, and nothing is left waiting for either. + #[test] + fn pr2403_a_theme_links_a_section_and_a_snippet_added_after_it() { + let mut project = Project::new() + .file( + "layout/theme.liquid", + "{% render 'price' %}{{ content_for_layout }}\n", + ) + .file("templates/404.json", &json_template("404")) + .index(); + project + .write("sections/404.liquid", "

      404

      \n") + .write("snippets/price.liquid", "{{ product.price }}\n"); + let graph = project.sync(); + assert_eq!( + links_of(graph, "templates/404.json"), + ["sections/404.liquid"], + "{}", + graph.report() + ); + assert_eq!( + links_of(graph, "layout/theme.liquid"), + ["snippets/price.liquid"], + "{}", + graph.report() + ); + assert!( + graph.unresolved.is_empty(), + "nothing waits for the added files:\n{}", + graph.report() + ); + } +} + +mod import_bindings { + use super::support::{Graph, Project}; + + /// The files that reach a module through a binding the module does not + /// declare by that name (upstream #2450's `IMPORTERS`): a default import + /// of a module whose default has another name, an aliased import through + /// a tsconfig alias, a namespace import, default imports called, built + /// and extended, an Express router mounted through a default import, and + /// Vue, Svelte and Astro components imported by default. + const IMPORTERS: [(&str, &str); 12] = [ + ( + "package.json", + "{\"name\":\"app\",\"dependencies\":{\"express\":\"^4\",\"vue\":\"^3\",\"svelte\":\"^4\",\"astro\":\"^4\"}}", + ), + ( + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@/*\":[\"src/*\"]}}}", + ), + ( + "src/routes/routes.ts", + "import tagsController from './tag/tag.controller';\nexport const routes = [tagsController];\n", + ), + ( + "src/pages/groups_page.test.tsx", + "import { Component as GroupsPageWrapper } from '@/pages/groups_page';\nexport const page = ;\n", + ), + ( + "src/use-ns.ts", + "import * as NS from './ns';\nexport const all = NS;\n", + ), + ( + "src/call.ts", + "import runIt from './handler';\nexport function go() { return runIt(); }\n", + ), + ( + "src/draw.ts", + "import { Widget as W } from './widgets/Widget';\nexport function paint() { return new W(); }\n", + ), + ( + "src/admin.ts", + "import Base from './base';\nexport class Admin extends Base {}\n", + ), + ( + "backend/server.js", + "import express from 'express';\nimport uploadRoutes from './routes/uploadRoutes.js';\nconst app = express();\napp.use('/api/upload', uploadRoutes);\nexport default app;\n", + ), + ( + "src/layouts/LayoutMain.vue", + "\n\n", + ), + ( + "src/App.svelte", + "\n\n", + ), + ( + "src/pages/index.astro", + "---\nimport Shell from '../layouts/BaseLayout.astro';\n---\n

      Home

      \n", + ), + ]; + + /// The modules they import (upstream's `MODULES`). + const MODULES: [(&str, &str); 10] = [ + ( + "src/routes/tag/tag.controller.ts", + "const router = { list() { return []; } };\nexport default router;\n", + ), + ( + "src/pages/groups_page.tsx", + "export function Component() { return null; }\n", + ), + ("src/ns.ts", "export const member = 1;\n"), + ( + "src/handler.ts", + "export default function handle() { return 1; }\n", + ), + ("src/widgets/Widget.ts", "export class Widget {}\n"), + ("src/base.ts", "export default class BaseUser {}\n"), + ( + "backend/routes/uploadRoutes.js", + "import express from 'express';\nconst router = express.Router();\nrouter.post('/', (req, res) => res.send('ok'));\nexport default router;\n", + ), + ( + "src/views/error/404.vue", + "\n", + ), + ("src/lib/Hero.svelte", "

      Hero

      \n"), + ( + "src/layouts/BaseLayout.astro", + "\n", + ), + ]; + + /// What this port's fresh index links from the importers to the modules + /// (upstream's `LINKS`, in this port's graph: it links neither Astro's + /// `Shell` to the `BaseLayout` component nor Express's mount of + /// `uploadRoutes` to its `router`, upstream's two other links). + const LINKS: [&str; 20] = [ + "calls function src/call.ts -> function src/handler.ts::handle", + "extends class src/admin.ts -> class src/base.ts::BaseUser", + "imports file backend/server.js -> file backend/routes/uploadRoutes.js::uploadRoutes.js", + "imports file src/App.svelte -> component src/lib/Hero.svelte::Hero", + "imports file src/App.svelte -> file src/lib/Hero.svelte::Hero.svelte", + "imports file src/admin.ts -> file src/base.ts::base.ts", + "imports file src/call.ts -> file src/handler.ts::handler.ts", + "imports file src/draw.ts -> class src/widgets/Widget.ts::Widget", + "imports file src/draw.ts -> file src/widgets/Widget.ts::Widget.ts", + "imports file src/layouts/LayoutMain.vue -> component src/views/error/404.vue::404", + "imports file src/layouts/LayoutMain.vue -> file src/views/error/404.vue::404.vue", + "imports file src/pages/groups_page.test.tsx -> file src/pages/groups_page.tsx::groups_page.tsx", + "imports file src/pages/groups_page.test.tsx -> function src/pages/groups_page.tsx::Component", + "imports file src/pages/index.astro -> file src/layouts/BaseLayout.astro::BaseLayout.astro", + "imports file src/routes/routes.ts -> file src/routes/tag/tag.controller.ts::tag.controller.ts", + "imports file src/use-ns.ts -> file src/ns.ts::ns.ts", + "instantiates function src/draw.ts -> class src/widgets/Widget.ts::Widget", + "references component src/App.svelte -> component src/lib/Hero.svelte::Hero", + "references component src/layouts/LayoutMain.vue -> component src/views/error/404.vue::404", + "references constant src/pages/groups_page.test.tsx -> function src/pages/groups_page.tsx::Component", + ]; + + fn project_of(files: &[(&str, &str)]) -> Project { + files.iter().fold(Project::new(), |project, (path, text)| { + project.file(path, text) + }) + } + + fn every_file() -> Vec<(&'static str, &'static str)> { + IMPORTERS.iter().chain(&MODULES).copied().collect() + } + + /// Every edge from another file to a module's, by its kind and its ends' + /// kinds and files, sorted and deduplicated. + fn links(graph: &Graph) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind != "contains") + .filter_map(|edge| { + let source = graph.node(&edge.source)?; + let target = graph.node(&edge.target)?; + (source.file_path != target.file_path + && MODULES.iter().any(|(path, _)| *path == target.file_path)) + .then(|| { + format!( + "{} {} {} -> {} {}::{}", + edge.kind, + source.kind, + source.file_path, + target.kind, + target.file_path, + target.name + ) + }) + }) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// Every edge by its ends, kind, site and how it resolved, sorted: what a + /// sync has to match a fresh index of the same files on. + fn edge_set(graph: &Graph) -> Vec { + let mut out: Vec = graph + .edges + .iter() + .map(|edge| { + format!( + "{} {} -> {} @{:?}:{:?} {}", + edge.kind, edge.source, edge.target, edge.line, edge.col, edge.metadata + ) + }) + .collect(); + out.sort(); + out + } + + #[track_caller] + fn assert_fresh(synced: &Graph, files: &[(&str, &str)]) { + let fresh = project_of(files).index(); + assert_eq!( + edge_set(synced), + edge_set(fresh.graph()), + "the synced graph is a fresh index's\nsynced:\n{}\nfresh:\n{}", + synced.report(), + fresh.graph().report() + ); + } + + /// A module before the edit that gives it what its importers import + /// (upstream's `stub`). + fn stub(path: &str) -> &'static str { + if path.ends_with(".vue") { + "\n" + } else if path.ends_with(".svelte") || path.ends_with(".astro") { + "

      \n" + } else { + "export const placeholder = 1;\n" + } + } + + /// Upstream #2450: the modules added after their importers link them on + /// the next sync, the importers unchanged. + #[test] + fn pr2450_a_module_added_after_its_importers_links_them() { + let mut project = project_of(&IMPORTERS).index(); + assert_eq!(links(project.graph()), Vec::::new()); + for (path, text) in MODULES { + project.write(path, text); + } + let synced = project.sync().clone(); + assert_eq!(links(&synced), LINKS, "{}", synced.report()); + assert_fresh(&synced, &every_file()); + } + + /// Upstream #2450: the modules deleted and then restored link their + /// importers again. + #[test] + fn pr2450_a_module_restored_after_a_delete_links_them_again() { + let mut project = project_of(&every_file()).index(); + assert_eq!( + links(project.graph()), + LINKS, + "{}", + project.graph().report() + ); + for (path, _) in MODULES { + project.remove(path); + } + let graph = project.sync(); + assert_eq!(links(graph), Vec::::new(), "{}", graph.report()); + for (path, text) in MODULES { + project.write(path, text); + } + let synced = project.sync().clone(); + assert_eq!(links(&synced), LINKS, "{}", synced.report()); + assert_fresh(&synced, &every_file()); + } + + /// Upstream #2450: an edit that gives each module what its importers + /// import links them; an aliased binding (`W`, `GroupsPageWrapper`) is + /// written under a name the module never declares. + #[test] + fn pr2450_an_edit_that_gives_the_module_its_import_links_the_importers() { + let stubs: Vec<(&str, &str)> = MODULES + .iter() + .map(|(path, _)| (*path, stub(path))) + .collect(); + let initial: Vec<(&str, &str)> = IMPORTERS.iter().copied().chain(stubs).collect(); + let mut project = project_of(&initial).index(); + for (path, text) in MODULES { + project.write(path, text); + } + let synced = project.sync().clone(); + assert_eq!(links(&synced), LINKS, "{}", synced.report()); + assert_fresh(&synced, &every_file()); + } + + /// Upstream #2450 (vben's `{ VbenFormSchema as FormSchema }`): a binding + /// its module never declares is linked by its own name, to the + /// declaration a sync adds elsewhere. + #[test] + fn pr2450_a_binding_its_module_never_declares_links_by_its_own_name() { + let initial = [ + ("src/lib.ts", "export const unrelated = 1;\n"), + ( + "src/form.ts", + "import type { VbenFormSchema as FormSchema } from './lib';\nexport type Schema = FormSchema;\n", + ), + ]; + let added = ( + "src/types.ts", + "export type FormSchema = { field: string };\n", + ); + let mut project = project_of(&initial).index(); + project.write(added.0, added.1); + let synced = project.sync().clone(); + let mut to_form_schema: Vec = synced + .edges + .iter() + .filter_map(|edge| { + let source = synced.node(&edge.source)?; + let target = synced.node(&edge.target)?; + (source.file_path == "src/form.ts" && target.name == "FormSchema").then(|| { + format!( + "{} {} -> {} {}", + edge.kind, source.kind, target.kind, target.file_path + ) + }) + }) + .collect(); + to_form_schema.sort(); + assert_eq!( + to_form_schema, + [ + "imports file -> type_alias src/types.ts", + "references type_alias -> type_alias src/types.ts", + ], + "{}", + synced.report() + ); + let mut every_file = initial.to_vec(); + every_file.push(added); + assert_fresh(&synced, &every_file); + } +} + +mod pascal_includes { + use super::support::{Graph, Project}; + + const DIRECTIVES: &str = "{ Compiler version switches }\n{$IFDEF VER150}\n {$DEFINE DELPHI7}\n{$ENDIF}\n{$IFNDEF FPC}\n {$WARN SYMBOL_PLATFORM OFF}\n{$ELSE}\n {$MODE DELPHI}{$H+}\n{$ENDIF}\n"; + const ROUTINES: &str = "procedure Foo;\nbegin\n WriteLn('foo');\nend;\n\nfunction Bar(A: Integer): Integer;\nbegin\n Result := A + 1;\nend;\n"; + + fn language_of<'g>(graph: &'g Graph, path: &str) -> Option<&'g str> { + graph + .files + .iter() + .find(|file| file.path == path) + .map(|file| file.language.as_str()) + } + + /// `kind:name:language` of every node in `path` but its file node, sorted + /// (upstream's `symbolsIn`). + fn symbols_in(graph: &Graph, path: &str) -> Vec { + let mut out: Vec = graph + .nodes + .iter() + .filter(|node| node.file_path == path && node.kind != "file") + .map(|node| format!("{}:{}:{}", node.kind, node.name, node.language)) + .collect(); + out.sort(); + out + } + + fn has_no_errors(graph: &Graph, path: &str) -> bool { + graph + .files + .iter() + .find(|file| file.path == path) + .is_some_and(|file| file.errors.is_null() || file.errors == serde_json::json!([])) + } + + /// Upstream #2289 (`pascal-inc-detection.test.ts`): a tree of Pascal + /// includes with no `.pas` file indexes them as Pascal. + #[test] + fn pr2289_a_tree_of_pascal_includes_indexes_as_pascal() { + let project = Project::new() + .file("inc/defs.inc", DIRECTIVES) + .file("inc/routines.inc", ROUTINES) + .index(); + let graph = project.graph(); + assert_eq!( + language_of(graph, "inc/defs.inc"), + Some("pascal"), + "{}", + graph.report() + ); + assert_eq!( + language_of(graph, "inc/routines.inc"), + Some("pascal"), + "{}", + graph.report() + ); + assert_eq!( + symbols_in(graph, "inc/routines.inc"), + ["function:Bar:pascal", "function:Foo:pascal"], + "{}", + graph.report() + ); + assert!( + has_no_errors(graph, "inc/routines.inc"), + "{}", + graph.report() + ); + } + + /// Upstream #2289: each include is classified by its content, and a sync + /// of edited includes keeps the language. + #[test] + fn pr2289_each_include_is_classified_by_content_and_sync_keeps_it() { + let mut project = Project::new() + .file( + "U.pas", + "unit U;\n\ninterface\n\n{$I defs.inc}\n\nprocedure Foo;\n\nimplementation\n\n{$I routines.inc}\n\nend.\n", + ) + .file("defs.inc", DIRECTIVES) + .file("routines.inc", ROUTINES) + .file( + "drupal/mymodule.inc", + "::new(), + "{report}" + ); + assert!(has_no_errors(graph, "defs.inc"), "{report}"); + assert_eq!( + symbols_in(graph, "routines.inc"), + ["function:Bar:pascal", "function:Foo:pascal"], + "{report}" + ); + assert_eq!( + symbols_in(graph, "drupal/mymodule.inc"), + ["function:mymodule_helper:php"], + "{report}" + ); + + project.write( + "defs.inc", + &format!("{DIRECTIVES}{{$DEFINE USE_FAST_MM}}\n"), + ); + project.write( + "routines.inc", + &format!("{ROUTINES}procedure Baz;\nbegin\nend;\n"), + ); + let graph = project.sync(); + let report = graph.report(); + assert_eq!(language_of(graph, "defs.inc"), Some("pascal"), "{report}"); + assert_eq!( + language_of(graph, "routines.inc"), + Some("pascal"), + "{report}" + ); + assert_eq!( + symbols_in(graph, "routines.inc"), + [ + "function:Bar:pascal", + "function:Baz:pascal", + "function:Foo:pascal" + ], + "{report}" + ); + assert_eq!( + language_of(graph, "drupal/mymodule.inc"), + Some("php"), + "{report}" + ); + } + + /// Upstream #2289: a `codegraph.json` mapping for `.inc` is the project's + /// answer, kept through index and sync. + #[test] + fn pr2289_a_codegraph_json_inc_mapping_is_not_second_guessed() { + let mut project = Project::new() + .file( + ".codegraph/codegraph.json", + "{ \"extensions\": { \".inc\": \"php\" } }\n", + ) + .file("defs.inc", DIRECTIVES) + .index(); + assert_eq!( + language_of(project.graph(), "defs.inc"), + Some("php"), + "{}", + project.graph().report() + ); + project.write("defs.inc", &format!("{DIRECTIVES}{{$DEFINE MORE}}\n")); + let graph = project.sync(); + assert_eq!( + language_of(graph, "defs.inc"), + Some("php"), + "{}", + graph.report() + ); + } + + /// KEEP-RUST: an override never claims a built-in extension, so a + /// mapping of `.inc` to another language turns the content reading off + /// and leaves `.inc` PHP (upstream would take the mapping). + #[test] + fn keep_rust_an_inc_mapping_to_another_language_leaves_it_php() { + let project = Project::new() + .file( + ".codegraph/codegraph.json", + "{ \"extensions\": { \".inc\": \"pascal\" } }\n", + ) + .file("defs.inc", DIRECTIVES) + .file("page.inc", " SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_000) +} + +/// Modification time of the `generation`-th edit of a project (1-based). +pub fn edit_mtime(generation: u32) -> SystemTime { + base_mtime() + Duration::from_secs(60 * u64::from(generation)) +} + +pub fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +/// Runs the built `codegraph` with the daemon and the watcher disabled, in +/// `cwd`, and returns its output. A non-zero exit panics with both streams. +#[track_caller] +pub fn codegraph(cwd: &Path, args: I) -> Output +where + I: IntoIterator, + S: AsRef, +{ + let args = args + .into_iter() + .map(|arg| arg.as_ref().to_os_string()) + .collect::>(); + let output = Command::new(bin()) + .args(&args) + .current_dir(cwd) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .output() + .unwrap_or_else(|err| panic!("spawn {}: {err}", bin().display())); + assert!( + output.status.success(), + "`codegraph {}` exited with {}\n--- stdout\n{}--- stderr\n{}", + args.iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "), + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + output +} + +/// The index paths the CLI and the in-process sync resolve for `root`. +#[track_caller] +pub fn index_paths(root: &Path) -> IndexPaths { + IndexPaths::resolve(root, std::env::var("CODEGRAPH_DIR").ok().as_deref()) + .unwrap_or_else(|err| panic!("resolve the index paths of {}: {err}", root.display())) +} + +pub fn db_path(root: &Path) -> PathBuf { + index_paths(root).current_db() +} + +/// A fixture path stays inside its project: relative, `/`-separated, with no +/// empty, `.` or `..` component. Returns it in the platform's own form. +#[track_caller] +pub fn checked_relative(relative: &str) -> PathBuf { + let plain = !relative.is_empty() + && !relative.starts_with('/') + && !relative.contains('\\') + && !relative.contains(':') + && relative + .split('/') + .all(|part| !part.is_empty() && part != "." && part != ".."); + assert!( + plain, + "fixture path `{relative}` must be a plain relative `/` path inside the project" + ); + relative.split('/').collect() +} + +/// Writes `contents` to `root/relative`, creating parent directories, and +/// stamps the file with `mtime`. +#[track_caller] +pub fn write_file(root: &Path, relative: &str, contents: &str, mtime: SystemTime) { + let path = root.join(checked_relative(relative)); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .unwrap_or_else(|err| panic!("create {}: {err}", parent.display())); + } + fs::write(&path, contents).unwrap_or_else(|err| panic!("write {}: {err}", path.display())); + set_mtime(&path, mtime); +} + +#[track_caller] +pub fn set_mtime(path: &Path, mtime: SystemTime) { + fs::File::options() + .write(true) + .open(path) + .and_then(|file| file.set_modified(mtime)) + .unwrap_or_else(|err| panic!("set the mtime of {}: {err}", path.display())); +} + +/// A project tree in its own temporary directory, not yet indexed. +pub struct Project { + _dir: TempDir, + root: PathBuf, +} + +impl Default for Project { + fn default() -> Self { + Self::new() + } +} + +impl Project { + pub fn new() -> Self { + Self::named("project") + } + + /// A project whose root directory is called `label`, which failure output + /// and stray-process listings show. + pub fn named(label: &str) -> Self { + let dir = TempDir::new().expect("create a temporary project directory"); + let root = dir.path().join(checked_relative(label)); + fs::create_dir_all(&root).unwrap_or_else(|err| panic!("create {}: {err}", root.display())); + Self { _dir: dir, root } + } + + #[track_caller] + pub fn file(self, relative: &str, contents: &str) -> Self { + write_file(&self.root, relative, contents, base_mtime()); + self + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// Runs `codegraph init` on the tree and reads the published graph. + #[track_caller] + pub fn index(self) -> Indexed { + codegraph(&self.root, [OsStr::new("init"), self.root.as_os_str()]); + let graph = Graph::load(&db_path(&self.root)); + Indexed { + project: self, + graph, + generation: 0, + } + } +} + +/// An indexed project and the graph last read from it. +pub struct Indexed { + project: Project, + graph: Graph, + generation: u32, +} + +impl Indexed { + pub fn root(&self) -> &Path { + self.project.root() + } + + pub fn db(&self) -> PathBuf { + db_path(self.root()) + } + + pub fn graph(&self) -> &Graph { + &self.graph + } + + /// Adds or rewrites one file with a modification time no earlier write of + /// this project used. The index is not touched until [`Self::sync`]. + #[track_caller] + pub fn write(&mut self, relative: &str, contents: &str) -> &mut Self { + self.generation += 1; + write_file( + self.project.root(), + relative, + contents, + edit_mtime(self.generation), + ); + self + } + + #[track_caller] + pub fn remove(&mut self, relative: &str) -> &mut Self { + let path = self.root().join(checked_relative(relative)); + fs::remove_file(&path).unwrap_or_else(|err| panic!("remove {}: {err}", path.display())); + self + } + + /// Renames one file. The moved file keeps its modification time, as a + /// rename on disk does. + #[track_caller] + pub fn rename(&mut self, from: &str, to: &str) -> &mut Self { + let from = self.root().join(checked_relative(from)); + let to = self.root().join(checked_relative(to)); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent) + .unwrap_or_else(|err| panic!("create {}: {err}", parent.display())); + } + fs::rename(&from, &to) + .unwrap_or_else(|err| panic!("rename {} to {}: {err}", from.display(), to.display())); + self + } + + /// Runs `codegraph sync` and re-reads the graph. + #[track_caller] + pub fn sync(&mut self) -> &Graph { + codegraph(self.root(), [OsStr::new("sync"), self.root().as_os_str()]); + self.reload() + } + + /// Re-reads the graph after something other than this harness updated the + /// index. + #[track_caller] + pub fn reload(&mut self) -> &Graph { + self.graph = Graph::load(&self.db()); + &self.graph + } +} + +impl AsRef for Indexed { + fn as_ref(&self) -> &Graph { + &self.graph + } +} + +impl AsRef for Graph { + fn as_ref(&self) -> &Graph { + self + } +} + +/// What the assertion macros accept: an [`Indexed`] project or a [`Graph`]. +pub fn graph_of + ?Sized>(graph: &G) -> &Graph { + graph.as_ref() +} + +#[derive(Clone, Debug)] +pub struct NodeRow { + pub id: String, + pub kind: String, + pub name: String, + pub qualified_name: String, + pub file_path: String, + pub language: String, + pub start_line: i64, + pub end_line: i64, + pub is_exported: bool, + pub is_static: bool, + pub visibility: Option, + pub signature: Option, + pub docstring: Option, + pub decorators: Value, + pub return_type: Option, +} + +#[derive(Clone, Debug)] +pub struct EdgeRow { + pub source: String, + pub target: String, + pub kind: String, + pub metadata: Value, + pub line: Option, + pub col: Option, + pub provenance: Option, +} + +impl EdgeRow { + pub fn resolved_by(&self) -> Option<&str> { + self.metadata.get("resolvedBy").and_then(Value::as_str) + } + + pub fn confidence(&self) -> Option { + self.metadata.get("confidence").and_then(Value::as_f64) + } +} + +#[derive(Clone, Debug)] +pub struct RefRow { + pub from_node_id: String, + pub reference_name: String, + pub reference_kind: String, + pub line: i64, + pub col: i64, + pub file_path: String, + pub language: String, + pub candidates: Value, + pub subkind: Option, +} + +#[derive(Clone, Debug)] +pub struct FileRow { + pub path: String, + pub language: String, + pub node_count: i64, + pub errors: Value, + pub generated: bool, +} + +/// Every row of one published index, read once. +#[derive(Clone, Debug)] +pub struct Graph { + pub nodes: Vec, + pub edges: Vec, + pub unresolved: Vec, + pub files: Vec, + by_id: BTreeMap, +} + +impl Graph { + /// Reads every node, edge, unresolved reference and file of `db` through + /// an immutable connection. + #[track_caller] + pub fn load(db: &Path) -> Self { + let conn = open_immutable(db); + let nodes = rows( + &conn, + "SELECT id, kind, name, qualified_name, file_path, language, start_line, end_line, \ + is_exported, is_static, visibility, signature, docstring, decorators, return_type \ + FROM nodes ORDER BY id", + |row| { + Ok(NodeRow { + id: row.get(0)?, + kind: row.get(1)?, + name: row.get(2)?, + qualified_name: row.get(3)?, + file_path: row.get(4)?, + language: row.get(5)?, + start_line: row.get(6)?, + end_line: row.get(7)?, + is_exported: row.get(8)?, + is_static: row.get(9)?, + visibility: row.get(10)?, + signature: row.get(11)?, + docstring: row.get(12)?, + decorators: json_column(row, 13)?, + return_type: row.get(14)?, + }) + }, + ); + let edges = rows( + &conn, + "SELECT source, target, kind, metadata, line, col, provenance FROM edges \ + ORDER BY source, kind, target, line, col", + |row| { + Ok(EdgeRow { + source: row.get(0)?, + target: row.get(1)?, + kind: row.get(2)?, + metadata: json_column(row, 3)?, + line: row.get(4)?, + col: row.get(5)?, + provenance: row.get(6)?, + }) + }, + ); + let unresolved = rows( + &conn, + "SELECT from_node_id, reference_name, reference_kind, line, col, file_path, \ + language, candidates, reference_subkind FROM unresolved_refs \ + ORDER BY file_path, line, col, reference_kind, reference_name", + |row| { + Ok(RefRow { + from_node_id: row.get(0)?, + reference_name: row.get(1)?, + reference_kind: row.get(2)?, + line: row.get(3)?, + col: row.get(4)?, + file_path: row.get(5)?, + language: row.get(6)?, + candidates: json_column(row, 7)?, + subkind: row.get(8)?, + }) + }, + ); + let files = rows( + &conn, + "SELECT path, language, node_count, errors, generated FROM files ORDER BY path", + |row| { + Ok(FileRow { + path: row.get(0)?, + language: row.get(1)?, + node_count: row.get(2)?, + errors: json_column(row, 3)?, + generated: row.get::<_, i64>(4)? != 0, + }) + }, + ); + let by_id = nodes + .iter() + .enumerate() + .map(|(index, node)| (node.id.clone(), index)) + .collect(); + Self { + nodes, + edges, + unresolved, + files, + by_id, + } + } + + pub fn node(&self, id: &str) -> Option<&NodeRow> { + self.by_id.get(id).map(|&index| &self.nodes[index]) + } + + /// Every node `selector` matches. + #[track_caller] + pub fn nodes_matching(&self, selector: &str) -> Vec<&NodeRow> { + let selector = Selector::parse(selector); + self.nodes + .iter() + .filter(|node| selector.matches(node)) + .collect() + } + + /// Every node of `kind` (a [`NodeKind`] name) whose bare name is `name`. + #[track_caller] + pub fn nodes_named(&self, kind: &str, name: &str) -> Vec<&NodeRow> { + let kind = node_kind(kind); + self.nodes + .iter() + .filter(|node| node.kind == kind && node.name == name) + .collect() + } + + /// Every edge of `kind` (an [`EdgeKind`] name) from a node matching + /// `source` to a node matching `target`. + #[track_caller] + pub fn edges_matching(&self, source: &str, kind: &str, target: &str) -> Vec<&EdgeRow> { + let kind = edge_kind(kind); + let source = Selector::parse(source); + let target = Selector::parse(target); + self.edges + .iter() + .filter(|edge| { + edge.kind == kind + && self + .node(&edge.source) + .is_some_and(|node| source.matches(node)) + && self + .node(&edge.target) + .is_some_and(|node| target.matches(node)) + }) + .collect() + } + + /// Every unresolved reference of `kind` named `name` whose source node + /// matches `from`. + #[track_caller] + pub fn unresolved_matching(&self, from: &str, kind: &str, name: &str) -> Vec<&RefRow> { + let from = Selector::parse(from); + self.unresolved + .iter() + .filter(|reference| { + reference.reference_kind == kind + && reference.reference_name == name + && self + .node(&reference.from_node_id) + .is_some_and(|node| from.matches(node)) + }) + .collect() + } + + /// `node` in selector notation. + pub fn describe(&self, id: &str) -> String { + match self.node(id) { + Some(node) if node.kind == NodeKind::File.as_str() => { + format!("file:{}", node.file_path) + } + Some(node) => format!( + "{} {} @{}:{}", + node.kind, node.qualified_name, node.file_path, node.start_line + ), + None => format!(""), + } + } + + pub fn describe_edge(&self, edge: &EdgeRow) -> String { + let mut line = format!( + "{} -[{}]-> {}", + self.describe(&edge.source), + edge.kind, + self.describe(&edge.target) + ); + if let (Some(at), Some(col)) = (edge.line, edge.col) { + let _ = write!(line, " at {at}:{col}"); + } + if let Some(by) = edge.resolved_by() { + let _ = write!(line, " {by}"); + } + if let Some(confidence) = edge.confidence() { + let _ = write!(line, " {confidence}"); + } + if let Some(provenance) = &edge.provenance { + let _ = write!(line, " provenance={provenance}"); + } + line + } + + pub fn describe_ref(&self, reference: &RefRow) -> String { + let mut line = format!( + "{} -[{}]-> ? {} at {}:{}", + self.describe(&reference.from_node_id), + reference.reference_kind, + reference.reference_name, + reference.line, + reference.col + ); + if !reference.candidates.is_null() { + let _ = write!(line, " candidates={}", reference.candidates); + } + if let Some(subkind) = &reference.subkind { + let _ = write!(line, " subkind={subkind}"); + } + line + } + + /// Every edge except `contains`, one per line in selector notation, sorted. + pub fn edge_list(&self) -> String { + let mut lines = self + .edges + .iter() + .filter(|edge| edge.kind != EdgeKind::Contains.as_str()) + .map(|edge| self.describe_edge(edge)) + .collect::>(); + lines.sort(); + listing("non-contains edges", &lines) + } + + /// Every unresolved reference, one per line, sorted. + pub fn unresolved_list(&self) -> String { + let mut lines = self + .unresolved + .iter() + .map(|reference| self.describe_ref(reference)) + .collect::>(); + lines.sort(); + listing("unresolved references", &lines) + } + + /// The project's whole graph: non-contains edges, unresolved references and + /// files with their languages. + pub fn report(&self) -> String { + let files = self + .files + .iter() + .map(|file| { + if file.errors.is_null() { + format!("{} ({})", file.path, file.language) + } else { + format!("{} ({}) errors={}", file.path, file.language, file.errors) + } + }) + .collect::>(); + format!( + "{}{}{}", + self.edge_list(), + self.unresolved_list(), + listing("files", &files) + ) + } + + /// Panics unless an edge of `kind` joins a node matching `source` to a node + /// matching `target` (with `metadata.resolvedBy == resolved_by`, if given). + #[track_caller] + pub fn assert_edge(&self, source: &str, kind: &str, target: &str, resolved_by: Option<&str>) { + let found = self.edges_matching(source, kind, target); + let satisfied = match resolved_by { + None => !found.is_empty(), + Some(by) => found.iter().any(|edge| edge.resolved_by() == Some(by)), + }; + if !satisfied { + let wanted = match resolved_by { + None => format!("{source} -[{kind}]-> {target}"), + Some(by) => format!("{source} -[{kind}]-> {target} resolved by `{by}`"), + }; + panic!( + "expected an edge {wanted}\n{}{}", + self.selector_report(&[source, target], &found), + self.report() + ); + } + } + + /// Panics if any edge of `kind` joins a node matching `source` to a node + /// matching `target`. Both selectors must match a node: a negative + /// assertion over a node that does not exist proves nothing. + #[track_caller] + pub fn assert_no_edge(&self, source: &str, kind: &str, target: &str) { + for selector in [source, target] { + if self.nodes_matching(selector).is_empty() { + panic!( + "`{selector}` matches no node, so `assert_no_edge` would hold vacuously\n{}", + self.report() + ); + } + } + let found = self.edges_matching(source, kind, target); + if !found.is_empty() { + panic!( + "expected no edge {source} -[{kind}]-> {target}\n{}{}", + self.selector_report(&[source, target], &found), + self.report() + ); + } + } + + /// Panics unless an unresolved reference of `kind` named `name` leaves a + /// node matching `from`. + #[track_caller] + pub fn assert_unresolved(&self, from: &str, kind: &str, name: &str) { + if self.unresolved_matching(from, kind, name).is_empty() { + panic!( + "expected an unresolved reference {from} -[{kind}]-> ? {name}\n{}{}", + self.selector_report(&[from], &[]), + self.report() + ); + } + } + + fn selector_report(&self, selectors: &[&str], found: &[&EdgeRow]) -> String { + let mut report = String::new(); + for selector in selectors { + let matched = self + .nodes_matching(selector) + .into_iter() + .map(|node| self.describe(&node.id)) + .collect::>(); + if matched.is_empty() { + // Point at the nodes the selector probably meant. + let wanted = Selector::parse(selector); + let bare = wanted + .qualified_name + .rsplit("::") + .next() + .unwrap_or(wanted.qualified_name); + let near = self + .nodes + .iter() + .filter(|node| node.name == bare || node.qualified_name.ends_with(bare)) + .map(|node| self.describe(&node.id)) + .collect::>(); + report.push_str(&listing( + &format!("`{selector}` matches no node; nodes named like it"), + &near, + )); + } else { + report.push_str(&listing(&format!("`{selector}` matches"), &matched)); + } + } + if !found.is_empty() { + let lines = found + .iter() + .map(|edge| self.describe_edge(edge)) + .collect::>(); + report.push_str(&listing("matching edges", &lines)); + } + report + } +} + +fn listing(title: &str, lines: &[String]) -> String { + let mut text = format!("{title} ({}):\n", lines.len()); + for line in lines { + let _ = writeln!(text, " {line}"); + } + text +} + +/// A parsed node selector; see the module documentation for the notation. +struct Selector<'a> { + file_node: Option<&'a str>, + kind: Option<&'a str>, + qualified_name: &'a str, + file: Option<&'a str>, + line: Option, +} + +impl<'a> Selector<'a> { + #[track_caller] + fn parse(text: &'a str) -> Self { + if let Some(path) = text.strip_prefix("file:") { + assert!(!path.is_empty(), "malformed node selector `{text}`"); + return Self { + file_node: Some(path), + kind: None, + qualified_name: path, + file: None, + line: None, + }; + } + let (body, location) = match text.rsplit_once(" @") { + Some((body, location)) => (body, Some(location)), + None => (text, None), + }; + let (file, line) = match location { + None => (None, None), + Some(location) => match location.rsplit_once(':') { + Some((file, line)) + if !line.is_empty() && line.bytes().all(|byte| byte.is_ascii_digit()) => + { + (Some(file), line.parse().ok()) + } + _ => (Some(location), None), + }, + }; + let (kind, qualified_name) = match body.split_once(' ') { + Some((kind, rest)) if NodeKind::ALL.iter().any(|known| known.as_str() == kind) => { + (Some(kind), rest) + } + _ => (None, body), + }; + assert!( + !qualified_name.is_empty() && file.is_none_or(|file| !file.is_empty()), + "malformed node selector `{text}`" + ); + Self { + file_node: None, + kind, + qualified_name, + file, + line, + } + } + + fn matches(&self, node: &NodeRow) -> bool { + // By path, not by id: the embedded extractors (Liquid, MyBatis, …) key + // their file node by a hash rather than `file:{path}`. + if let Some(path) = self.file_node { + return node.kind == NodeKind::File.as_str() && node.file_path == path; + } + node.qualified_name == self.qualified_name + && self.kind.is_none_or(|kind| node.kind == kind) + && self.file.is_none_or(|file| node.file_path == file) + && self.line.is_none_or(|line| node.start_line == line) + } +} + +#[track_caller] +fn edge_kind(kind: &str) -> &str { + assert!( + EdgeKind::ALL.iter().any(|known| known.as_str() == kind), + "`{kind}` is not an edge kind; known kinds: {:?}", + EdgeKind::ALL.map(EdgeKind::as_str) + ); + kind +} + +#[track_caller] +fn node_kind(kind: &str) -> &str { + assert!( + NodeKind::ALL.iter().any(|known| known.as_str() == kind), + "`{kind}` is not a node kind; known kinds: {:?}", + NodeKind::ALL.map(NodeKind::as_str) + ); + kind +} + +/// The `project_metadata` rows of `db` whose key starts with `prefix`, by +/// key. The sync matrix compares the module-surface rows a sync leaves with a +/// fresh index's; the probes do not read them. +#[allow(dead_code)] +#[track_caller] +pub fn project_metadata_with_prefix(db: &Path, prefix: &str) -> Vec<(String, String)> { + let conn = open_immutable(db); + let mut stmt = conn + .prepare( + "SELECT key, value FROM project_metadata WHERE substr(key, 1, ?2) = ?1 ORDER BY key", + ) + .expect("prepare the project_metadata read"); + stmt.query_map( + rusqlite::params![prefix, prefix.chars().count() as i64], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .and_then(|rows| rows.collect::>>()) + .unwrap_or_else(|err| panic!("read project_metadata of {}: {err}", db.display())) +} + +/// Opens `db` read-only with `immutable=1`: no lock, no `-wal`/`-shm`. +/// +/// An immutable connection does not read a write-ahead log, so a non-empty one +/// beside the database would make it miss committed rows. The CLI checkpoints +/// and removes its log when it exits, so one here is a harness error. +#[track_caller] +fn open_immutable(db: &Path) -> Connection { + let mut wal = db.as_os_str().to_os_string(); + wal.push("-wal"); + if let Ok(meta) = fs::metadata(&wal) { + assert!( + meta.len() == 0, + "{} has a non-empty write-ahead log; an immutable read would miss rows", + db.display() + ); + } + let uri = format!("{}?immutable=1", sqlite_file_uri(db)); + Connection::open_with_flags( + &uri, + OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .unwrap_or_else(|err| panic!("open {uri}: {err}")) +} + +/// `path` as an absolute SQLite `file:` URI. A Windows verbatim prefix +/// (`\\?\`) is dropped, and every byte outside the unreserved set is +/// percent-encoded. +fn sqlite_file_uri(path: &Path) -> String { + let text = path.to_string_lossy().replace('\\', "/"); + let text = text.strip_prefix("//?/").unwrap_or(&text); + let mut uri = String::from("file://"); + if !text.starts_with('/') { + uri.push('/'); + } + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || b"/-._~:".contains(&byte) { + uri.push(char::from(byte)); + } else { + let _ = write!(uri, "%{byte:02X}"); + } + } + uri +} + +#[track_caller] +fn rows( + conn: &Connection, + sql: &str, + map: impl FnMut(&Row<'_>) -> rusqlite::Result, +) -> Vec { + let mut statement = conn + .prepare(sql) + .unwrap_or_else(|err| panic!("prepare `{sql}`: {err}")); + statement + .query_map([], map) + .and_then(|mapped| mapped.collect::>>()) + .unwrap_or_else(|err| panic!("query `{sql}`: {err}")) +} + +fn json_column(row: &Row<'_>, index: usize) -> rusqlite::Result { + let text: Option = row.get(index)?; + Ok(text + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or(Value::Null)) +} + +/// `assert_edge!(graph, "source" => kind => "target")` panics unless an edge +/// of `kind` joins a node matching `source` to a node matching `target`; +/// `, resolved_by = "import"` also requires that `metadata.resolvedBy`. +/// The failure lists the project's whole non-contains edge list. +macro_rules! assert_edge { + ($graph:expr, $source:expr => $kind:ident => $target:expr $(,)?) => { + $crate::support::graph_of(&$graph).assert_edge($source, stringify!($kind), $target, None) + }; + ($graph:expr, $source:expr => $kind:ident => $target:expr, resolved_by = $by:expr $(,)?) => { + $crate::support::graph_of(&$graph).assert_edge( + $source, + stringify!($kind), + $target, + Some($by), + ) + }; +} +pub(crate) use assert_edge; + +/// `assert_no_edge!(graph, "source" => kind => "target")` panics if such an +/// edge exists, or if either selector matches no node. +macro_rules! assert_no_edge { + ($graph:expr, $source:expr => $kind:ident => $target:expr $(,)?) => { + $crate::support::graph_of(&$graph).assert_no_edge($source, stringify!($kind), $target) + }; +} +pub(crate) use assert_no_edge; + +/// `assert_unresolved!(graph, "from" => kind => "name")` panics unless an +/// unresolved reference of `kind` named `name` leaves a node matching `from`. +macro_rules! assert_unresolved { + ($graph:expr, $from:expr => $kind:ident => $name:expr $(,)?) => { + $crate::support::graph_of(&$graph).assert_unresolved($from, stringify!($kind), $name) + }; +} +pub(crate) use assert_unresolved; diff --git a/crates/codegraph-cli/tests/serve_large_cwd.rs b/crates/codegraph-cli/tests/serve_large_cwd.rs new file mode 100644 index 00000000..a9f3c54a --- /dev/null +++ b/crates/codegraph-cli/tests/serve_large_cwd.rs @@ -0,0 +1,130 @@ +//! A stdio server launched in a large unindexed workspace answers promptly. +//! +//! With no index at or above the launch directory and a `.git` there, `serve +//! --mcp` searches downward for indexed sub-projects. That search is bounded +//! by a counted entry budget and a deadline, and the server's own constructor +//! no longer runs it before `initialize`, so a directory like `/tmp` with tens +//! of thousands of entries cannot keep the handshake waiting. + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +const ANSWER_WITHIN: Duration = Duration::from_secs(2); + +struct TestDir(PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +/// `.git` plus 10,000 directories, two levels deep, none of them indexed. +fn large_workspace() -> TestDir { + let root = std::env::temp_dir().join(format!( + "codegraph-serve-large-cwd-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join(".git")).unwrap(); + for outer in 0..100 { + for inner in 0..100 { + fs::create_dir_all(root.join(format!("pkg{outer:03}/mod{inner:03}"))).unwrap(); + } + } + TestDir(root) +} + +fn read_id(reader: &mut impl BufRead, want: i64) -> Value { + loop { + let mut line = String::new(); + let read = reader.read_line(&mut line).expect("read serve stdout"); + assert!(read > 0, "serve closed stdout before response {want}"); + let value: Value = serde_json::from_str(line.trim()).expect("JSON-RPC on stdout"); + if value.get("id").and_then(Value::as_i64) == Some(want) { + return value; + } + } +} + +fn answered_within( + stdin: &mut impl Write, + stdout: &mut impl BufRead, + id: i64, + request: &Value, +) -> (Value, Duration) { + writeln!(stdin, "{request}").unwrap(); + stdin.flush().unwrap(); + let started = Instant::now(); + let response = read_id(stdout, id); + (response, started.elapsed()) +} + +#[test] +fn initialize_and_tools_list_answer_promptly_in_a_large_unindexed_workspace() { + let workspace = large_workspace(); + let registry = workspace.0.with_extension("registry"); + let mut child = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(["serve", "--mcp"]) + .current_dir(&workspace.0) + .env("CODEGRAPH_MCP_REGISTRY_DIR", ®istry) + .env_remove("CODEGRAPH_DIR") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn serve --mcp"); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let launched = Instant::now(); + + let (init, init_latency) = answered_within( + &mut stdin, + &mut stdout, + 1, + &json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "serve-large-cwd", "version": "0" } + } + }), + ); + assert_eq!(init["result"]["serverInfo"]["name"], "codegraph", "{init}"); + assert!( + launched.elapsed() < ANSWER_WITHIN, + "initialize took {:?} from launch (request to answer {init_latency:?})", + launched.elapsed() + ); + writeln!( + stdin, + "{}", + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }) + ) + .unwrap(); + + let (list, list_latency) = answered_within( + &mut stdin, + &mut stdout, + 2, + &json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }), + ); + assert!(list["result"]["tools"].is_array(), "{list}"); + assert!( + list_latency < ANSWER_WITHIN, + "tools/list, which runs the bounded downward scan, took {list_latency:?}" + ); + + drop(stdin); + let _ = child.wait(); + let _ = fs::remove_dir_all(Path::new(®istry)); +} diff --git a/crates/codegraph-cli/tests/static_member_reads_impact.rs b/crates/codegraph-cli/tests/static_member_reads_impact.rs new file mode 100644 index 00000000..87d43c49 --- /dev/null +++ b/crates/codegraph-cli/tests/static_member_reads_impact.rs @@ -0,0 +1,114 @@ +//! A type used only through a static field or enum value has its readers as +//! dependents (G4, upstream `extraction.test.ts` "links a type referenced only +//! via a static field / enum value"), and a read never links a same-named +//! type of another language family. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-cli-static-member-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn cli(args: &[&str]) -> (String, String, bool) { + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(args) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .output() + .expect("run codegraph binary"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.success(), + ) +} + +/// The files of the symbols `impact` reports for `symbol`. +fn impacted_files(project: &Path, symbol: &str) -> Vec { + let p = project.to_str().unwrap(); + let (out, err, ok) = cli(&["init", p]); + assert!(ok, "init failed: stdout={out} stderr={err}"); + let (out, err, ok) = cli(&["index", "--force", p]); + assert!(ok, "index --force failed: stdout={out} stderr={err}"); + let (stdout, err, ok) = cli(&["impact", symbol, "-p", p, "--depth", "3", "--json"]); + assert!(ok, "impact failed: stdout={stdout} stderr={err}"); + let value: serde_json::Value = + serde_json::from_str(&stdout).expect("impact emits valid JSON on stdout"); + value["affected"] + .as_array() + .expect("affected is an array") + .iter() + .filter_map(|node| node["filePath"].as_str().map(str::to_string)) + .collect() +} + +#[test] +fn a_type_read_only_through_a_static_field_has_its_reader_as_dependent() { + let dir = TestDir::new("java"); + let project = dir.path().join("java"); + fs::create_dir_all(&project).unwrap(); + fs::write( + project.join("JsonScope.java"), + "class JsonScope {\n static final int EMPTY_DOCUMENT = 1;\n}\n", + ) + .unwrap(); + fs::write( + project.join("Reader.java"), + "class Reader {\n private int helper;\n int peek() {\n return JsonScope.EMPTY_DOCUMENT;\n }\n int noop() {\n return this.helper;\n }\n}\n", + ) + .unwrap(); + let files = impacted_files(&project, "JsonScope"); + assert!( + files.iter().any(|path| path.ends_with("Reader.java")), + "{files:?}" + ); +} + +#[test] +fn a_static_member_read_never_links_another_language_family() { + let dir = TestDir::new("cross"); + let project = dir.path().join("cross"); + fs::create_dir_all(&project).unwrap(); + fs::write( + project.join("Build.ts"), + "export class Build {\n static version = 1;\n}\n", + ) + .unwrap(); + fs::write( + project.join("Device.kt"), + "package app\nclass Device {\n fun sdk(): Int = Build.VERSION\n}\n", + ) + .unwrap(); + let files = impacted_files(&project, "Build"); + assert!( + !files.iter().any(|path| path.ends_with("Device.kt")), + "{files:?}" + ); +} diff --git a/crates/codegraph-cli/tests/status_file_errors.rs b/crates/codegraph-cli/tests/status_file_errors.rs new file mode 100644 index 00000000..9112a1b3 --- /dev/null +++ b/crates/codegraph-cli/tests/status_file_errors.rs @@ -0,0 +1,208 @@ +//! `status` reports indexed files whose symbols are missing although their +//! content is current (upstream #2336), and `files --json` carries each file's +//! recorded errors. +//! +//! A content-hash comparison calls such a file up to date, so before this +//! report `status` said "Index is up to date" while a file contributed nothing +//! to the graph. Two groups are reported: +//! +//! - files needing a re-index: rows stored with no nodes and no recorded +//! reason, which a hash-based `sync` never revisits; +//! - files with parse errors: a recorded parse failure and no symbols from it. +//! +//! A file over the size limit and a file-level-only language are empty on +//! purpose and are in neither group. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-status-file-errors-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn run(args: &[&str], project: &Path) -> (String, String) { + let output = Command::new(bin()) + .args(args) + .arg(project) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env_remove("CODEGRAPH_DIR") + .output() + .expect("run codegraph"); + assert!( + output.status.success(), + "codegraph {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +/// A project with one healthy file, one whose parse collapses to no symbols, +/// one over the size limit, and a YAML file (a file-level-only language). +fn project(dir: &TestDir) -> PathBuf { + let project = dir.0.join("project"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::create_dir_all(project.join(".codegraph")).unwrap(); + fs::write( + project.join(".codegraph/config.toml"), + "[app]\nname = \"status-file-errors\"\n\n[indexing]\nmax_file_size = 4096\n", + ) + .unwrap(); + fs::write( + project.join("src/healthy.ts"), + "export function healthySymbol(): number { return 1; }\n", + ) + .unwrap(); + fs::write(project.join("src/broken.ts"), "@@@ ### ((( === }}} <<<\n").unwrap(); + fs::write( + project.join("src/huge.ts"), + "export const filler = 1;\n".repeat(400), + ) + .unwrap(); + fs::write(project.join("settings.yaml"), "name: status\n").unwrap(); + run(&["init"], &project); + project +} + +/// Store `src/healthy.ts` the way a wiped row looks: no nodes, no error, and +/// the content hash still current. +fn wipe_row(project: &Path) { + let db = project.join(".codegraph/codegraph.db"); + let conn = rusqlite::Connection::open(&db).unwrap(); + let updated = conn + .execute( + "UPDATE files SET node_count = 0 WHERE path = 'src/healthy.ts'", + [], + ) + .unwrap(); + assert_eq!(updated, 1); + conn.close().unwrap(); + for sidecar in ["-wal", "-shm"] { + let path = PathBuf::from(format!("{}{sidecar}", db.display())); + assert!( + !path.exists(), + "closing the test's connection left {sidecar} behind" + ); + } +} + +#[test] +fn status_json_counts_files_missing_their_symbols() { + let dir = TestDir::new("json"); + let project = project(&dir); + + let (stdout, _) = run(&["status", "--json"], &project); + let status: Value = serde_json::from_str(&stdout).expect("status JSON"); + assert_eq!(status["index"]["filesNeedingReindex"], 0, "{status}"); + assert_eq!( + status["index"]["filesWithParseErrors"], 1, + "the collapsed parse is reported: {status}" + ); + + wipe_row(&project); + let (stdout, _) = run(&["status", "--json"], &project); + let status: Value = serde_json::from_str(&stdout).expect("status JSON"); + assert_eq!( + status["index"]["filesNeedingReindex"], 1, + "the wiped row is reported: {status}" + ); + assert_eq!(status["index"]["filesWithParseErrors"], 1, "{status}"); + for key in [ + "builtWithVersion", + "builtWithExtractionVersion", + "currentExtractionVersion", + "reindexRecommended", + ] { + assert!( + status["index"].get(key).is_some(), + "the existing `index.{key}` field stays: {status}" + ); + } +} + +#[test] +fn status_text_names_the_files_missing_their_symbols() { + let dir = TestDir::new("text"); + let project = project(&dir); + wipe_row(&project); + + let (stdout, _) = run(&["status"], &project); + assert!( + stdout.contains("1 file is missing its symbols") && stdout.contains("src/healthy.ts"), + "the wiped row is named with what to do: {stdout}" + ); + assert!( + stdout.contains("1 file could not be parsed") && stdout.contains("src/broken.ts"), + "the collapsed parse is named: {stdout}" + ); + assert!( + !stdout.contains("src/huge.ts") && !stdout.contains("settings.yaml"), + "files that are empty on purpose are not reported: {stdout}" + ); + assert!( + !stdout.contains("Index is up to date"), + "an index with files missing their symbols is not up to date: {stdout}" + ); +} + +#[test] +fn files_json_carries_each_files_recorded_errors() { + let dir = TestDir::new("files"); + let project = project(&dir); + + let (stdout, _) = run(&["files", "--json", "-p"], &project); + let files: Vec = serde_json::from_str(&stdout).expect("files JSON"); + let errors_of = |path: &str| { + files + .iter() + .find(|file| file["path"] == path) + .unwrap_or_else(|| panic!("{path} is listed: {stdout}"))["errors"] + .clone() + }; + assert_eq!(errors_of("src/healthy.ts"), serde_json::json!([])); + let broken = errors_of("src/broken.ts"); + assert!( + broken + .as_array() + .is_some_and(|errors| errors.iter().any(|e| e + .as_str() + .is_some_and(|e| e.contains("parse produced no symbols")))), + "the parse failure is listed: {broken}" + ); + let huge = errors_of("src/huge.ts"); + assert!( + huge.as_array().is_some_and(|errors| errors + .iter() + .any(|e| e.as_str().is_some_and(|e| e.contains("exceeds max size")))), + "the size skip is listed: {huge}" + ); +} diff --git a/crates/codegraph-cli/tests/sync_convergence/cases.rs b/crates/codegraph-cli/tests/sync_convergence/cases.rs new file mode 100644 index 00000000..76124a08 --- /dev/null +++ b/crates/codegraph-cli/tests/sync_convergence/cases.rs @@ -0,0 +1,12639 @@ +//! The convergence cases, one function per case; `main.rs` registers each in +//! its `matrix!` list. Fixtures stay minimal: two or three files that hold one +//! cross-file edge the edit has to keep, move or drop. +//! +//! Selectors use the probe notation (`tests/probes/support.rs`). An +//! `edge_before`/`edge_after` expectation proves that the `before` index or a +//! fresh index of the final tree holds the relationship the case is about. + +use std::sync::LazyLock; + +use codegraph_core::types::Language; + +use crate::{Case, Change}; + +// ----- a file appears that satisfies an existing import -------------------- + +pub fn typescript_named_import_of_an_added_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "src/main.ts", + "import { helper } from './util';\n\nexport function main(): number {\n return helper();\n}\n", + ) + .write( + "src/util.ts", + "export function helper(): number {\n return 1;\n}\n", + ) + .edge_after("function main", "calls", "function helper @src/util.ts") + .edge_after("file:src/main.ts", "imports", "function helper") +} + +pub fn typescript_default_import_of_an_added_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "src/main.ts", + "import Widget from './widget';\n\nexport function build(): Widget {\n return new Widget();\n}\n", + ) + .write("src/widget.ts", "export default class Widget {}\n") + .edge_after("file:src/main.ts", "imports", "file:src/widget.ts") + .edge_after("function build", "instantiates", "class Widget") +} + +/// Upstream #2450: a reference through an aliased import binding links its +/// module only on a fresh index. +pub fn typescript_aliased_named_import_of_an_added_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "src/main.ts", + "import { Widget as Wrapper } from './widget';\n\nexport function build(): Wrapper {\n return new Wrapper();\n}\n", + ) + .write("src/widget.ts", "export class Widget {}\n") + .edge_after("file:src/main.ts", "imports", "class Widget") + .edge_after("function build", "instantiates", "class Widget") +} + +/// Upstream #2450: an edit that gives the module the name an aliased binding +/// imports links the binding and its uses, which are written under the +/// alias. +pub fn typescript_aliased_named_import_gains_its_export() -> Case { + Case::new(Change::RenameSymbol, &[Language::TypeScript]) + .file( + "src/main.ts", + "import { Widget as Wrapper } from './widget';\n\nexport function build(): Wrapper {\n return new Wrapper();\n}\n", + ) + .file("src/widget.ts", "export class Placeholder {}\n") + .write("src/widget.ts", "export class Widget {}\n") + .edge_after("file:src/main.ts", "imports", "class Widget") + .edge_after("function build", "instantiates", "class Widget") +} + +/// Upstream #2450 (luci-go's milo): the same through a tsconfig alias, the +/// binding rendered as a JSX tag. +pub fn tsx_aliased_component_import_gains_its_export() -> Case { + Case::new(Change::RenameSymbol, &[Language::Tsx]) + .file( + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@/*\":[\"src/*\"]}}}", + ) + .file( + "src/pages/groups_page.test.tsx", + "import { Component as GroupsPageWrapper } from '@/pages/groups_page';\nexport const page = ;\n", + ) + .file( + "src/pages/groups_page.tsx", + "export function Placeholder() { return null; }\n", + ) + .write( + "src/pages/groups_page.tsx", + "export function Component() { return null; }\n", + ) + .edge_after( + "file:src/pages/groups_page.test.tsx", + "imports", + "function Component", + ) + .edge_after("constant page", "references", "function Component") +} + +/// Upstream #2450, found by W5-03's plan gate: while the module declares no +/// `Widget`, the binding `W` is linked by its name to a `W` elsewhere; once +/// the module gains `Widget`, the binding and its use leave that namesake. +pub fn typescript_aliased_named_import_leaves_a_namesake_when_its_module_gains_the_export() -> Case +{ + Case::new(Change::RenameSymbol, &[Language::TypeScript]) + .file( + "src/draw.ts", + "import { Widget as W } from './widgets/Widget';\n\nexport function paint() {\n return new W();\n}\n", + ) + .file("src/widgets/Widget.ts", "export class Placeholder {}\n") + .file("src/other.ts", "export class W {}\n") + .edge_before("function paint", "instantiates", "class W") + .write("src/widgets/Widget.ts", "export class Widget {}\n") + .edge_after("function paint", "instantiates", "class Widget") + .no_edge_after("function paint", "instantiates", "class W") +} + +/// Upstream #2450, found by W5-03's plan gate: the binding `W` is matched by +/// its name to the module's own `W` and its use to a `class W` elsewhere; +/// once the module gains `Widget`, both reach it. +pub fn typescript_aliased_named_import_leaves_an_in_module_namesake_when_its_module_gains_the_export() +-> Case { + Case::new(Change::RenameSymbol, &[Language::TypeScript]) + .file( + "src/draw.ts", + "import { Widget as W } from './mod'; + +export function paint() { + return new W(); +} +", + ) + .file( + "src/mod.ts", + "export const W = 1; +export class Placeholder {} +", + ) + .file( + "src/other.ts", + "export class W {} +", + ) + .edge_before("function paint", "instantiates", "class W") + .write( + "src/mod.ts", + "export const W = 1; +export class Widget {} +", + ) + .edge_after("file:src/draw.ts", "imports", "class Widget") + .edge_after("function paint", "instantiates", "class Widget") + .no_edge_after("function paint", "instantiates", "class W") +} + +/// Upstream #2450, the other way: the module renames the declaration the +/// aliased binding imports, and the binding's uses leave it. +pub fn typescript_aliased_named_import_loses_its_export() -> Case { + Case::new(Change::RenameSymbol, &[Language::TypeScript]) + .file( + "src/main.ts", + "import { Widget as Wrapper } from './widget';\n\nexport function build(): Wrapper {\n return new Wrapper();\n}\n", + ) + .file("src/widget.ts", "export class Widget {}\n") + .edge_before("function build", "instantiates", "class Widget") + .write("src/widget.ts", "export class Gadget {}\n") + .no_edge_after("function build", "instantiates", "class Gadget") +} + +pub fn tsx_component_import_of_an_added_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::Tsx]) + .file( + "src/App.tsx", + "import { Button } from './Button';\n\nexport function App() {\n return ;\n}\n", + ) + .edge_after("function App", "references", "function Button") +} + +/// Upstream #2392 (3): a failed `from pkg.mod import` is not retried when +/// `pkg/mod.py` appears. +pub fn python_from_import_of_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::Python]) + .file( + "main.py", + "from pkg.mod import helper\n\n\ndef main():\n return helper()\n", + ) + .write("pkg/mod.py", "def helper():\n return 1\n") + .edge_after("file:main.py", "imports", "file:pkg/mod.py") + .edge_after("function main", "calls", "function helper @pkg/mod.py") +} + +/// Upstream #2392 (3): a failed PHP include is retried when the file it names +/// appears. +pub fn php_include_of_an_added_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::Php]) + .file( + "index.php", + " Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/a.dart", "import 'b.dart';\n\nvoid main() {}\n") + .write("lib/b.dart", "class B {}\n") + .edge_after("file:lib/a.dart", "imports", "file:lib/b.dart") +} + +/// Upstream #2392 (3): a C# `using` links the namespace a later file declares. +pub fn csharp_using_of_an_added_namespace() -> Case { + Case::new(Change::AddImportTarget, &[Language::CSharp]) + .file( + "src/Svc.cs", + "using App.Models;\nnamespace App { class Svc {} }\n", + ) + .write( + "src/Models.cs", + "namespace App.Models { public class User {} }\n", + ) + // A `using` outside a block namespace is its file's (upstream #2220). + .edge_after("file:src/Svc.cs", "imports", "namespace App.Models") +} + +/// Upstream #2392 (3): a Java import links the class a later file declares. +pub fn java_import_of_an_added_class() -> Case { + Case::new(Change::AddImportTarget, &[Language::Java]) + .file( + "src/app/Main.java", + "package app;\nimport app.util.Util;\nclass Main { void run() { Util.go(); } }\n", + ) + .write( + "src/app/util/Util.java", + "package app.util;\npublic class Util { public static void go() {} }\n", + ) + .edge_after( + "namespace app @src/app/Main.java", + "imports", + "class app.util::Util", + ) +} + +/// Upstream #2392 (2): a Lua `require` reaches a module file or nothing, so a +/// require of a module that appears later parks and links it (it used to bind +/// the local it is assigned to, which no sync retries). +pub fn lua_require_of_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::Lua]) + .file( + "main.lua", + "local util = require('util')\n\nlocal function run()\n return util.clamp(5, 0, 3)\nend\n\nreturn run\n", + ) + .write( + "util.lua", + "local M = {}\n\nfunction M.clamp(value, low, high)\n return math.max(low, math.min(high, value))\nend\n\nreturn M\n", + ) + .edge_after("file:main.lua", "imports", "file:util.lua") +} + +/// Upstream #2263 and #2392 (1): a side-effect import names a file, so one +/// that names no file yet parks and links it when it appears (it used to bind +/// its own import statement). +pub fn typescript_side_effect_import_of_an_added_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "src/main.ts", + "import './polyfill';\n\nexport function start() {\n return 1;\n}\n", + ) + .write("src/polyfill.ts", "export const ready = true;\n") + .edge_after("file:src/main.ts", "imports", "file:src/polyfill.ts") +} + +/// Upstream #2120: a bare call to a name imported from a package outside the +/// repository never binds the project function of that name that appears +/// later, while a file that imports the project's own does. +pub fn typescript_out_of_repo_call_ignores_an_added_namesake() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "package.json", + "{\"name\":\"app\",\"dependencies\":{\"vitest\":\"*\"}}\n", + ) + .file( + "src/use.ts", + "import { test } from './helpers';\n\nexport function run() {\n return test('a');\n}\n", + ) + .file( + "src/a.test.ts", + "import { test } from 'vitest';\n\ntest('works');\n", + ) + .write( + "src/helpers.ts", + "export function test(name: string) {\n return name;\n}\n", + ) + .edge_after("function run", "calls", "function test @src/helpers.ts") + .no_edge_after("file:src/a.test.ts", "calls", "function test") +} + +/// Upstream #2136: a `super` call inside an override is the parent's, never a +/// self-call, before and after the override's body changes. +pub fn java_super_call_in_an_override_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Java]) + .file( + "src/app/Base.java", + "package app;\n\npublic class Base {\n public void start() {\n }\n}\n", + ) + .file( + "src/app/Service.java", + "package app;\n\npublic class Service extends Base {\n @Override\n public void start() {\n super.start();\n }\n\n void log() {\n }\n}\n", + ) + .write( + "src/app/Service.java", + "package app;\n\npublic class Service extends Base {\n @Override\n public void start() {\n super.start();\n log();\n }\n\n void log() {\n }\n}\n", + ) + .edge_after("method app::Service::start", "calls", "method app::Service::log") + .no_edge_after( + "method app::Service::start", + "calls", + "method app::Service::start", + ) +} + +/// Upstream #2139: a Java `@Test` never decorates a `test()` method, whose +/// name differs only in case, before or after the annotated method's body +/// changes; an annotation the project declares keeps its edge. +pub fn java_annotation_ignores_a_case_variant_method_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Java]) + .file( + "src/app/CharPredicate.java", + "package app;\n\npublic interface CharPredicate {\n boolean test(char c);\n}\n", + ) + .file( + "src/app/Marker.java", + "package app;\n\npublic @interface Marker {\n}\n", + ) + .file( + "src/app/ParserTest.java", + "package app;\n\nimport org.junit.jupiter.api.Test;\n\npublic class ParserTest {\n @Test\n @Marker\n void parses() {\n }\n}\n", + ) + .write( + "src/app/ParserTest.java", + "package app;\n\nimport org.junit.jupiter.api.Test;\n\npublic class ParserTest {\n @Test\n @Marker\n void parses() {\n int checked = 1;\n }\n}\n", + ) + .edge_throughout("method app::ParserTest::parses", "decorates", "interface app::Marker") + .no_edge_after( + "method app::ParserTest::parses", + "decorates", + "method app::CharPredicate::test", + ) +} + +/// Upstream #2166: `Object.assign(…)` stays the platform's when a project +/// class with an `assign` method appears; a call written on that class is the +/// project's. +pub fn typescript_outside_type_call_ignores_an_added_method() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "src/merge.ts", + "export function merge(a: unknown) {\n return Object.assign(a);\n}\n", + ) + .file( + "src/use.ts", + "import { ObjectUtils } from './ObjectUtils';\n\nexport function use(a: unknown) {\n return ObjectUtils.assign(a);\n}\n", + ) + .write( + "src/ObjectUtils.ts", + "export class ObjectUtils {\n static assign(a: unknown) {\n return a;\n }\n}\n", + ) + .edge_after("function use", "calls", "method ObjectUtils::assign") + .no_edge_after("function merge", "calls", "method ObjectUtils::assign") +} + +/// Upstream #2169: a C++ receiver declared as one of the project's class +/// templates (`SkipList list_`) keeps its method, before and after +/// the body changes. +pub fn cpp_class_template_receiver_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "db/skiplist.h", + "namespace leveldb {\ntemplate \nclass SkipList {\n public:\n void Insert(const Key& key) {}\n};\n} // namespace leveldb\n", + ) + .file( + "db/writer.cc", + "#include \"db/skiplist.h\"\nnamespace leveldb {\nclass Writer {\n SkipList list_;\n\n public:\n void WriteStep() { list_.Insert(1); }\n};\n} // namespace leveldb\n", + ) + .write( + "db/writer.cc", + "#include \"db/skiplist.h\"\nnamespace leveldb {\nclass Writer {\n SkipList list_;\n\n public:\n void WriteStep() { list_.Insert(2); }\n};\n} // namespace leveldb\n", + ) + .edge_throughout( + "method leveldb::Writer::WriteStep", + "calls", + "method leveldb::SkipList::Insert", + ) +} + +/// W3-05c (KEEP-RUST): a C# call on an outside type keeps reaching the +/// project's extension method for it, before and after the body changes. +pub fn csharp_extension_method_call_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::CSharp]) + .file( + "src/Utilities/TypeExtensions.cs", + "using System;\n\nnamespace App.Utilities\n{\n internal static class TypeExtensions\n {\n public static bool IsEnum(this Type type)\n {\n return false;\n }\n }\n}\n", + ) + .file( + "src/Reader.cs", + "using System;\nusing App.Utilities;\n\nnamespace App\n{\n public class Reader\n {\n public bool Check(Type objectType)\n {\n return objectType.IsEnum();\n }\n }\n}\n", + ) + .write( + "src/Reader.cs", + "using System;\nusing App.Utilities;\n\nnamespace App\n{\n public class Reader\n {\n public bool Check(Type objectType)\n {\n bool result = objectType.IsEnum();\n return result;\n }\n }\n}\n", + ) + .edge_throughout( + "method App::Reader::Check", + "calls", + "method App.Utilities::TypeExtensions::IsEnum", + ) +} + +/// W3-05c (KEEP-RUST): a static method that gains its `this` parameter, on +/// the same line, becomes the extension method an unchanged file's call on an +/// outside type reaches, as a fresh index of the edit finds. +pub fn csharp_method_becomes_an_extension_method() -> Case { + Case::new(Change::BodyEdit, &[Language::CSharp]) + .file( + "src/Utilities/TypeExtensions.cs", + "using System;\n\nnamespace App.Utilities\n{\n internal static class TypeExtensions\n {\n public static bool IsEnum(Type type)\n {\n return false;\n }\n }\n}\n", + ) + .file( + "src/Reader.cs", + "using System;\nusing App.Utilities;\n\nnamespace App\n{\n public class Reader\n {\n public bool Check(Type objectType)\n {\n return objectType.IsEnum();\n }\n }\n}\n", + ) + .write( + "src/Utilities/TypeExtensions.cs", + "using System;\n\nnamespace App.Utilities\n{\n internal static class TypeExtensions\n {\n public static bool IsEnum(this Type type)\n {\n return false;\n }\n }\n}\n", + ) + .edge_after( + "method App::Reader::Check", + "calls", + "method App.Utilities::TypeExtensions::IsEnum", + ) +} + +/// W3-05c (KEEP-RUST): of a C# extension method's overloads, only those +/// extending the receiver's type compete, type parameters renamed or not: an +/// added `Open(this Box, U)` takes `box.Open(1)` from the `params` +/// overload, while the plain `Open(object)` and `Open(this Exception, …)` +/// never do, as a fresh index of the edit finds. +pub fn csharp_extension_overload_of_a_renamed_type_parameter_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::CSharp]) + .file( + "src/Utilities/Boxes.cs", + "using System;\n\nnamespace App.Utilities\n{\n public class Box\n {\n public T Value;\n }\n\n public static class BoxExtensions\n {\n public static object Open(this Box box, params T[] values)\n {\n return box;\n }\n\n public static object Open(object value)\n {\n return value;\n }\n\n public static object Open(this Exception error, object value)\n {\n return error;\n }\n }\n}\n", + ) + .file( + "src/Reader.cs", + "using App.Utilities;\n\nnamespace App\n{\n public class Reader\n {\n public object Unbox(Box box)\n {\n return box.Open(1);\n }\n }\n}\n", + ) + .write( + "src/Utilities/Boxes.cs", + "using System;\n\nnamespace App.Utilities\n{\n public class Box\n {\n public T Value;\n }\n\n public static class BoxExtensions\n {\n public static object Open(this Box box, params T[] values)\n {\n return box;\n }\n\n public static object Open(this Box box, U value)\n {\n return box;\n }\n\n public static object Open(object value)\n {\n return value;\n }\n\n public static object Open(this Exception error, object value)\n {\n return error;\n }\n }\n}\n", + ) + .edge_before( + "method App::Reader::Unbox", + "calls", + "method App.Utilities::BoxExtensions::Open @src/Utilities/Boxes.cs:12", + ) + .edge_after( + "method App::Reader::Unbox", + "calls", + "method App.Utilities::BoxExtensions::Open @src/Utilities/Boxes.cs:17", + ) + .no_edge_after( + "method App::Reader::Unbox", + "calls", + "method App.Utilities::BoxExtensions::Open @src/Utilities/Boxes.cs:12", + ) +} + +/// W3-05c (KEEP-RUST): an added C# extension overload whose `where` clause +/// takes no receiver the current one takes (`Seal(this Box, U) where +/// U : class` beside `Seal(this Box, params T[]) where T : struct`) +/// never takes `box.Seal(1)` on a `Box`, as a fresh index of the edit +/// finds. +pub fn csharp_extension_overload_under_another_constraint_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::CSharp]) + .file( + "src/Utilities/Boxes.cs", + "namespace App.Utilities\n{\n public class Box\n {\n public T Value;\n }\n\n public static class BoxExtensions\n {\n public static object Seal(this Box box, params T[] values) where T : struct\n {\n return box;\n }\n }\n}\n", + ) + .file( + "src/Reader.cs", + "using App.Utilities;\n\nnamespace App\n{\n public class Reader\n {\n public object Close(Box box)\n {\n return box.Seal(1);\n }\n }\n}\n", + ) + .write( + "src/Utilities/Boxes.cs", + "namespace App.Utilities\n{\n public class Box\n {\n public T Value;\n }\n\n public static class BoxExtensions\n {\n public static object Seal(this Box box, params T[] values) where T : struct\n {\n return box;\n }\n\n public static object Seal(this Box box, U value) where U : class\n {\n return box;\n }\n }\n}\n", + ) + .edge_throughout( + "method App::Reader::Close", + "calls", + "method App.Utilities::BoxExtensions::Seal @src/Utilities/Boxes.cs:10", + ) + .no_edge_after( + "method App::Reader::Close", + "calls", + "method App.Utilities::BoxExtensions::Seal @src/Utilities/Boxes.cs:15", + ) +} + +/// W3-05c (KEEP-RUST): spacing and comments are no part of a C# extension +/// overload's receiver or constraints: an added `Wrap(this Box< T > /* … */ +/// box, T) where T : struct // …` takes `box.Wrap(1)` from +/// `Wrap(this Box, params T[]) where T: struct`, as a fresh index of +/// the edit finds. +pub fn csharp_extension_overload_spelled_with_other_spacing_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::CSharp]) + .file( + "src/Utilities/Boxes.cs", + "namespace App.Utilities\n{\n public class Box\n {\n public T Value;\n }\n\n public static class BoxExtensions\n {\n public static object Wrap(this Box box, params T[] values) where T: struct\n {\n return box;\n }\n }\n}\n", + ) + .file( + "src/Reader.cs", + "using App.Utilities;\n\nnamespace App\n{\n public class Reader\n {\n public object Fold(Box box)\n {\n return box.Wrap(1);\n }\n }\n}\n", + ) + .write( + "src/Utilities/Boxes.cs", + "namespace App.Utilities\n{\n public class Box\n {\n public T Value;\n }\n\n public static class BoxExtensions\n {\n public static object Wrap(this Box box, params T[] values) where T: struct\n {\n return box;\n }\n\n public static object Wrap(this Box< T > /* boxed */ box, T value) where T : struct // value types\n {\n return box;\n }\n }\n}\n", + ) + .edge_before( + "method App::Reader::Fold", + "calls", + "method App.Utilities::BoxExtensions::Wrap @src/Utilities/Boxes.cs:10", + ) + .edge_after( + "method App::Reader::Fold", + "calls", + "method App.Utilities::BoxExtensions::Wrap @src/Utilities/Boxes.cs:15", + ) + .no_edge_after( + "method App::Reader::Fold", + "calls", + "method App.Utilities::BoxExtensions::Wrap @src/Utilities/Boxes.cs:10", + ) +} + +/// W3-05c (KEEP-RUST): a comment marker inside a raw string default value is +/// text: an added `Note(this Box, U, string note = """x" // …""") where +/// U : struct` keeps its `where` clause and takes `box.Note(1)` from the +/// `params` overload, as a fresh index of the edit finds. +pub fn csharp_extension_overload_with_a_raw_string_default_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::CSharp]) + .file( + "src/Utilities/Boxes.cs", + "namespace App.Utilities\n{\n public class Box\n {\n public T Value;\n }\n\n public static class BoxExtensions\n {\n public static object Note(this Box box, params T[] values) where T : struct { return box; }\n }\n}\n", + ) + .file( + "src/Reader.cs", + "using App.Utilities;\n\nnamespace App\n{\n public class Reader\n {\n public object Jot(Box box)\n {\n return box.Note(1);\n }\n }\n}\n", + ) + .write( + "src/Utilities/Boxes.cs", + "namespace App.Utilities\n{\n public class Box\n {\n public T Value;\n }\n\n public static class BoxExtensions\n {\n public static object Note(this Box box, params T[] values) where T : struct { return box; }\n\n public static object Note(this Box box, U value, string note = \"\"\"x\" // literal text\"\"\") where U : struct { return box; }\n }\n}\n", + ) + .edge_before( + "method App::Reader::Jot", + "calls", + "method App.Utilities::BoxExtensions::Note @src/Utilities/Boxes.cs:10", + ) + .edge_after( + "method App::Reader::Jot", + "calls", + "method App.Utilities::BoxExtensions::Note @src/Utilities/Boxes.cs:12", + ) + .no_edge_after( + "method App::Reader::Jot", + "calls", + "method App.Utilities::BoxExtensions::Note @src/Utilities/Boxes.cs:10", + ) +} + +/// Upstream #2180: a call through a class name reaches the class method the +/// class inherits, before and after the calling body changes. +pub fn python_inherited_classmethod_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Python]) + .file( + "pkg/base.py", + "class Base:\n @classmethod\n def create(cls):\n return cls()\n", + ) + .file( + "pkg/user.py", + "from pkg.base import Base\n\n\nclass User(Base):\n pass\n", + ) + .file( + "pkg/service.py", + "from pkg.user import User\n\n\ndef make():\n return None\n", + ) + .write( + "pkg/service.py", + "from pkg.user import User\n\n\ndef make():\n return User.create()\n", + ) + .edge_after("function make", "calls", "method Base::create") +} + +/// W5-05a (upstream #1240's tail retry): a qualified call `User.create()` +/// parked before its class exists links once a new file declares the class, +/// as a fresh index links it; the whole-name retry never matches it. +pub fn python_class_method_of_an_added_class() -> Case { + Case::new(Change::AddImportTarget, &[Language::Python]) + .file("pkg/service.py", "def make():\n return User.create()\n") + .write( + "pkg/user.py", + "class User:\n @classmethod\n def create(cls):\n return cls()\n", + ) + .edge_after("function make", "calls", "method User::create") +} + +/// W5-05a: a call through a subclass (`User.create()`) reaches the +/// classmethod of a base that a new file declares later, as a fresh index +/// reaches it; the caller's file is unchanged and its import already resolved. +pub fn python_inherited_classmethod_of_an_added_base() -> Case { + Case::new(Change::AddImportTarget, &[Language::Python]) + .file( + "pkg/user.py", + "from pkg.base import Base\n\n\nclass User(Base):\n pass\n", + ) + .file( + "pkg/service.py", + "from pkg.user import User\n\n\ndef make():\n return User.create()\n", + ) + .write( + "pkg/base.py", + "class Base:\n @classmethod\n def create(cls):\n return cls()\n", + ) + .edge_after("function make", "calls", "method Base::create") +} + +/// Upstream #2209: a test suite's `Record` that appears later is never what +/// production code's `Record<…>` names; shipped test support stays in reach. +pub fn typescript_test_suite_namesake_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::TypeScript]) + .file( + "src/testing/stub.ts", + "export function stubConnection(): number {\n return 2;\n}\n", + ) + .file( + "src/columns.ts", + "export function toJson(values: Record): string {\n return JSON.stringify(values);\n}\n\nexport function connect(): number {\n return stubConnection();\n}\n", + ) + .write( + "test/functional/entity/Record.ts", + "export class Record {\n id = 0;\n}\n", + ) + .edge_throughout("function connect", "calls", "function stubConnection") + .no_edge_after("function toJson", "references", "class Record") +} + +/// Upstream #2212: a method delegating to another overload of itself +/// reaches that overload, before and after its body changes. +pub fn java_overload_delegation_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Java]) + .file( + "src/app/Instants.java", + "package app;\n\npublic class Instants {\n public static Object toInstant(Object instant) {\n return toInstant(instant, null);\n }\n\n public static Object toInstant(Object instant, Object fallback) {\n return instant;\n }\n}\n", + ) + .write( + "src/app/Instants.java", + "package app;\n\npublic class Instants {\n public static Object toInstant(Object instant) {\n return toInstant(instant, instant);\n }\n\n public static Object toInstant(Object instant, Object fallback) {\n return fallback;\n }\n}\n", + ) + .edge_after( + "method app::Instants::toInstant @src/app/Instants.java:4", + "calls", + "method app::Instants::toInstant @src/app/Instants.java:8", + ) + .no_edge_after( + "method app::Instants::toInstant @src/app/Instants.java:4", + "calls", + "method app::Instants::toInstant @src/app/Instants.java:4", + ) +} + +/// Upstream #2415: in a React app, a Python call never reaches a React hook +/// by the React resolver's rules, before or after its body changes; the +/// app's own call still does. +pub fn python_call_ignores_a_react_hook_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Python, Language::Tsx]) + .file( + "package.json", + "{\"name\":\"web\",\"dependencies\":{\"react\":\"^19.0.0\"}}\n", + ) + .file( + "src/hooks/useSession.tsx", + "export function useSession() {\n return null;\n}\n", + ) + .file( + "src/App.tsx", + "import { useSession } from './hooks/useSession';\n\nexport function App() {\n const session = useSession();\n return
      {String(session)}
      ;\n}\n", + ) + .file( + "backend/session.py", + "def handle():\n return useSession()\n", + ) + .write( + "backend/session.py", + "def handle():\n value = useSession()\n return value\n", + ) + .edge_throughout("function App", "calls", "function useSession") + .no_edge_after("function handle", "calls", "function useSession") +} + +/// Upstream #2123: a type parameter the function declares in an edit is +/// that parameter, never the project class it shares a name with. +pub fn typescript_type_parameter_added_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::TypeScript]) + .file("src/model.ts", "export class T {\n id = 0;\n}\n") + .file( + "src/wrap.ts", + "import { T } from './model';\n\nexport function wrap(x: T): T {\n return x;\n}\n", + ) + .write( + "src/wrap.ts", + "import { T } from './model';\n\nexport function wrap(x: T): T {\n return x;\n}\n", + ) + .edge_before("function wrap", "references", "class T @src/model.ts") + .no_edge_after("function wrap", "references", "class T @src/model.ts") +} + +/// `util.js` with `u` on its first line and, below it, a data array: on one +/// line of 5,000 characters of code (a minified script), or split over many. +fn util_script(minified: bool) -> String { + let separator = if minified { "" } else { "\n" }; + let data = format!("1,{separator}").repeat(2500); + format!("function u(e){{return e}}\nvar data=[{separator}{data}0];\n") +} + +static MINIFIED_UTIL: LazyLock = LazyLock::new(|| util_script(true)); +static READABLE_UTIL: LazyLock = LazyLock::new(|| util_script(false)); + +/// Upstream #2204: a script that stops being minified, every declaration +/// where it was, is reachable by name again, as a fresh index of it is. +pub fn javascript_minified_script_becomes_readable() -> Case { + Case::new(Change::BodyEdit, &[Language::JavaScript]) + .file( + "static/js/app.js", + "function init() {\n return u(\"x\");\n}\n", + ) + .file("static/js/util.js", MINIFIED_UTIL.as_str()) + .write("static/js/util.js", READABLE_UTIL.as_str()) + .edge_after("function init", "calls", "function u @static/js/util.js") +} + +/// Upstream #2204: a script that becomes minified, every declaration where +/// it was, is no longer reachable by name from another file. +pub fn javascript_readable_script_becomes_minified() -> Case { + Case::new(Change::BodyEdit, &[Language::JavaScript]) + .file( + "static/js/app.js", + "function init() {\n return u(\"x\");\n}\n", + ) + .file("static/js/util.js", READABLE_UTIL.as_str()) + .write("static/js/util.js", MINIFIED_UTIL.as_str()) + .edge_before("function init", "calls", "function u @static/js/util.js") + .no_edge_after("function init", "calls", "function u @static/js/util.js") +} + +/// Upstream #2219: a Python name an edit makes the function's own parameter +/// is that local's value, not the package's function of that name (the +/// same-file `Recorder.request` makes `request` a value candidate). +pub fn python_function_value_bound_by_an_added_parameter() -> Case { + Case::new(Change::BodyEdit, &[Language::Python]) + .file("app/api.py", "def request(method, url):\n return method, url\n") + .file( + "app/views.py", + "class Recorder:\n def request(self):\n return None\n\n\ndef view():\n return handle(request)\n", + ) + .write( + "app/views.py", + "class Recorder:\n def request(self):\n return None\n\n\ndef view(request):\n return handle(request)\n", + ) + .edge_before("function view", "references", "function request @app/api.py") + .no_edge_after("function view", "references", "function request @app/api.py") +} + +/// Upstream #2392 (3): a Go call through an imported package links once the +/// package appears. +pub fn go_import_of_an_added_package() -> Case { + Case::new(Change::AddImportTarget, &[Language::Go]) + .file("go.mod", "module example.com/shop\n\ngo 1.22\n") + .file( + "main.go", + "package main\n\nimport \"example.com/shop/util\"\n\nfunc main() { util.Go() }\n", + ) + .write("util/util.go", "package util\n\nfunc Go() {}\n") + .edge_after("function main", "calls", "function Go @util/util.go") +} + +/// Upstream #2392 (3): a Solidity import links the file it names once it appears. +pub fn solidity_import_of_an_added_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::Solidity]) + .file( + "contracts/Token.sol", + "pragma solidity ^0.8.0;\nimport \"./Lib.sol\";\ncontract Token {}\n", + ) + .write( + "contracts/Lib.sol", + "pragma solidity ^0.8.0;\nlibrary Lib {}\n", + ) + .edge_after( + "file:contracts/Token.sol", + "imports", + "file:contracts/Lib.sol", + ) +} + +/// Upstream #2128: a Svelte call imported through SvelteKit's `$lib` alias, +/// which no package.json declares, stays the project's and reaches the +/// module once it appears, while one from the framework's `$app/…` never +/// reaches a project namesake, as a fresh index of the edit finds. +pub fn svelte_lib_alias_call_reaches_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::Svelte, Language::TypeScript]) + .file( + "docs/package.json", + "{\"name\":\"docs\",\"devDependencies\":{\"@sveltejs/kit\":\"*\"}}\n", + ) + .file( + "docs/src/routes/Design.svelte", + "\n\n

      {ds}

      \n", + ) + .file( + "docs/src/lib/router.ts", + "export function goto(path: string) {\n return path;\n}\n", + ) + .write( + "docs/src/lib/design.ts", + "export function useDesignSystem() {\n return 1;\n}\n", + ) + .edge_after( + "component docs/src/routes/Design.svelte::Design", + "calls", + "function useDesignSystem", + ) + .no_edge_after( + "component docs/src/routes/Design.svelte::Design", + "calls", + "function goto", + ) +} + +/// Upstream #2128: a bare call in a Vue component follows the JS/TS rules: a +/// `t` destructured from a package's composable never reaches an interface's +/// `t` property that appears later, as a fresh index of the edit finds. +pub fn vue_bare_call_ignores_an_added_interface_property() -> Case { + Case::new(Change::AddCompetitor, &[Language::Vue, Language::TypeScript]) + .file( + "package.json", + "{\"name\":\"app\",\"dependencies\":{\"vue-i18n\":\"*\"}}\n", + ) + .file( + "src/Labels.vue", + "\n\n", + ) + .write( + "src/types/context.ts", + "export interface ProvidersContext {\n t: (key: string) => string;\n}\n", + ) + .no_edge_after("component src/Labels.vue::Labels", "calls", "ProvidersContext::t") +} + +/// Upstream #2162: an added ES module's unexported top-level function is +/// reached by its own module's call, never by another file's bare call of +/// its name, as a fresh index of the edit finds. +pub fn javascript_unexported_namesake_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::JavaScript]) + .file("package.json", "{\"name\":\"app\",\"type\":\"module\"}\n") + .file( + "src/use.js", + "export async function handle(event) {\n await resolve(event);\n}\n", + ) + .write( + "src/manifest.js", + "import { join } from 'node:path';\nfunction resolve(event) {\n return join(event);\n}\nexport function manifest() {\n return resolve('x');\n}\n", + ) + .edge_after("function manifest", "calls", "function resolve") + .no_edge_after("function handle", "calls", "function resolve") +} + +/// Upstream #2227: what an added Svelte component's instance script declares +/// is its own: another file's bare call of the name never reaches it, as a +/// fresh index of the edit finds. +pub fn svelte_instance_script_namesake_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Svelte, Language::TypeScript]) + .file( + "src/extensions/code-block.ts", + "export function active(state: unknown) {\n return formatLabel(state);\n}\n", + ) + .write( + "src/components/Widget.svelte", + "\n\n

      {label}

      \n", + ) + .edge_after( + "component src/components/Widget.svelte::Widget", + "calls", + "function formatLabel", + ) + .no_edge_after("function active", "calls", "function formatLabel") +} + +/// Upstream #2226: a JS function's parameter shadows a same-named function +/// added elsewhere in its file: `mixin(object, source)` calling `object(…)` +/// calls its parameter, as a fresh index of the edit finds. +pub fn javascript_parameter_shadows_an_added_function() -> Case { + Case::new(Change::AddCompetitor, &[Language::JavaScript]) + .file( + "lodash.js", + "function runInContext(context) {\n function mixin(object, source) {\n var result = object(source);\n return result;\n }\n return { mixin };\n}\n", + ) + .write( + "lodash.js", + "function runInContext(context) {\n var baseCreate = (function() {\n function object() {}\n return object;\n }());\n\n function mixin(object, source) {\n var result = object(source);\n return result;\n }\n return { baseCreate, mixin };\n}\n", + ) + .no_edge_after("runInContext::mixin", "calls", "runInContext::object") +} + +/// Upstream #2163: a call through a name destructured from a composable's +/// result reaches the function the composable returns once its module +/// appears, as a fresh index of the edit finds. +pub fn typescript_destructured_composable_call_reaches_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "src/pages/index.ts", + "import { useAuth } from '../hooks/useAuth';\nexport function go() {\n const { login } = useAuth();\n return login('ada');\n}\n", + ) + .write( + "src/hooks/useAuth.ts", + "export function useAuth() {\n function login(user: string) {\n return user;\n }\n return { login };\n}\n", + ) + .edge_after("function go", "calls", "function useAuth::login") +} + +/// Upstream #2357 (a): a module-scope destructuring declaration's initializer +/// call is the file's, and an edit to it moves the edge. +pub fn typescript_module_destructuring_call_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::TypeScript]) + .file( + "src/composables.ts", + "export function useFoo(n: number) {\n return { a: n };\n}\n\nexport function useBar(n: number) {\n return { a: n };\n}\n", + ) + .file( + "src/app.ts", + "import { useFoo, useBar } from './composables';\nconst { a } = useFoo(1);\nexport const value = a;\n", + ) + .write( + "src/app.ts", + "import { useFoo, useBar } from './composables';\nconst { a } = useBar(1);\nexport const value = a;\n", + ) + .edge_before("file:src/app.ts", "calls", "function useFoo") + .edge_after("file:src/app.ts", "calls", "function useBar") +} + +/// Upstream #2357 (d): a Vue page's own function, once added, shadows the +/// Nuxt auto-import of its name: the call that was the auto-import's (an +/// edge from the caller to itself) reaches the page's function, as a fresh +/// index of the edit finds. +pub fn vue_own_function_shadows_a_nuxt_auto_import() -> Case { + Case::new(Change::BodyEdit, &[Language::Vue, Language::TypeScript]) + .file( + "package.json", + "{\"name\":\"app\",\"dependencies\":{\"nuxt\":\"^3\",\"vue\":\"^3\"}}\n", + ) + .file("nuxt.config.ts", "export default defineNuxtConfig({});\n") + .file( + "utils/errors.ts", + "export function clearError(reset: boolean) {\n return reset;\n}\n", + ) + .file( + "pages/lists.vue", + "\n\n", + ) + .write( + "pages/lists.vue", + "\n\n", + ) + .edge_before("function retry", "calls", "function retry") + .edge_after("function retry", "calls", "function clearError @pages/lists.vue") + .no_edge_after("function retry", "calls", "function retry") +} + +/// Same-file value references (upstream #895) are extraction edges: a +/// namesake appearing in another file sends the unchanged reader file through +/// the whole-file re-resolution, which must keep `run`'s value reference to +/// its own `LIMIT_MAX`, as a fresh index of the edit has it. +pub fn typescript_value_reference_survives_an_added_namesake() -> Case { + Case::new(Change::AddCompetitor, &[Language::TypeScript]) + .file( + "src/a.ts", + "const LIMIT_MAX = 3;\nexport function run() {\n return LIMIT_MAX + 1;\n}\n", + ) + .write("src/b.ts", "export const LIMIT_MAX = 5;\n") + .edge_throughout("function run", "references", "constant LIMIT_MAX @src/a.ts") +} + +/// Upstream #2363: a classic script's call through a global namespace +/// reaches the member once the script defining `window.WS = {…}` appears, as +/// a fresh index of the edit finds. +pub fn javascript_global_namespace_member_appears() -> Case { + Case::new(Change::AddImportTarget, &[Language::JavaScript]) + .file("use.js", "function viaGlobal() { return WS.wsM(); }\n") + .write( + "ns.js", + "window.WS = { a() { return 1; }, wsM() { return 2; } };\n", + ) + .edge_after("function viaGlobal", "calls", "function window.WS::wsM") +} + +/// Upstream #2363: renaming a global namespace's member drops the call that +/// reached it from another script, as a fresh index of the edit finds. +pub fn javascript_global_namespace_member_renamed() -> Case { + Case::new(Change::RenameSymbol, &[Language::JavaScript]) + .file( + "ns.js", + "window.WS = { a() { return 1; }, wsM() { return 2; } };\n", + ) + .file("use.js", "function viaGlobal() { return WS.wsM(); }\n") + .write("ns.js", "window.WS = { renamed() { return 4; } };\n") + .edge_before("function viaGlobal", "calls", "function window.WS::wsM") + .no_edge_after("function viaGlobal", "calls", "function window.WS::renamed") +} + +/// Upstream #2363: a sibling's `this.render()` inside the methods of an +/// object hung on `window` follows an edit of the call to another member, as +/// a fresh index of the edit finds. +pub fn javascript_object_literal_this_call_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::JavaScript]) + .file( + "app.js", + "window.App = {\n init: function () { this.render(); },\n render: function () { return 1; },\n draw: function () { return 2; },\n};\n", + ) + .write( + "app.js", + "window.App = {\n init: function () { this.draw(); },\n render: function () { return 1; },\n draw: function () { return 2; },\n};\n", + ) + .edge_before("function window.App::init", "calls", "function window.App::render") + .edge_after("function window.App::init", "calls", "function window.App::draw") + .no_edge_after("function window.App::init", "calls", "function window.App::render") +} + +/// Upstream #2442: a JSX tag whose name the component comes to bind as a +/// parameter renders that local, so the edge to another file's component of +/// the name goes, as a fresh index of the edit finds. +pub fn tsx_tag_becomes_a_parameter_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Tsx]) + .file("package.json", "{\"dependencies\":{\"react\":\"^18.0.0\"}}\n") + .file( + "src/Widget.tsx", + "export function Widget() {\n return
      ;\n}\n", + ) + .file( + "src/Widgets.tsx", + "export function Widgets({ widgets }) {\n return
      {widgets.map((item, index) => )}
      ;\n}\n", + ) + .write( + "src/Widgets.tsx", + "export function Widgets({ widgets }) {\n return
      {widgets.map((Widget, index) => )}
      ;\n}\n", + ) + .edge_before("function Widgets", "references", "function Widget @src/Widget.tsx") + .no_edge_after("function Widgets", "references", "function Widget @src/Widget.tsx") +} + +/// Upstream #2418: an interface moved into a `declare module 'x'` body is +/// exported, so another file's type reference reaches it by name, as a fresh +/// index of the edit finds. +pub fn typescript_interface_moves_into_a_declare_module_body() -> Case { + Case::new(Change::BodyEdit, &[Language::TypeScript]) + .file( + "src/chart.registry.ts", + "export {};\ninterface PluginOptions {\n color?: string;\n}\n", + ) + .file( + "src/use.ts", + "export function pick(options: PluginOptions) {\n return options;\n}\n", + ) + .write( + "src/chart.registry.ts", + "export {};\ndeclare module 'chart.js' {\n interface PluginOptions {\n color?: string;\n }\n}\n", + ) + .edge_after("function pick", "references", "interface PluginOptions") +} + +/// Upstream #2140: an Options API method's `this.b()` follows an edit of +/// the call to another method of the component, as a fresh index of the +/// edit finds. +pub fn vue_options_api_this_call_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Vue]) + .file( + "src/Form.vue", + "\n\n", + ) + .write( + "src/Form.vue", + "\n\n", + ) + .edge_before("method Form::submit", "calls", "method Form::validate") + .edge_after("method Form::submit", "calls", "method Form::reset") + .no_edge_after("method Form::submit", "calls", "method Form::validate") +} + +/// Upstream #2357 (b): a call in a component's template reaches the +/// composable once its module appears, as a fresh index of the edit finds. +pub fn vue_template_call_reaches_an_added_composable() -> Case { + Case::new( + Change::AddImportTarget, + &[Language::Vue, Language::TypeScript], + ) + .file( + "package.json", + "{\"name\":\"nuxt-app\",\"dependencies\":{\"nuxt\":\"^4.0.0\"}}\n", + ) + .file( + "components/Link.vue", + "\n", + ) + .write( + "composables/useBar.ts", + "export function useBar(x: unknown) {\n return String(x);\n}\n", + ) + .edge_after( + "component components/Link.vue::Link", + "calls", + "function useBar", + ) +} + +/// Upstream #2177: `file.text()` on an untyped receiver is fetch's body +/// method, so a project class's `text` added later is never its target, as a +/// fresh index of the edit finds. +pub fn javascript_fetch_body_call_ignores_an_added_method() -> Case { + Case::new(Change::AddCompetitor, &[Language::JavaScript]) + .file( + "src/load.js", + "export async function load(file) {\n return file.text();\n}\n", + ) + .write( + "src/LazyFile.js", + "export class LazyFile {\n text() {\n return '';\n }\n}\n", + ) + .no_edge_after("function load", "calls", "method LazyFile::text") +} + +/// Upstream #2125: a bare Python call is no method, so a class with a +/// method of that name added later is never its target, as a fresh index of +/// the edit finds. +pub fn python_bare_call_ignores_an_added_method() -> Case { + Case::new(Change::AddCompetitor, &[Language::Python]) + .file( + "app/views.py", + "def bare_login():\n return login(\"x\")\n", + ) + .write( + "app/tests.py", + "class AuthTestCase:\n def login(self, name):\n return name\n", + ) + .no_edge_after("function bare_login", "calls", "method AuthTestCase::login") +} + +/// Upstream #2125: a name taken `from` a module outside the project is the +/// package's, so a project function of that name added later is never the +/// call's target, as a fresh index of the edit finds. +pub fn python_outside_import_ignores_an_added_function() -> Case { + Case::new(Change::AddCompetitor, &[Language::Python]) + .file( + "app/pages.py", + "from django.shortcuts import render\n\ndef page(request):\n return render(request, \"page.html\")\n", + ) + .write("app/tags.py", "def render(context):\n return context\n") + .no_edge_after("function page", "calls", "function render") +} + +/// Upstream #2198: a bare call to a name the function comes to bind itself +/// is that value, so the edge to another file's function of the name goes, +/// as a fresh index of the edit finds. +pub fn python_call_becomes_a_local_binding_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Python]) + .file("tests/test_testing.py", "def view(request):\n return request\n") + .file( + "tests/test_versioning.py", + "def test_version(request):\n response = view(request)\n return response\n", + ) + .write( + "tests/test_versioning.py", + "def test_version(request):\n view = MyView.as_view()\n response = view(request)\n return response\n", + ) + .edge_before("function test_version", "calls", "function view") + .no_edge_after("function test_version", "calls", "function view") +} + +/// Upstream #2247: a name a package star-imports from a module reaches that +/// module's class once the module appears, ahead of a test's namesake. +pub fn python_star_reexport_of_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::Python]) + .file("users/__init__.py", "") + .file("users/models/__init__.py", "from .users import *\n") + .file("extras/tests/test_tags.py", "class User:\n pass\n") + .file( + "core/views.py", + "from users.models import User\n\n\ndef make():\n return User()\n", + ) + .write("users/models/users.py", "class User:\n pass\n") + .edge_after( + "function make", + "instantiates", + "class User @users/models/users.py", + ) +} + +/// Upstream #2247: a package that comes to star-import a module re-exports +/// its class, so an unchanged importer's call binds it, as a fresh index of +/// the edit finds. +pub fn python_package_gains_a_star_reexport() -> Case { + Case::new(Change::ReexportEdit, &[Language::Python]) + .file("users/__init__.py", "") + .file("users/models/__init__.py", "") + .file("users/models/users.py", "class User:\n pass\n") + .file("extras/tests/test_tags.py", "class User:\n pass\n") + .file( + "core/views.py", + "from users.models import User\n\n\ndef make():\n return User()\n", + ) + .write("users/models/__init__.py", "from .users import *\n") + .edge_after( + "function make", + "instantiates", + "class User @users/models/users.py", + ) +} + +/// Upstream #2247: a package that stops star-importing a module no longer +/// re-exports its class, so an unchanged importer's import-bound call goes, +/// as a fresh index of the edit finds. +pub fn python_package_drops_a_star_reexport() -> Case { + Case::new(Change::ReexportEdit, &[Language::Python]) + .file("users/__init__.py", "") + .file("users/models/__init__.py", "from .users import *\n") + .file("users/models/users.py", "class User:\n pass\n") + .file("extras/tests/test_tags.py", "class User:\n pass\n") + .file( + "core/views.py", + "from users.models import User\n\n\ndef make():\n return User()\n", + ) + .write("users/models/__init__.py", "") + .edge_before( + "function make", + "instantiates", + "class User @users/models/users.py", + ) +} + +/// Upstream #2247: deleting the package module that re-exported a class +/// takes the import binding with it, as a fresh index of the edit finds. +pub fn python_reexporting_package_is_deleted() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Python]) + .file("users/__init__.py", "") + .file("users/models/__init__.py", "from .tokens import Token\n") + .file("users/models/tokens.py", "class Token:\n pass\n") + .file( + "alembic/versions/0001_tokens.py", + "class Token:\n pass\n", + ) + .file( + "core/views.py", + "from users.models import Token\n\n\ndef make():\n return Token()\n", + ) + .remove("users/models/__init__.py") + .edge_before( + "function make", + "instantiates", + "class Token @users/models/tokens.py", + ) +} + +/// Upstream #2247 and #2291 (c): a package's named re-export moves to +/// another module, written as a parenthesized list, and an unchanged +/// importer's call follows it. +pub fn python_named_reexport_moves_to_another_module() -> Case { + Case::new(Change::ReexportEdit, &[Language::Python]) + .file("users/__init__.py", "") + .file("users/models/__init__.py", "from .legacy import Token\n") + .file("users/models/legacy.py", "class Token:\n pass\n") + .file("users/models/tokens.py", "class Token:\n pass\n") + .file( + "core/views.py", + "from users.models import Token\n\n\ndef make():\n return Token()\n", + ) + .write( + "users/models/__init__.py", + "from .tokens import (\n Token, # moved()\n)\n", + ) + .edge_before( + "function make", + "instantiates", + "class Token @users/models/legacy.py", + ) + .edge_after( + "function make", + "instantiates", + "class Token @users/models/tokens.py", + ) + .no_edge_after( + "function make", + "instantiates", + "class Token @users/models/legacy.py", + ) +} + +/// Upstream #2248: a module member used through the imported module follows +/// a star re-export its package comes to make. +pub fn python_module_member_reexport_appears() -> Case { + Case::new(Change::ReexportEdit, &[Language::Python]) + .file("netbox/__init__.py", "") + .file("netbox/views/__init__.py", "") + .file("netbox/views/generic/__init__.py", "") + .file( + "netbox/views/generic/object_views.py", + "class ObjectEditView:\n pass\n", + ) + .file("extras/views.py", "class ObjectEditView:\n pass\n") + .file( + "dcim/views.py", + "from netbox.views import generic\n\n\nclass DeviceEditView(generic.ObjectEditView):\n pass\n", + ) + .write( + "netbox/views/generic/__init__.py", + "from .object_views import *\n", + ) + .edge_after( + "class DeviceEditView", + "extends", + "class ObjectEditView @netbox/views/generic/object_views.py", + ) +} + +/// Upstream #2291 (b): `alpha.foo.Client` through `import alpha.foo` is the +/// module's class once the module appears, so a method value on a subclass +/// reaches its method. +pub fn python_dotted_module_class_of_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::Python]) + .file("alpha/__init__.py", "") + .file( + "one.py", + "import alpha.foo\n\n\nclass Sub(alpha.foo.Client):\n pass\n\n\ndef go(pool, s: Sub):\n pool.submit(s.fetch)\n", + ) + .write( + "alpha/foo.py", + "class Client:\n def fetch(self):\n return 1\n", + ) + .edge_after("function go", "references", "method Client::fetch") +} + +/// Upstream #2290: `self.get_ip()` stays the class's own method when the +/// module the file imports a `get_ip` from appears, as a fresh index of the +/// edit finds. +pub fn python_self_call_ignores_an_added_import_target() -> Case { + Case::new(Change::AddImportTarget, &[Language::Python]) + .file("pkg/__init__.py", "") + .file( + "pkg/adapter.py", + "from pkg.httpkit import get_ip\n\n\nclass Adapter:\n def get_ip(self, request):\n return request\n\n def notify(self, request):\n return self.get_ip(request)\n", + ) + .write( + "pkg/httpkit.py", + "def get_ip(request):\n return \"1.2.3.4\"\n", + ) + .edge_after("method Adapter::notify", "calls", "method Adapter::get_ip") + .no_edge_after("method Adapter::notify", "calls", "function get_ip") +} + +/// The models the pytest fixture cases type parameters with. +const PYTHON_FIXTURE_MODELS: &str = "class Forum:\n def save(self):\n return self\n\n\nclass Topic:\n def save(self):\n return self\n\n\nclass Board:\n def pin(self):\n return self\n\n\nclass Stray:\n def pin(self):\n return self\n"; + +/// Upstream #2228: a conftest fixture that comes to return another class +/// retypes an unchanged test's parameter, as a fresh index of the edit finds. +pub fn python_fixture_returns_another_class() -> Case { + Case::new(Change::BodyEdit, &[Language::Python]) + .file("app/models.py", PYTHON_FIXTURE_MODELS) + .file( + "tests/conftest.py", + "import pytest\nfrom app.models import Forum, Topic\n\n\n@pytest.fixture\ndef account():\n return Forum()\n", + ) + .file( + "tests/test_forum.py", + "def test_forum(account):\n account.save()\n", + ) + .write( + "tests/conftest.py", + "import pytest\nfrom app.models import Forum, Topic\n\n\n@pytest.fixture\ndef account():\n return Topic()\n", + ) + .edge_before("function test_forum", "calls", "method Forum::save") + .edge_after("function test_forum", "calls", "method Topic::save") + .no_edge_after("function test_forum", "calls", "method Forum::save") +} + +/// Upstream #2228: a conftest that comes to list a fixture module in +/// `pytest_plugins` puts its fixtures in reach of an unchanged test. +pub fn python_conftest_plugs_a_fixture_module() -> Case { + Case::new(Change::BodyEdit, &[Language::Python]) + .file("app/models.py", PYTHON_FIXTURE_MODELS) + .file("tests/conftest.py", "import pytest\n") + .file( + "tests/fixtures/plugged.py", + "import pytest\nfrom app.models import Board\n\n\n@pytest.fixture\ndef pinned():\n return Board()\n", + ) + .file("tests/test_board.py", "def test_board(pinned):\n pinned.pin()\n") + .write( + "tests/conftest.py", + "import pytest\n\npytest_plugins = [\"tests.fixtures.plugged\"]\n", + ) + .edge_after("function test_board", "calls", "method Board::pin") +} + +/// Upstream #2228: a fixture module a conftest pulls in that comes to +/// return another class retypes an unchanged test's parameter. +pub fn python_plugged_fixture_returns_another_class() -> Case { + Case::new(Change::BodyEdit, &[Language::Python]) + .file("app/models.py", PYTHON_FIXTURE_MODELS) + .file("tests/conftest.py", "from tests.fixtures.plugged import * # noqa\n") + .file( + "tests/fixtures/plugged.py", + "import pytest\nfrom app.models import Board, Stray\n\n\n@pytest.fixture\ndef board():\n return Board()\n", + ) + .file("tests/test_board.py", "def test_board(board):\n board.pin()\n") + .write( + "tests/fixtures/plugged.py", + "import pytest\nfrom app.models import Board, Stray\n\n\n@pytest.fixture\ndef board():\n return Stray()\n", + ) + .edge_before("function test_board", "calls", "method Board::pin") + .edge_after("function test_board", "calls", "method Stray::pin") +} + +/// Upstream #2228: a fixture decorated through an alias (`from helpers +/// import fixture`, `@fixture`) in a module a conftest pulls in is a fixture +/// by its decorator alone, as on a fresh index; coming to return another +/// class, it retypes an unchanged test's parameter although the module never +/// names pytest. +pub fn python_aliased_plugged_fixture_returns_another_class() -> Case { + Case::new(Change::BodyEdit, &[Language::Python]) + .file("app/models.py", PYTHON_FIXTURE_MODELS) + .file("helpers.py", "from pytest import fixture\n") + .file("tests/conftest.py", "from tests.fixtures.plugged import * # noqa\n") + .file( + "tests/fixtures/plugged.py", + "from helpers import fixture\nfrom app.models import Board, Stray\n\n\n@fixture\ndef board():\n return Board()\n", + ) + .file("tests/test_board.py", "def test_board(board):\n board.pin()\n") + .write( + "tests/fixtures/plugged.py", + "from helpers import fixture\nfrom app.models import Board, Stray\n\n\n@fixture\ndef board():\n return Stray()\n", + ) + .edge_before("function test_board", "calls", "method Board::pin") + .edge_after("function test_board", "calls", "method Stray::pin") +} + +/// Upstream #2228: a closer conftest's fixture of the same name, added +/// later, is what an unchanged test below it takes. +pub fn python_closer_conftest_overrides_a_fixture() -> Case { + Case::new(Change::AddCompetitor, &[Language::Python]) + .file("app/models.py", PYTHON_FIXTURE_MODELS) + .file( + "tests/conftest.py", + "import pytest\nfrom app.models import Forum\n\n\n@pytest.fixture\ndef account():\n return Forum()\n", + ) + .file( + "tests/sub/test_sub.py", + "def test_sub(account):\n account.save()\n", + ) + .write( + "tests/sub/conftest.py", + "import pytest\nfrom app.models import Topic\n\n\n@pytest.fixture\ndef account():\n return Topic()\n", + ) + .edge_before("function test_sub", "calls", "method Forum::save") + .edge_after("function test_sub", "calls", "method Topic::save") +} + +/// Upstream #2175: a Django manager call that comes to build another model +/// types the local it binds. +pub fn python_django_manager_result_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Python]) + .file( + "app/models.py", + "class Site:\n def save(self):\n return self\n\n\nclass Rack:\n def save(self):\n return self\n", + ) + .file( + "tests/test_sites.py", + "from app.models import Rack, Site\n\n\ndef test_record():\n record = Site.objects.get(pk=1)\n record.save()\n", + ) + .write( + "tests/test_sites.py", + "from app.models import Rack, Site\n\n\ndef test_record():\n record = Rack.objects.get(pk=1)\n record.save()\n", + ) + .edge_before("function test_record", "calls", "method Site::save") + .edge_after("function test_record", "calls", "method Rack::save") +} + +/// The files every module-global case starts from: two classes with a +/// `fetch`, a module whose `conn` its `init()` assigns, and two importers +/// reading `fetch` through it, by the module and by the name. +fn python_global_files(case: Case, init: &'static str) -> Case { + case.file( + "store.py", + "class Store:\n def fetch(self, ids):\n return ids\n", + ) + .file( + "decoy.py", + "class Decoy:\n def fetch(self, ids):\n return []\n", + ) + .file("settings.py", init) + .file( + "consumer.py", + "import settings\n\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n", + ) + .file( + "direct.py", + "from settings import conn\n\n\ndef direct(pool):\n pool.submit(conn.fetch)\n", + ) +} + +/// Upstream #2291 (a): a module global its module comes to assign another +/// class takes that class's method for an unchanged importer's method value, +/// as a fresh index of the edit finds. +pub fn python_module_global_retyped_in_its_module() -> Case { + python_global_files( + Case::new(Change::BodyEdit, &[Language::Python]), + "from store import Store\nfrom decoy import Decoy\nconn = None\n\n\ndef init():\n global conn\n conn = Store()\n", + ) + .write( + "settings.py", + "from store import Store\nfrom decoy import Decoy\nconn = None\n\n\ndef init():\n global conn\n conn = Decoy()\n", + ) + .edge_before("function cb", "references", "method Store::fetch") + .edge_before("function direct", "references", "method Store::fetch") + .edge_after("function cb", "references", "method Decoy::fetch") + .edge_after("function direct", "references", "method Decoy::fetch") + .no_edge_after("function cb", "references", "method Store::fetch") +} + +/// Upstream #2291 (a): another module that comes to write a second class +/// into the global leaves its type with no shared declaration, so the +/// method values through it go, in its module and in an importer. +pub fn python_module_global_gains_an_external_write() -> Case { + python_global_files( + Case::new(Change::BodyEdit, &[Language::Python]), + "from store import Store\nconn = None\n\n\ndef init():\n global conn\n conn = Store()\n\n\ndef same_file(pool):\n pool.submit(conn.fetch)\n", + ) + .file( + "reset.py", + "import settings\nfrom decoy import Decoy\n\n\ndef reset():\n return Decoy\n", + ) + .write( + "reset.py", + "import settings\nfrom decoy import Decoy\n\n\ndef reset():\n settings.conn = Decoy()\n", + ) + .edge_before("function cb", "references", "method Store::fetch") + .edge_before("function same_file", "references", "method Store::fetch") + .no_edge_after("function cb", "references", "method Store::fetch") + .no_edge_after("function same_file", "references", "method Store::fetch") +} + +/// Upstream #2291 (a): a write the type could not use goes, so the global's +/// type is its module's again and an importer's method value binds it. +pub fn python_module_global_loses_an_unknown_write() -> Case { + python_global_files( + Case::new(Change::BodyEdit, &[Language::Python]), + "from store import Store\nconn = None\n\n\ndef init():\n global conn\n conn = Store()\n", + ) + .file( + "reset.py", + "import settings\n\n\ndef reset():\n settings.conn = make_conn()\n", + ) + .write("reset.py", "import settings\n\n\ndef reset():\n return None\n") + .edge_after("function cb", "references", "method Store::fetch") +} + +/// Upstream #2291 (a): deleting the module that wrote an unknown value into +/// the global restores its type for an importer's method value. +pub fn python_module_global_writer_is_deleted() -> Case { + python_global_files( + Case::new(Change::DeleteImportedModule, &[Language::Python]), + "from store import Store\nimport reset\nconn = None\n\n\ndef init():\n global conn\n conn = Store()\n", + ) + .file( + "reset.py", + "import settings\n\n\ndef reset():\n settings.conn = make_conn()\n", + ) + .remove("reset.py") + .edge_after("function cb", "references", "method Store::fetch") +} + +/// Upstream #2263: a CommonJS `require('./application')` imports that file once +/// it appears, though the binding is only ever used as a value. +pub fn javascript_require_imports_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::JavaScript]) + .file( + "lib/express.js", + "var proto = require('./application');\n\nfunction createApplication() {\n return Object.create(proto);\n}\n\nmodule.exports = createApplication;\n", + ) + .write( + "lib/application.js", + "var app = exports = module.exports = {};\napp.init = function init() {};\n", + ) + .edge_after("file:lib/express.js", "imports", "file:lib/application.js") +} + +/// Upstream #2189: `require('./utils').setCharset` is the module's +/// `setCharset` once the module appears, never its first export. +pub fn javascript_require_member_reaches_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::JavaScript]) + .file( + "lib/response.js", + "'use strict';\n\nvar setCharset = require('./utils').setCharset;\n\nfunction send(body) {\n return setCharset(body, 'utf-8');\n}\n\nmodule.exports = { send };\n", + ) + .write( + "lib/utils.js", + "'use strict';\n\nexports.normalizeType = function (type) {\n return type;\n};\n\nexports.setCharset = function setCharset(type, charset) {\n return type + charset;\n};\n", + ) + .edge_after("function send", "calls", "function setCharset") + .no_edge_after("function send", "calls", "function normalizeType") +} + +/// Upstream #2433: `require('./widget').default` of an ES module is its +/// default export once the module appears, under whatever local name. +pub fn typescript_require_default_of_an_added_es_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "src/main.ts", + "export function mount(): unknown {\n const Ctor = require('./widget').default;\n return new Ctor();\n}\n", + ) + .write( + "src/widget.ts", + "function helper(): number {\n return 1;\n}\n\nexport default class Widget {\n size = helper();\n}\n", + ) + .edge_after("function mount", "instantiates", "class Widget") +} + +/// Upstream #2192: through `export * as core`, a namespace import's +/// `z.core.safeParse()` reaches the namespaced module's function once that +/// module appears, as a fresh index of the edit finds. +pub fn typescript_namespace_reexport_member_reaches_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "src/external.ts", + "export * as core from './core';\n", + ) + .file( + "src/use.ts", + "import * as z from './external';\n\nexport function check() {\n return z.core.safeParse(1);\n}\n", + ) + .write( + "src/core.ts", + "export function safeParse(value: unknown) {\n return value;\n}\n", + ) + .edge_after("function check", "calls", "function safeParse") +} + +/// Two modules exporting the same function, for the re-export edit cases. +const JS_REEXPORT_A: &str = "export function safeParse(value: unknown) {\n return value;\n}\n"; +const JS_REEXPORT_B: &str = "export function safeParse(value: unknown) {\n return [value];\n}\n"; + +/// Upstream #2192: a namespace re-export that comes to name another module +/// moves an unchanged caller's member call there, although the barrel holds +/// no node either side of the edit. +pub fn typescript_namespace_reexport_names_another_module() -> Case { + Case::new(Change::ReexportEdit, &[Language::TypeScript]) + .file("src/a.ts", JS_REEXPORT_A) + .file("src/b.ts", JS_REEXPORT_B) + .file("src/external.ts", "export * as core from './a';\n") + .file( + "src/use.ts", + "import * as z from './external';\n\nexport function check() {\n return z.core.safeParse(1);\n}\n", + ) + .write("src/external.ts", "export * as core from './b';\n") + .edge_before("function check", "calls", "function safeParse @src/a.ts") + .edge_after("function check", "calls", "function safeParse @src/b.ts") +} + +/// A named re-export (`export { safeParse as parse } from …`) that comes to +/// name another module moves an unchanged importer's call there. +pub fn typescript_named_reexport_names_another_module() -> Case { + Case::new(Change::ReexportEdit, &[Language::TypeScript]) + .file("src/a.ts", JS_REEXPORT_A) + .file("src/b.ts", JS_REEXPORT_B) + .file("src/external.ts", "export { safeParse as parse } from './a';\n") + .file( + "src/use.ts", + "import { parse } from './external';\n\nexport function check() {\n return parse(1);\n}\n", + ) + .write("src/external.ts", "export { safeParse as parse } from './b';\n") + .edge_before("function check", "calls", "function safeParse @src/a.ts") + .edge_after("function check", "calls", "function safeParse @src/b.ts") +} + +/// A wildcard re-export behind a second barrel that comes to name another +/// module moves a caller importing the outer barrel. +pub fn typescript_nested_wildcard_reexport_names_another_module() -> Case { + Case::new(Change::ReexportEdit, &[Language::TypeScript]) + .file("src/a.ts", JS_REEXPORT_A) + .file("src/b.ts", JS_REEXPORT_B) + .file("src/inner.ts", "export * from './a';\n") + .file("src/index.ts", "export * from './inner';\n") + .file( + "src/use.ts", + "import { safeParse } from './index';\n\nexport function check() {\n return safeParse(1);\n}\n", + ) + .write("src/inner.ts", "export * from './b';\n") + .edge_before("function check", "calls", "function safeParse @src/a.ts") + .edge_after("function check", "calls", "function safeParse @src/b.ts") +} + +/// A local export clause (`export { impl as api }`) and an `export default` +/// statement that come to name another declaration move unchanged +/// importers, although every declaration keeps its node. +pub fn typescript_local_export_alias_names_another_declaration() -> Case { + Case::new(Change::ReexportEdit, &[Language::TypeScript]) + .file( + "src/api.ts", + "function impl() {\n return 1;\n}\n\nfunction other() {\n return 2;\n}\n\nexport { impl as api };\nexport default impl;\n", + ) + .file( + "src/use.ts", + "import run, { api } from './api';\n\nexport function check() {\n return api() + run();\n}\n", + ) + .write( + "src/api.ts", + "function impl() {\n return 1;\n}\n\nfunction other() {\n return 2;\n}\n\nexport { other as api };\nexport default other;\n", + ) + .edge_before("function check", "calls", "function impl") + .edge_after("function check", "calls", "function other") + .no_edge_after("function check", "calls", "function impl") +} + +/// A barrel that comes to re-export a name binds an unchanged importer's +/// call that resolved nowhere before. +pub fn typescript_barrel_gains_a_named_reexport() -> Case { + Case::new(Change::ReexportEdit, &[Language::TypeScript]) + .file("src/a.ts", JS_REEXPORT_A) + .file("src/external.ts", "export const version = 1;\n") + .file( + "src/use.ts", + "import { parse } from './external';\n\nexport function check() {\n return parse(1);\n}\n", + ) + .write( + "src/external.ts", + "export const version = 1;\nexport { safeParse as parse } from './a';\n", + ) + .edge_after("function check", "calls", "function safeParse @src/a.ts") +} + +/// Upstream #2162: a binding an export list stops naming is an unexported +/// module binding, out of another file's name match, although its node is +/// unchanged. +pub fn javascript_export_list_drops_a_binding() -> Case { + Case::new(Change::ReexportEdit, &[Language::JavaScript]) + .file( + "src/lib.js", + "function helper() {\n return 1;\n}\n\nexport { helper };\n", + ) + .file( + "src/use.js", + "function run() {\n return helper();\n}\n\nmodule.exports = run;\n", + ) + .write( + "src/lib.js", + "function helper() {\n return 1;\n}\n\nexport {};\n", + ) + .edge_before("function run", "calls", "function helper") +} + +/// Upstream #2162: a binding an export list comes to name is a module +/// export again, so another file's name match reaches it. +pub fn javascript_export_list_gains_a_binding() -> Case { + Case::new(Change::ReexportEdit, &[Language::JavaScript]) + .file( + "src/lib.js", + "function helper() {\n return 1;\n}\n\nexport {};\n", + ) + .file( + "src/use.js", + "function run() {\n return helper();\n}\n\nmodule.exports = run;\n", + ) + .write( + "src/lib.js", + "function helper() {\n return 1;\n}\n\nexport { helper };\n", + ) + .edge_after("function run", "calls", "function helper") +} + +/// Upstream #2189: `module.exports = require('./a')` that comes to forward +/// another module moves an unchanged CommonJS caller of the default. +pub fn javascript_commonjs_forward_names_another_module() -> Case { + Case::new(Change::ReexportEdit, &[Language::JavaScript]) + .file("src/a.js", "module.exports = function safeParse(value) {\n return value;\n};\n") + .file("src/b.js", "module.exports = function safeParse(value) {\n return [value];\n};\n") + .file("src/external.js", "module.exports = require('./a');\n") + .file( + "src/use.js", + "const parse = require('./external');\n\nfunction check() {\n return parse(1);\n}\n\nmodule.exports = check;\n", + ) + .write("src/external.js", "module.exports = require('./b');\n") + .edge_before("function check", "calls", "function safeParse @src/a.js") + .edge_after("function check", "calls", "function safeParse @src/b.js") +} + +/// A sealed module (an import and no export) keeps its bindings from other +/// files; once it gains a CommonJS export (its import and its function's +/// node untouched), a classic script's `helper()` reaches it. +pub fn javascript_sealed_module_gains_a_commonjs_export() -> Case { + Case::new(Change::ReexportEdit, &[Language::JavaScript]) + .file("src/x.js", "export const x = 1;\n") + .file( + "src/lib.js", + "import { x } from './x';\nfunction helper() {\n return 1;\n}\n", + ) + .file("src/use.js", "function run() {\n return helper();\n}\n") + .write( + "src/lib.js", + "import { x } from './x';\nfunction helper() {\n return 1;\n}\nmodule.exports = {};\n", + ) + .edge_after("function run", "calls", "function helper") +} + +/// Upstream #2227: what a Svelte component's instance `\n\n

      hi

      \n", + ) + .file("src/use.ts", "export function run() {\n return helper();\n}\n") + .write( + "src/Widget.svelte", + "\n\n

      hi

      \n", + ) + .edge_after("function run", "calls", "function helper @src/Widget.svelte") +} + +/// A barrel forwarded through a tsconfig `paths` alias (`@mid`) is deleted: +/// the outer barrel's alias source names no file any more, so an unchanged +/// caller's call through both is no import resolution, as on a fresh index +/// (which falls back to a name match). Only the target the outer barrel's +/// row recorded finds the caller. +pub fn typescript_alias_forwarded_barrel_is_deleted() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::TypeScript]) + .file( + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@mid\":[\"src/mid.ts\"]}}}\n", + ) + .file("src/a.ts", JS_REEXPORT_A) + .file("src/mid.ts", "export * from './a';\n") + .file("src/index.ts", "export * from '@mid';\n") + .file( + "src/use.ts", + "import { safeParse } from './index';\n\nexport function check() {\n return safeParse(1);\n}\n", + ) + .remove("src/mid.ts") + .edge_before("function check", "calls", "function safeParse @src/a.ts") +} + +/// A workspace package's entry barrel moves into a subdirectory: the +/// package name no longer names a file, so an unchanged caller importing a +/// barrel that forwards the package is no import resolution, as on a fresh +/// index. +pub fn typescript_workspace_entry_barrel_is_renamed() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::TypeScript]) + .file("package.json", "{\"name\":\"root\",\"private\":true,\"workspaces\":[\"packages/*\"]}\n") + .file("packages/mid/package.json", "{\"name\":\"mid\",\"version\":\"1.0.0\"}\n") + .file("packages/mid/index.ts", "export * from './a';\n") + .file("packages/mid/a.ts", JS_REEXPORT_A) + .file("packages/mid/lib/a.ts", JS_REEXPORT_B) + .file("src/index.ts", "export * from 'mid';\n") + .file( + "src/use.ts", + "import { safeParse } from './index';\n\nexport function check() {\n return safeParse(1);\n}\n", + ) + .rename("packages/mid/index.ts", "packages/mid/lib/index.ts") + .edge_before("function check", "calls", "function safeParse @packages/mid/a.ts") +} + +/// A barrel's source resolves by the language of the import it is followed +/// for: `./inner` from a JavaScript caller is `inner.js` first, from a +/// TypeScript one `inner.ts`. The node-less `inner.js` that a JavaScript +/// caller reaches through `outer.js` comes to forward another module, so the +/// caller moves although `inner.ts` is what a TypeScript import would see. +pub fn javascript_barrel_chain_follows_the_importer_language() -> Case { + Case::new(Change::ReexportEdit, &[Language::JavaScript, Language::TypeScript]) + .file("src/a.js", "export function safeParse(value) {\n return value;\n}\n") + .file("src/b.js", "export function safeParse(value) {\n return [value];\n}\n") + .file("src/inner.js", "export * from './a';\n") + .file("src/inner.ts", "export function other(): number {\n return 1;\n}\n") + .file("src/outer.js", "export * from './inner';\n") + .file( + "src/use.js", + "import { safeParse } from './outer';\n\nexport function check() {\n return safeParse(1);\n}\n", + ) + .write("src/inner.js", "export * from './b';\n") + .edge_before("function check", "calls", "function safeParse @src/a.js") + .edge_after("function check", "calls", "function safeParse @src/b.js") +} + +/// Upstream #2449: a class written around attribute macros (an annotation +/// after the parameter list, one after a field's name) keeps its method +/// through a body edit, and an unchanged caller keeps its call. +pub fn cpp_attribute_macro_class_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "src/counter.h", + "namespace db {\n\nclass Counter {\n public:\n void IncrementBy(int count) LOCKS_EXCLUDED(mu_) {\n count_ += count;\n }\n\n private:\n int count_ GUARDED_BY(mu_);\n};\n\n} // namespace db\n", + ) + .file( + "src/use.cc", + "#include \"counter.h\"\n\nvoid run(db::Counter& counter) {\n counter.IncrementBy(1);\n}\n", + ) + .write( + "src/counter.h", + "namespace db {\n\nclass Counter {\n public:\n void IncrementBy(int count) LOCKS_EXCLUDED(mu_) {\n count_ += count * 2;\n }\n\n private:\n int count_ GUARDED_BY(mu_);\n};\n\n} // namespace db\n", + ) + .edge_before("function run", "calls", "method db::Counter::IncrementBy") + .edge_after("function run", "calls", "method db::Counter::IncrementBy") +} + +/// Upstream #2426: in a C++ file whose tree has errors (rocksdb's macro call +/// in a nested struct's head), the class after it keeps its namespaces +/// through a body edit, and an unchanged caller keeps its call into it. +pub fn cpp_broken_tree_class_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "cache/clock_cache.h", + "namespace rocksdb {\nnamespace clock_cache {\n\nclass FixedTable : public BaseTable {\n public:\n struct cacheline_aligned(64U) HandleImpl : public ClockHandle {\n RelaxedAtomic displacements{};\n bool standalone = false;\n };\n\n struct Opts : public BaseOpts {\n explicit Opts(size_t size) : BaseOpts(0), size(size) {}\n size_t size;\n };\n\n void Insert(const Opts& opts);\n};\n\nclass AutoTable : public BaseTable {\n public:\n void Erase() {\n count_ = 0;\n }\n\n private:\n int count_;\n};\n\n} // namespace clock_cache\n} // namespace rocksdb\n", + ) + .file( + "src/use.cc", + "#include \"clock_cache.h\"\n\nvoid run(rocksdb::clock_cache::AutoTable& table) {\n table.Erase();\n}\n", + ) + .write( + "cache/clock_cache.h", + "namespace rocksdb {\nnamespace clock_cache {\n\nclass FixedTable : public BaseTable {\n public:\n struct cacheline_aligned(64U) HandleImpl : public ClockHandle {\n RelaxedAtomic displacements{};\n bool standalone = false;\n };\n\n struct Opts : public BaseOpts {\n explicit Opts(size_t size) : BaseOpts(0), size(size) {}\n size_t size;\n };\n\n void Insert(const Opts& opts);\n};\n\nclass AutoTable : public BaseTable {\n public:\n void Erase() {\n count_ = 1;\n }\n\n private:\n int count_;\n};\n\n} // namespace clock_cache\n} // namespace rocksdb\n", + ) + .edge_before("function run", "calls", "method rocksdb::clock_cache::AutoTable::Erase") + .edge_after("function run", "calls", "method rocksdb::clock_cache::AutoTable::Erase") +} + +/// Upstream #2399: an alias retargeted in a header (`typedef SkipList<…> +/// Table;` becoming `typedef OtherList<…> Table;`) moves an unchanged +/// caller's `table_.Insert(buf)` to the type the alias now names, as on a +/// fresh index, though the alias node keeps its identity: a name the unchanged +/// file uses now means another definition. +pub fn cpp_alias_retarget_moves_an_unchanged_receiver() -> Case { + Case::new(Change::ReexportEdit, &[Language::Cpp]) + .file( + "db/lists.h", + "namespace leveldb {\ntemplate \nclass SkipList {\n public:\n void Insert(const Key& key) {}\n};\ntemplate \nclass OtherList {\n public:\n void Insert(const Key& key) {}\n};\n} // namespace leveldb\n", + ) + .file( + "db/memtable.h", + "#include \"db/lists.h\"\nnamespace leveldb {\nclass MemTable {\n public:\n void Add(const char* buf);\n private:\n typedef SkipList Table;\n Table table_;\n};\n} // namespace leveldb\n", + ) + .file( + "db/memtable.cc", + "#include \"db/memtable.h\"\nnamespace leveldb {\nvoid MemTable::Add(const char* buf) {\n table_.Insert(buf);\n}\n} // namespace leveldb\n", + ) + .write( + "db/memtable.h", + "#include \"db/lists.h\"\nnamespace leveldb {\nclass MemTable {\n public:\n void Add(const char* buf);\n private:\n typedef OtherList Table;\n Table table_;\n};\n} // namespace leveldb\n", + ) + .edge_before( + "method leveldb::MemTable::Add @db/memtable.cc", + "calls", + "method leveldb::SkipList::Insert", + ) + .edge_after( + "method leveldb::MemTable::Add @db/memtable.cc", + "calls", + "method leveldb::OtherList::Insert", + ) + .no_edge_after( + "method leveldb::MemTable::Add @db/memtable.cc", + "calls", + "method leveldb::SkipList::Insert", + ) +} + +/// Upstream #2399: an alias template retargeted in a header +/// (`template using Table = SkipList;` naming `OtherList`) +/// moves the call of an unchanged file that writes `Table`, +/// though the file is no source of that header. +pub fn cpp_template_alias_retarget_moves_an_unchanged_receiver() -> Case { + Case::new(Change::ReexportEdit, &[Language::Cpp]) + .file( + "db/lists.hpp", + "namespace leveldb {\ntemplate \nclass SkipList {\n public:\n void Insert(const Key& key) {}\n};\ntemplate \nclass OtherList {\n public:\n void Insert(const Key& key) {}\n};\n} // namespace leveldb\n", + ) + .file( + "db/aliases.hpp", + "#include \"db/lists.hpp\"\nnamespace leveldb {\ntemplate \nusing Table = SkipList;\n} // namespace leveldb\n", + ) + .file( + "db/use.cc", + "#include \"db/aliases.hpp\"\nnamespace leveldb {\nvoid Fill() {\n Table table;\n table.Insert(\"k\");\n}\n} // namespace leveldb\n", + ) + .write( + "db/aliases.hpp", + "#include \"db/lists.hpp\"\nnamespace leveldb {\ntemplate \nusing Table = OtherList;\n} // namespace leveldb\n", + ) + .edge_before("function leveldb::Fill", "calls", "method leveldb::SkipList::Insert") + .edge_after("function leveldb::Fill", "calls", "method leveldb::OtherList::Insert") + .no_edge_after("function leveldb::Fill", "calls", "method leveldb::SkipList::Insert") +} + +/// Upstream #2399: an alias reached through another alias +/// (`using Lookup = Table;`) moves an unchanged caller that writes only +/// `Lookup` when `Table` is retargeted, with the header declaring `Lookup` +/// rewritten in the same batch. +pub fn cpp_alias_chain_retarget_moves_an_unchanged_receiver() -> Case { + Case::new(Change::ReexportEdit, &[Language::Cpp]) + .file( + "db/lists.hpp", + "namespace leveldb {\nclass SkipList {\n public:\n void Insert(int key) {}\n};\nclass OtherList {\n public:\n void Insert(int key) {}\n};\n} // namespace leveldb\n", + ) + .file( + "db/table.hpp", + "#include \"db/lists.hpp\"\nnamespace leveldb {\nusing Table = SkipList;\n} // namespace leveldb\n", + ) + .file( + "db/lookup.hpp", + "#include \"db/table.hpp\"\nnamespace leveldb {\nusing Lookup = Table;\n} // namespace leveldb\n", + ) + .file( + "db/fill.cc", + "#include \"db/lookup.hpp\"\nnamespace leveldb {\nvoid Fill() {\n Lookup lookup;\n lookup.Insert(1);\n}\n} // namespace leveldb\n", + ) + .write( + "db/lookup.hpp", + "#include \"db/table.hpp\"\nnamespace leveldb {\n// The skip list's lookup.\nusing Lookup = Table;\n} // namespace leveldb\n", + ) + .write( + "db/table.hpp", + "#include \"db/lists.hpp\"\nnamespace leveldb {\nusing Table = OtherList;\n} // namespace leveldb\n", + ) + .edge_before("function leveldb::Fill", "calls", "method leveldb::SkipList::Insert") + .edge_after("function leveldb::Fill", "calls", "method leveldb::OtherList::Insert") + .no_edge_after("function leveldb::Fill", "calls", "method leveldb::SkipList::Insert") +} + +/// A member's type changed in a header (`SkipList table_;` becoming +/// `OtherList table_;`, no alias) moves the call of the header's unchanged +/// source, which receiver inference reads it from (v0.54.0 already diverged +/// here; owner 2026-10-12: fixed with W4-CPP-04). +pub fn cpp_header_member_type_moves_an_unchanged_receiver() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "db/lists.hpp", + "namespace leveldb {\nclass SkipList {\n public:\n void Insert(int key) {}\n};\nclass OtherList {\n public:\n void Insert(int key) {}\n};\n} // namespace leveldb\n", + ) + .file( + "db/memtable.h", + "#include \"db/lists.hpp\"\nnamespace leveldb {\nclass MemTable {\n public:\n void Add(int buf);\n private:\n SkipList table_;\n};\n} // namespace leveldb\n", + ) + .file( + "db/memtable.cc", + "#include \"db/memtable.h\"\nnamespace leveldb {\nvoid MemTable::Add(int buf) {\n table_.Insert(buf);\n}\n} // namespace leveldb\n", + ) + .write( + "db/memtable.h", + "#include \"db/lists.hpp\"\nnamespace leveldb {\nclass MemTable {\n public:\n void Add(int buf);\n private:\n OtherList table_;\n};\n} // namespace leveldb\n", + ) + .edge_before( + "method leveldb::MemTable::Add @db/memtable.cc", + "calls", + "method leveldb::SkipList::Insert", + ) + .edge_after( + "method leveldb::MemTable::Add @db/memtable.cc", + "calls", + "method leveldb::OtherList::Insert", + ) + .no_edge_after( + "method leveldb::MemTable::Add @db/memtable.cc", + "calls", + "method leveldb::SkipList::Insert", + ) +} + +/// Upstream #2399: a class added in an inner scope hides an outer alias of +/// its name. `Holder::Use`, out of line in a file no header names as its +/// source, writes `Widget`; once `Holder` declares its own `Widget` (without +/// `Spin`), the call no longer reaches `Gadget::Spin` through the alias. +pub fn cpp_inner_class_added_hides_an_outer_alias() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cpp]) + .file( + "app/gadget.hpp", + "namespace app {\nclass Gadget {\n public:\n void Spin() {}\n};\nusing Widget = Gadget;\n} // namespace app\n", + ) + .file( + "app/holder.hpp", + "#include \"app/gadget.hpp\"\nnamespace app {\nclass Holder {\n public:\n void Use();\n};\n} // namespace app\n", + ) + .file( + "app/use.cc", + "#include \"app/holder.hpp\"\nnamespace app {\nvoid Holder::Use() {\n Widget w;\n w.Spin();\n}\n} // namespace app\n", + ) + .write( + "app/holder.hpp", + "#include \"app/gadget.hpp\"\nnamespace app {\nclass Holder {\n public:\n class Widget {\n public:\n void Turn() {}\n };\n void Use();\n};\n} // namespace app\n", + ) + .edge_before("method app::Holder::Use", "calls", "method app::Gadget::Spin") + .no_edge_after("method app::Holder::Use", "calls", "method app::Gadget::Spin") +} + +/// Upstream #2399: a header the caller does not include declaring the same +/// alias for another type makes it ambiguous, so the caller no longer follows +/// it, as on a fresh index. +pub fn cpp_alias_in_an_unincluded_header_makes_it_ambiguous() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cpp]) + .file( + "store/kinds.hpp", + "namespace app {\nclass MemStore {\n public:\n void Save() {}\n};\nclass DiskStore {\n public:\n void Save() {}\n};\n} // namespace app\n", + ) + .file( + "config/fast.hpp", + "#include \"store/kinds.hpp\"\nnamespace app {\nusing Store = MemStore;\n} // namespace app\n", + ) + .file( + "src/use.cc", + "#include \"config/fast.hpp\"\nnamespace app {\nvoid Run() {\n Store store;\n store.Save();\n}\n} // namespace app\n", + ) + .write( + "config/safe.hpp", + "#include \"store/kinds.hpp\"\nnamespace app {\nusing Store = DiskStore;\n} // namespace app\n", + ) + .edge_before("function app::Run", "calls", "method app::MemStore::Save") + .no_edge_after("function app::Run", "calls", "method app::MemStore::Save") +} + +/// Upstream #2399: an alias a class inherits follows its base clause. Once +/// `ParseDescriptor` derives from `Proto3Descriptor` instead, its out-of-line +/// `NewMsg(Field f)` reaches the other base's `Field`. +pub fn cpp_base_change_moves_an_inherited_alias() -> Case { + Case::new(Change::EditHeritage, &[Language::Cpp]) + .file("proto/descriptor.hpp", "namespace proto {\nclass FieldDescriptor {\n public:\n bool is_repeated() const { return false; }\n};\nclass OneofDescriptor {\n public:\n bool is_repeated() const { return false; }\n};\n} // namespace proto\n") + .file( + "proto/traits.hpp", + "#include \"proto/descriptor.hpp\"\nnamespace proto {\nstruct Proto2Descriptor {\n using Field = const FieldDescriptor*;\n};\nstruct Proto3Descriptor {\n using Field = const OneofDescriptor*;\n};\nstruct ParseDescriptor : Proto2Descriptor {\n static bool NewMsg(Field f);\n};\n} // namespace proto\n", + ) + .file( + "proto/parse.cc", + "#include \"proto/traits.hpp\"\nnamespace proto {\nbool ParseDescriptor::NewMsg(Field f) { return f->is_repeated(); }\n} // namespace proto\n", + ) + .write( + "proto/traits.hpp", + "#include \"proto/descriptor.hpp\"\nnamespace proto {\nstruct Proto2Descriptor {\n using Field = const FieldDescriptor*;\n};\nstruct Proto3Descriptor {\n using Field = const OneofDescriptor*;\n};\nstruct ParseDescriptor : Proto3Descriptor {\n static bool NewMsg(Field f);\n};\n} // namespace proto\n", + ) + .edge_before( + "method proto::ParseDescriptor::NewMsg @proto/parse.cc", + "calls", + "method proto::FieldDescriptor::is_repeated", + ) + .edge_after( + "method proto::ParseDescriptor::NewMsg @proto/parse.cc", + "calls", + "method proto::OneofDescriptor::is_repeated", + ) + .no_edge_after( + "method proto::ParseDescriptor::NewMsg @proto/parse.cc", + "calls", + "method proto::FieldDescriptor::is_repeated", + ) +} + +/// Upstream #2399: a base two derivations up changing its own base moves the +/// inherited alias of `StrictParser::NewMsg`, whose out-of-line definition +/// names only `StrictParser`, in a file that is no header's source. +pub fn cpp_base_change_reaches_an_out_of_line_method_of_a_derived_class() -> Case { + Case::new(Change::EditHeritage, &[Language::Cpp]) + .file("proto/descriptor.hpp", "namespace proto {\nclass FieldDescriptor {\n public:\n bool is_repeated() const { return false; }\n};\nclass OneofDescriptor {\n public:\n bool is_repeated() const { return false; }\n};\n} // namespace proto\n") + .file( + "proto/traits.hpp", + "#include \"proto/descriptor.hpp\"\nnamespace proto {\nstruct Proto2Descriptor {\n using Field = const FieldDescriptor*;\n};\nstruct Proto3Descriptor {\n using Field = const OneofDescriptor*;\n};\nstruct BaseParser : Proto2Descriptor {};\n} // namespace proto\n", + ) + .file( + "proto/parsers.hpp", + "#include \"proto/traits.hpp\"\nnamespace proto {\nstruct Parser : BaseParser {};\nstruct StrictParser : Parser {\n static bool NewMsg(Field f);\n};\n} // namespace proto\n", + ) + .file( + "proto/impl.cc", + "#include \"proto/parsers.hpp\"\nnamespace proto {\nbool StrictParser::NewMsg(Field f) { return f->is_repeated(); }\n} // namespace proto\n", + ) + .write( + "proto/traits.hpp", + "#include \"proto/descriptor.hpp\"\nnamespace proto {\nstruct Proto2Descriptor {\n using Field = const FieldDescriptor*;\n};\nstruct Proto3Descriptor {\n using Field = const OneofDescriptor*;\n};\nstruct BaseParser : Proto3Descriptor {};\n} // namespace proto\n", + ) + .edge_before( + "method proto::StrictParser::NewMsg @proto/impl.cc", + "calls", + "method proto::FieldDescriptor::is_repeated", + ) + .edge_after( + "method proto::StrictParser::NewMsg @proto/impl.cc", + "calls", + "method proto::OneofDescriptor::is_repeated", + ) + .no_edge_after( + "method proto::StrictParser::NewMsg @proto/impl.cc", + "calls", + "method proto::FieldDescriptor::is_repeated", + ) +} + +/// Upstream #2399, one batch: `base.hpp` is rewritten first (its nodes go, +/// and with them `Derived`'s `extends` edge), then `derived.hpp` drops `: +/// Proto2Descriptor`; the old surface of `derived.hpp` still shows the base, +/// so `Derived::NewMsg` moves to the namespace's `Field`. +pub fn cpp_batch_rewrites_a_base_before_dropping_a_derivation() -> Case { + Case::new(Change::EditHeritage, &[Language::Cpp]) + .file("proto/descriptor.hpp", "namespace proto {\nclass FieldDescriptor {\n public:\n bool is_repeated() const { return false; }\n};\nclass OneofDescriptor {\n public:\n bool is_repeated() const { return false; }\n};\n} // namespace proto\n") + .file( + "proto/base.hpp", + "#include \"proto/descriptor.hpp\"\nnamespace proto {\nstruct Proto2Descriptor {\n using Field = const FieldDescriptor*;\n};\n} // namespace proto\n", + ) + .file( + "proto/derived.hpp", + "#include \"proto/base.hpp\"\nnamespace proto {\nusing Field = const OneofDescriptor*;\nstruct Derived : Proto2Descriptor {\n static bool NewMsg(Field f);\n};\n} // namespace proto\n", + ) + .file( + "proto/use.cc", + "#include \"proto/derived.hpp\"\nnamespace proto {\nbool Derived::NewMsg(Field f) { return f->is_repeated(); }\n} // namespace proto\n", + ) + .write( + "proto/base.hpp", + "#include \"proto/descriptor.hpp\"\nnamespace proto {\n// Proto2's descriptor traits.\nstruct Proto2Descriptor {\n using Field = const FieldDescriptor*;\n};\n} // namespace proto\n", + ) + .write( + "proto/derived.hpp", + "#include \"proto/base.hpp\"\nnamespace proto {\nusing Field = const OneofDescriptor*;\nstruct Derived {\n static bool NewMsg(Field f);\n};\n} // namespace proto\n", + ) + .edge_before( + "method proto::Derived::NewMsg @proto/use.cc", + "calls", + "method proto::FieldDescriptor::is_repeated", + ) + .edge_after( + "method proto::Derived::NewMsg @proto/use.cc", + "calls", + "method proto::OneofDescriptor::is_repeated", + ) + .no_edge_after( + "method proto::Derived::NewMsg @proto/use.cc", + "calls", + "method proto::FieldDescriptor::is_repeated", + ) +} + +/// Upstream #2407: a class another source file defines belongs to that +/// translation unit, so a namesake `leveldb::Comparator` added in a test +/// source never takes the base of `NumberComparator`, which keeps the header's +/// `Comparator`, as on a fresh index. +pub fn cpp_source_file_namesake_does_not_take_a_base() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cpp]) + .file( + "include/leveldb/comparator.h", + "namespace leveldb {\nclass Comparator {\n public:\n virtual int Compare(const char* a, const char* b) const = 0;\n};\n} // namespace leveldb\n", + ) + .file( + "db/db_test.cc", + "#include \"include/leveldb/comparator.h\"\nnamespace leveldb {\nclass NumberComparator : public Comparator {\n public:\n int Compare(const char* a, const char* b) const override { return 0; }\n};\n} // namespace leveldb\n", + ) + .write( + "db/skiplist_test.cc", + "namespace leveldb {\nstruct Comparator {\n int operator()(const int& a, const int& b) const { return a - b; }\n};\n} // namespace leveldb\n", + ) + .edge_before( + "class leveldb::NumberComparator", + "extends", + "class leveldb::Comparator @include/leveldb/comparator.h", + ) + .edge_after( + "class leveldb::NumberComparator", + "extends", + "class leveldb::Comparator @include/leveldb/comparator.h", + ) + .no_edge_after( + "class leveldb::NumberComparator", + "extends", + "struct leveldb::Comparator @db/skiplist_test.cc", + ) +} + +/// Upstream #2407: a using-declaration the deriving file adds before the +/// class names the base it means, so `public FilterPolicy` moves from the +/// file's only visible namesake to `leveldb::FilterPolicy`. +pub fn cpp_using_declaration_names_the_base() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "include/leveldb/filter_policy.h", + "namespace leveldb {\nclass FilterPolicy {\n public:\n virtual const char* Name() const = 0;\n};\n} // namespace leveldb\n", + ) + .file( + "include/other/filter_policy.h", + "namespace other {\nclass FilterPolicy {\n public:\n virtual const char* Name() const = 0;\n};\n} // namespace other\n", + ) + .file( + "db/c.cc", + "#include \"include/leveldb/filter_policy.h\"\nusing other::FilterPolicy;\nstruct leveldb_filterpolicy_t : public FilterPolicy {\n const char* Name() const override { return \"c\"; }\n};\n", + ) + .write( + "db/c.cc", + "#include \"include/leveldb/filter_policy.h\"\nusing leveldb::FilterPolicy;\nstruct leveldb_filterpolicy_t : public FilterPolicy {\n const char* Name() const override { return \"c\"; }\n};\n", + ) + .edge_before( + "struct leveldb_filterpolicy_t", + "extends", + "class other::FilterPolicy", + ) + .edge_after( + "struct leveldb_filterpolicy_t", + "extends", + "class leveldb::FilterPolicy", + ) + .no_edge_after( + "struct leveldb_filterpolicy_t", + "extends", + "class other::FilterPolicy", + ) +} + +/// Upstream #2413: once the header retypes a member from the project's +/// `Slice` to `std::string`, the unchanged source's `saved_slice_.clear()` +/// is the string's own member, not `Slice::clear`, which the receiver's +/// shared word would otherwise guess. +pub fn cpp_header_member_becomes_a_library_type() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "include/leveldb/slice.h", + "namespace leveldb {\nclass Slice {\n public:\n void clear() { size_ = 0; }\n private:\n unsigned size_;\n};\n} // namespace leveldb\n", + ) + .file( + "db/db_iter.h", + "#include \n#include \"include/leveldb/slice.h\"\nnamespace leveldb {\nclass DBIter {\n public:\n void Next();\n private:\n Slice saved_slice_;\n};\n} // namespace leveldb\n", + ) + .file( + "db/db_iter.cc", + "#include \"db/db_iter.h\"\nnamespace leveldb {\nvoid DBIter::Next() {\n saved_slice_.clear();\n}\n} // namespace leveldb\n", + ) + .write( + "db/db_iter.h", + "#include \n#include \"include/leveldb/slice.h\"\nnamespace leveldb {\nclass DBIter {\n public:\n void Next();\n private:\n std::string saved_slice_;\n};\n} // namespace leveldb\n", + ) + .edge_before( + "method leveldb::DBIter::Next @db/db_iter.cc", + "calls", + "method leveldb::Slice::clear", + ) + .no_edge_after( + "method leveldb::DBIter::Next @db/db_iter.cc", + "calls", + "method leveldb::Slice::clear", + ) +} + +/// Upstream #2413: a lowercase type is from outside the project only while +/// the project declares no type of that name. Once a vendored +/// `absl::flat_hash_set` appears, the unchanged caller's `arena.insert(1)` +/// is that class's method, never the decoy `Arena::insert`. +pub fn cpp_project_declares_a_lowercase_outside_type() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cpp]) + .file( + "util/arena.h", + "namespace leveldb {\nclass Arena {\n public:\n void insert(int block) {}\n};\n} // namespace leveldb\n", + ) + .file( + "util/use.cc", + "#include \"absl/container/flat_hash_set.h\"\n#include \"util/arena.h\"\nnamespace leveldb {\nvoid Use() {\n absl::flat_hash_set arena;\n arena.insert(1);\n}\n} // namespace leveldb\n", + ) + .write( + "absl/container/flat_hash_set.h", + "namespace absl {\ntemplate \nclass flat_hash_set {\n public:\n void insert(const T& value) {}\n};\n} // namespace absl\n", + ) + .edge_after( + "function leveldb::Use", + "calls", + "method absl::flat_hash_set::insert", + ) + .no_edge_after("function leveldb::Use", "calls", "method leveldb::Arena::insert") +} + +/// Upstream #2443: a member declared with nested template arguments is its +/// class template's, so retyping it in the header moves the unchanged +/// source's call from `autovector::clear` to `smallvector::clear`. +pub fn cpp_nested_template_member_type_moves_an_unchanged_receiver() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "util/vectors.h", + "namespace rocksdb {\ntemplate \nclass autovector {\n public:\n void clear() {}\n};\ntemplate \nclass smallvector {\n public:\n void clear() {}\n};\nstruct FileMetaData {};\n} // namespace rocksdb\n", + ) + .file( + "db/version_set.h", + "#include \"util/vectors.h\"\nnamespace rocksdb {\nclass VersionStorageInfo {\n public:\n void Reset();\n private:\n autovector> files_;\n};\n} // namespace rocksdb\n", + ) + .file( + "db/version_set.cc", + "#include \"db/version_set.h\"\nnamespace rocksdb {\nvoid VersionStorageInfo::Reset() {\n files_.clear();\n}\n} // namespace rocksdb\n", + ) + .write( + "db/version_set.h", + "#include \"util/vectors.h\"\nnamespace rocksdb {\nclass VersionStorageInfo {\n public:\n void Reset();\n private:\n smallvector> files_;\n};\n} // namespace rocksdb\n", + ) + .edge_before( + "method rocksdb::VersionStorageInfo::Reset @db/version_set.cc", + "calls", + "method rocksdb::autovector::clear", + ) + .edge_after( + "method rocksdb::VersionStorageInfo::Reset @db/version_set.cc", + "calls", + "method rocksdb::smallvector::clear", + ) + .no_edge_after( + "method rocksdb::VersionStorageInfo::Reset @db/version_set.cc", + "calls", + "method rocksdb::autovector::clear", + ) +} + +/// Upstream #2176 (W4-CPP-07): a namespace alias another header writes +/// moves, so the unchanged caller's `py::print("hi")` reaches the other +/// namespace's `print`. +pub fn cpp_cross_file_namespace_alias_retarget() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "include/pybind11/print.h", + "#pragma once\nnamespace pybind11 {\ninline void print(const char* text) {}\n} // namespace pybind11\n", + ) + .file( + "include/other/print.h", + "#pragma once\nnamespace other {\ninline void print(const char* text) {}\n} // namespace other\n", + ) + .file( + "include/aliases.h", + "#pragma once\nnamespace py = pybind11;\n", + ) + .file( + "src/main.cc", + "#include \"aliases.h\"\nint main() {\n py::print(\"hi\");\n return 0;\n}\n", + ) + .write( + "include/aliases.h", + "#pragma once\nnamespace py = other;\n", + ) + .edge_before("function main", "calls", "function pybind11::print") + .edge_after("function main", "calls", "function other::print") + .no_edge_after("function main", "calls", "function pybind11::print") +} + +/// Upstream #2176 (W4-CPP-07): the namespace an opener macro opens moves, so +/// the unchanged caller's `lib::run()` names nothing the header declares. +pub fn cpp_macro_opener_opens_another_namespace() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "include/lib/config.h", + "#pragma once\n#define LIB_BEGIN namespace lib {\n#define LIB_END }\n", + ) + .file( + "include/lib/run.h", + "#pragma once\n#include \"lib/config.h\"\nLIB_BEGIN\ninline int run() { return 0; }\nLIB_END\n", + ) + .file( + "src/main.cc", + "#include \"lib/run.h\"\nint main() { return lib::run(); }\n", + ) + .write( + "include/lib/config.h", + "#pragma once\n#define LIB_BEGIN namespace mylib {\n#define LIB_END }\n", + ) + .edge_before("function main", "calls", "function run") + .no_edge_after("function main", "calls", "function run") +} + +/// Upstream #2176 (W4-CPP-07): only the header defining a macro alias +/// changes; the opener another header defines through it, and the header +/// using that opener, are unchanged, and the caller's `lib::run()` follows. +pub fn cpp_macro_alias_moves_an_opener_it_feeds() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "include/lib/name.h", + "#pragma once\n#define LIB_NAME lib\n", + ) + .file( + "include/lib/config.h", + "#pragma once\n#include \"lib/name.h\"\n#define LIB_BEGIN namespace LIB_NAME {\n#define LIB_END }\n", + ) + .file( + "include/lib/run.h", + "#pragma once\n#include \"lib/config.h\"\nLIB_BEGIN\ninline int run() { return 0; }\nLIB_END\n", + ) + .file( + "src/main.cc", + "#include \"lib/run.h\"\nint main() { return lib::run(); }\n", + ) + .write( + "include/lib/name.h", + "#pragma once\n#define LIB_NAME otherlib\n", + ) + .edge_before("function main", "calls", "function run") + .no_edge_after("function main", "calls", "function run") +} + +/// Upstream #2445 (W4-CPP-07): a closer moved above an unchanged +/// declaration line takes it out of the macro's namespace, so the +/// unchanged caller's `::pipe(fds)` reaches it (a global name skips a +/// declaration a macro namespace holds). +pub fn cpp_macro_closer_moves_a_declaration_out_of_the_namespace() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "include/lib/os.hpp", + "#pragma once\n#define LIB_BEGIN_NAMESPACE namespace lib {\n#define LIB_END_NAMESPACE }\nLIB_BEGIN_NAMESPACE\n\ninline int pipe(int* fds) { return 0; }\nLIB_END_NAMESPACE\n", + ) + .file( + "src/run.cc", + "#include \"lib/os.hpp\"\nint run(int* fds) { return ::pipe(fds); }\n", + ) + .write( + "include/lib/os.hpp", + "#pragma once\n#define LIB_BEGIN_NAMESPACE namespace lib {\n#define LIB_END_NAMESPACE }\nLIB_BEGIN_NAMESPACE\nLIB_END_NAMESPACE\ninline int pipe(int* fds) { return 0; }\n\n", + ) + .edge_after("function run", "calls", "function pipe") +} + +/// The same for a free `operator<<` whose declaration line stays put: the +/// name lane scans for `operator`, which `::operator<<(…)` writes. +pub fn cpp_free_operator_moves_out_of_the_macro_namespace() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "include/lib/stream.hpp", + "#pragma once\n#define LIB_BEGIN_NAMESPACE namespace lib {\n#define LIB_END_NAMESPACE }\nstruct Stream {};\nstruct Value {};\nLIB_BEGIN_NAMESPACE\n\ninline Stream& operator<<(Stream& out, const Value& value) { return out; }\nLIB_END_NAMESPACE\n", + ) + .file( + "src/print.cc", + "#include \"lib/stream.hpp\"\nvoid print(Stream& out, const Value& value) { ::operator<<(out, value); }\n", + ) + .write( + "include/lib/stream.hpp", + "#pragma once\n#define LIB_BEGIN_NAMESPACE namespace lib {\n#define LIB_END_NAMESPACE }\nstruct Stream {};\nstruct Value {};\nLIB_BEGIN_NAMESPACE\nLIB_END_NAMESPACE\ninline Stream& operator<<(Stream& out, const Value& value) { return out; }\n\n", + ) + .edge_after("function print", "calls", "function operator<<") +} + +/// Upstream #2445 (W4-CPP-07): only the header defining the opener and +/// closer changes, and they no longer open a namespace; the unchanged +/// header using them now declares `pipe` globally, and the unchanged +/// caller's `::pipe(fds)` reaches it. +pub fn cpp_opener_definition_stops_opening_a_namespace() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "include/lib/config.h", + "#pragma once\n#define LIB_BEGIN_NAMESPACE namespace lib {\n#define LIB_END_NAMESPACE }\n", + ) + .file( + "include/lib/os.h", + "#pragma once\n#include \"lib/config.h\"\nLIB_BEGIN_NAMESPACE\ninline int pipe(int* fds) { return 0; }\nLIB_END_NAMESPACE\n", + ) + .file( + "src/run.cc", + "#include \"lib/os.h\"\nint run(int* fds) { return ::pipe(fds); }\n", + ) + .write( + "include/lib/config.h", + "#pragma once\n#define LIB_BEGIN_NAMESPACE\n#define LIB_END_NAMESPACE\n", + ) + .edge_after("function run", "calls", "function pipe") +} + +/// Upstream #2234 (W4-CPP-08): a C++ header adding a method named like a C +/// file's call never gives that call a method: C has no methods. +pub fn c_call_ignores_an_added_cpp_method() -> Case { + Case::new(Change::AddCompetitor, &[Language::C, Language::Cpp]) + .file( + "src/util.c", + "#include \n\nlong slurp(int fd, char *buf, long n) {\n return read(fd, buf, n);\n}\n", + ) + .write( + "deps/hiredis/adapters/qt.hpp", + "class RedisQtAdapter {\npublic:\n void read() { }\n};\n", + ) + .no_edge_after("function slurp", "calls", "method RedisQtAdapter::read") +} + +/// Upstream #2150's C-call half (W4-CPP-08): an Objective-C class adding a +/// method named like a block a method calls in C call syntax never takes +/// that call. +pub fn objc_c_call_ignores_an_added_method() -> Case { + Case::new(Change::AddCompetitor, &[Language::ObjC]) + .file( + "Core/SDCache.h", + "#import \n@interface SDCache : NSObject\n- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image;\n@end\n", + ) + .file( + "Core/SDCache.m", + "#import \"SDCache.h\"\n@implementation SDCache\n- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image {\n completionBlock();\n}\n@end\n", + ) + .write( + "Core/SDOperation.m", + "#import \n@interface SDOperation : NSObject\n- (void)completionBlock;\n@end\n@implementation SDOperation\n- (void)completionBlock {\n}\n@end\n", + ) + .no_edge_after( + "method SDCache::storeWithCompletion:image:", + "calls", + "method SDOperation::completionBlock", + ) +} + +/// Layers of the deep include graph of [`cpp_macro_visibility_through_a_deep_include_graph`]. +const DEEP_INCLUDE_LAYERS: usize = 20; + +/// Two value-guarded headers per layer (`#define H_3_a 1`, a guard the walk +/// cannot decide from the guard alone), each including both headers of the +/// next layer: without upstream #2292's re-entry skip a walk rescans them on +/// every one of the 2^depth inclusion paths. +static DEEP_INCLUDE_HEADERS: LazyLock> = LazyLock::new(|| { + let mut headers = Vec::new(); + for depth in 0..DEEP_INCLUDE_LAYERS { + for side in ["a", "b"] { + let guard = format!("H_{depth}_{side}"); + let next = if depth + 1 < DEEP_INCLUDE_LAYERS { + format!("#include \"h{0}_a.h\"\n#include \"h{0}_b.h\"\n", depth + 1) + } else { + String::new() + }; + headers.push(( + format!("src/h{depth}_{side}.h"), + format!( + "#ifndef {guard}\n#define {guard} 1\n{next}#define M_{depth}_{side}(x) (x)\nint f_{depth}_{side}(int);\n#endif\n" + ), + )); + } + } + headers +}); + +/// Upstream #2292: the macro-visibility walk bounded by re-entry skips and an +/// event budget gives the answers of the full walk, so a header that defines +/// `TRACE` leaving the project, behind a deep value-guarded include graph, +/// turns the unchanged unit's `TRACE(1)` from a macro call into a call of the +/// project's `TRACE`, after the sync as on a fresh index. +pub fn cpp_macro_visibility_through_a_deep_include_graph() -> Case { + let mut case = Case::new(Change::DeleteImportedModule, &[Language::Cpp]); + for (path, contents) in DEEP_INCLUDE_HEADERS.iter() { + case = case.file(path.as_str(), contents.as_str()); + } + case.file("src/trace.h", "#define TRACE(v) ((void)(v))\n") + .file( + "src/unit.cpp", + "#include \"h0_a.h\"\n#include \"h0_b.h\"\n#include \"trace.h\"\nvoid unit() { TRACE(1); }\n", + ) + .file( + "src/decoy.cpp", + "void TRACE(int v) {}\nvoid caller() { TRACE(2); }\n", + ) + .remove("src/trace.h") + .edge_throughout("function caller", "calls", "function TRACE @src/decoy.cpp") + .edge_after("function unit", "calls", "function TRACE @src/decoy.cpp") +} + +/// Upstream #946 (W4-CPP-10): a class annotated with a lowercase macro +/// mints no phantom function, before and after an edit of a body in the +/// same file, and an unchanged caller of the real function keeps its edge. +pub fn cpp_lowercase_macro_class_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "src/shapes.cpp", + "class my_export Foo : public Base {\n public:\n void run() {}\n};\nint helper() { return 1; }\n", + ) + .file( + "src/use.cpp", + "int helper();\nint use() { return helper(); }\n", + ) + .write( + "src/shapes.cpp", + "class my_export Foo : public Base {\n public:\n void run() { int x = 0; (void)x; }\n};\nint helper() { return 2; }\n", + ) + .edge_throughout("function use", "calls", "function helper @src/shapes.cpp") +} + +/// Upstream #1353 (W4-CPP-11): a C23 `auto` local no longer hides the call +/// in its initializer, before and after a body edit. +pub fn c_auto_local_initializer_call_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::C]) + .file("src/hb.c", "int get_hb(void) { return 1; }\n") + .file( + "src/q.c", + "int get_hb(void);\nvoid q(void) { auto hb = get_hb(); (void)hb; }\n", + ) + .write( + "src/q.c", + "int get_hb(void);\nvoid q(void) { auto hb = get_hb(); (void)hb; (void)0; }\n", + ) + .edge_throughout("function q", "calls", "function get_hb @src/hb.c") +} + +/// Upstream #1789's prerequisite phase: once `Child` gains `extends Base` +/// in the file that also calls `c.run()` on a `Child`, the call reaches +/// `Base::run`, the extends edge resolved before the call, as on a fresh +/// index. +pub fn java_subclass_gains_a_base_its_caller_reaches() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/app/Base.java", + "package app;\n\npublic class Base {\n public void run() { }\n}\n", + ) + .file( + "src/app/Use.java", + "package app;\n\nclass Child {\n}\n\npublic class Use {\n public void go() {\n Child c = new Child();\n c.run();\n }\n}\n", + ) + .write( + "src/app/Use.java", + "package app;\n\nclass Child extends Base {\n}\n\npublic class Use {\n public void go() {\n Child c = new Child();\n c.run();\n }\n}\n", + ) + .edge_after("method app::Use::go", "calls", "method app::Base::run") +} + +/// Upstream #2172: a bare Kotlin call reaches a member of what the class +/// around it inherits, read from the declarations' heads by name, so a base +/// changed in another file moves the call of an unchanged subclass, as on a +/// fresh index. +pub fn kotlin_bare_call_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Kotlin]) + .file( + "src/app/Root.kt", + "package app\n\nopen class Root {\n fun assist() { }\n}\n", + ) + .file( + "src/app/Other.kt", + "package app\n\nopen class Other {\n fun assist() { }\n}\n", + ) + .file( + "src/app/Base.kt", + "package app\n\nopen class Base : Root()\n", + ) + .file( + "src/app/Child.kt", + "package app\n\nclass Child : Base() {\n fun go() {\n assist()\n }\n}\n", + ) + .write( + "src/app/Base.kt", + "package app\n\nopen class Base : Other()\n", + ) + .edge_before("method app::Child::go", "calls", "method app::Root::assist") + .edge_after( + "method app::Child::go", + "calls", + "method app::Other::assist", + ) +} + +/// Upstream #2172: the members of a type some project function type takes +/// as a receiver are in reach of every bare call, so a file appearing with +/// `Helper.() -> Unit` makes an unchanged file's bare `ping()` reach +/// `Helper::ping`, as on a fresh index. +pub fn kotlin_receiver_type_appears_in_another_file() -> Case { + Case::new(Change::AddCompetitor, &[Language::Kotlin]) + .file( + "src/app/Helper.kt", + "package app\n\nclass Helper {\n fun ping() { }\n}\n", + ) + .file( + "src/app/Use.kt", + "package app\n\nclass Use {\n fun go() {\n ping()\n }\n}\n", + ) + .write( + "src/app/Dsl.kt", + "package app\n\nfun helping(block: Helper.() -> Unit) { }\n", + ) + .edge_after("method app::Use::go", "calls", "method app::Helper::ping") +} + +/// Upstream #2174: a Kotlin infix call is a call, so an infix function +/// appearing in another file gives an unchanged file's `Column().id eq 3` +/// its callee, as on a fresh index. +pub fn kotlin_infix_function_appears_in_another_file() -> Case { + Case::new(Change::AddCompetitor, &[Language::Kotlin]) + .file( + "src/app/Column.kt", + "package app\n\nclass Column {\n val id: Int = 1\n}\n", + ) + .file( + "src/app/Use.kt", + "package app\n\nfun same(): Boolean = Column().id eq 3\n", + ) + .write( + "src/app/Column.kt", + "package app\n\nclass Column {\n val id: Int = 1\n\n infix fun eq(other: Int): Boolean = true\n}\n", + ) + .edge_after("function app::same", "calls", "method app::Column::eq") +} + +/// Upstream #2174: a project's bitwise extension on a number type appearing +/// in another file never takes an unchanged file's `x.inc() and 0xff`, while +/// another type's infix `and` written alongside it does, as on a fresh index. +pub fn kotlin_number_bitwise_extension_appears_in_another_file() -> Case { + Case::new(Change::AddCompetitor, &[Language::Kotlin]) + .file( + "src/app/Use.kt", + "package app\n\nfun low(x: Int): Int = x.inc() and 0xff\n", + ) + .write( + "src/app/Bits.kt", + "package app\n\ninfix fun Int.and(mask: Long): Long = 0\n\nclass Mask {\n infix fun and(other: Int): Int = other\n}\n", + ) + .no_edge_after("function app::low", "calls", "method Int::and") + .edge_after("function app::low", "calls", "method app::Mask::and") +} + +/// Upstream #2182: a `when` guard written into a class keeps the class and +/// its methods, so an unchanged file's call into it stays, as on a fresh +/// index. +pub fn kotlin_when_guard_keeps_the_class_around_it() -> Case { + Case::new(Change::BodyEdit, &[Language::Kotlin]) + .file( + "src/app/Dialect.kt", + "package app\n\nclass Dialect(private val mode: Int) {\n fun sql(value: Any?): String = when (value) {\n is String -> \"text\"\n else -> \"other\"\n }\n\n fun after(): Int = 1\n}\n", + ) + .file( + "src/app/Use.kt", + "package app\n\nfun use(d: Dialect): Int = d.after()\n", + ) + .write( + "src/app/Dialect.kt", + "package app\n\nclass Dialect(private val mode: Int) {\n fun sql(value: Any?): String = when (value) {\n is String if mode == 2 -> \"text\"\n else -> $$\"other $mode\"\n }\n\n fun after(): Int = 1\n}\n", + ) + .edge_before("function app::use", "calls", "method app::Dialect::after") + .edge_after("function app::use", "calls", "method app::Dialect::after") +} + +/// Upstream #2206: a lambda runs on the receiver its function's last +/// parameter type names, so that function's declaration changing in another +/// file moves the bare call inside an unchanged lambda, as on a fresh index. +pub fn kotlin_lambda_receiver_changes_in_another_file() -> Case { + Case::new(Change::BodyEdit, &[Language::Kotlin]) + .file( + "src/app/Scopes.kt", + "package app\n\nclass Scope {\n fun get(): Int = 1\n}\n\nclass Koin {\n fun get(): Int = 2\n}\n", + ) + .file( + "src/app/Module.kt", + "package app\n\nclass Module {\n fun single(definition: Scope.() -> Int) { }\n}\n", + ) + .file( + "src/app/Perfs.kt", + "package app\n\nfun perfModule(m: Module) {\n m.single { get() }\n}\n", + ) + .write( + "src/app/Module.kt", + "package app\n\nclass Module {\n fun single(definition: Koin.() -> Int) { }\n}\n", + ) + .edge_before("function app::perfModule", "calls", "method app::Scope::get") + .edge_after("function app::perfModule", "calls", "method app::Koin::get") +} + +/// Upstream #2196: the outside-import rule reads the project's packages for +/// Kotlin too: a Kotlin file appearing in `com.lib` makes an unchanged +/// Kotlin file's `Tools.make()` under `import com.lib.Tools` a project name, +/// as on a fresh index. +pub fn kotlin_import_package_appears_in_the_project() -> Case { + Case::new(Change::AddImportTarget, &[Language::Kotlin]) + .file( + "src/app/Tools.kt", + "package app\n\nobject Tools {\n fun make() { }\n}\n", + ) + .file( + "src/app/Use.kt", + "package app\n\nimport com.lib.Tools\n\nclass Use {\n fun go() {\n Tools.make()\n }\n}\n", + ) + .write( + "src/com/lib/Other.kt", + "package com.lib\n\nclass Other\n", + ) + .edge_after("method app::Use::go", "calls", "method app::Tools::make") +} + +/// Upstream #2232: a Gradle script's bare call reaches a member only of an +/// owner the project declares no class for, so a class of that name +/// appearing makes an unchanged script's call to an extension on it stop, +/// as on a fresh index. +pub fn kotlin_gradle_script_owner_becomes_a_project_class() -> Case { + Case::new(Change::AddCompetitor, &[Language::Kotlin]) + .file( + "build-support/src/main/kotlin/SourceSets.kt", + "fun NamedThings.createSourceSet(name: String) {\n}\n", + ) + .file( + "app/build.gradle.kts", + "kotlin {\n createSourceSet(\"nonJvm\")\n}\n", + ) + .write( + "build-support/src/main/kotlin/NamedThings.kt", + "class NamedThings\n", + ) + .edge_before( + "file:app/build.gradle.kts", + "calls", + "method NamedThings::createSourceSet", + ) + .no_edge_after( + "file:app/build.gradle.kts", + "calls", + "method NamedThings::createSourceSet", + ) +} + +/// Upstream #2172: the hierarchy walk takes Android's platform ancestry +/// between project types, so a project `Context` changing its base moves the +/// bare call of an unchanged `Screen : MyActivity`, `MyActivity : +/// AppCompatActivity`, as on a fresh index. +pub fn kotlin_platform_ancestry_chain_follows_a_project_head_change() -> Case { + Case::new(Change::EditHeritage, &[Language::Kotlin]) + .file( + "src/app/Root.kt", + "package app\n\nopen class Root {\n fun assist() { }\n}\n", + ) + .file( + "src/app/Other.kt", + "package app\n\nopen class Other {\n fun assist() { }\n}\n", + ) + .file("src/app/Context.kt", "package app\n\nopen class Context : Root()\n") + .file( + "src/app/MyActivity.kt", + "package app\n\nopen class MyActivity : AppCompatActivity()\n", + ) + .file( + "src/app/Screen.kt", + "package app\n\nclass Screen : MyActivity() {\n fun go() {\n assist()\n }\n}\n", + ) + .write("src/app/Context.kt", "package app\n\nopen class Context : Other()\n") + .edge_before("method app::Screen::go", "calls", "method app::Root::assist") + .edge_after("method app::Screen::go", "calls", "method app::Other::assist") +} + +/// A Kotlin function body edit moves no head, receiver or lambda type: an +/// unchanged subclass's bare call stays where it was, as on a fresh index. +pub fn kotlin_base_body_edit_keeps_a_bare_call() -> Case { + Case::new(Change::BodyEdit, &[Language::Kotlin]) + .file( + "src/app/Base.kt", + "package app\n\nopen class Base {\n fun assist() { }\n}\n", + ) + .file( + "src/app/Child.kt", + "package app\n\nclass Child : Base() {\n fun go() {\n assist()\n }\n}\n", + ) + .write( + "src/app/Base.kt", + "package app\n\nopen class Base {\n fun assist() {\n println(1)\n }\n}\n", + ) + .edge_throughout("method app::Child::go", "calls", "method app::Base::assist") +} + +/// Upstream #2172 in a batch: the base and the type between it and an +/// unchanged subclass both change in one sync; the subclass's bare call +/// reaches what the new middle type names, as on a fresh index. +pub fn kotlin_batch_rewrites_a_base_and_a_middle_type() -> Case { + Case::new(Change::EditHeritage, &[Language::Kotlin]) + .file( + "src/app/Root.kt", + "package app\n\nopen class Root {\n fun assist() { }\n}\n", + ) + .file( + "src/app/Other.kt", + "package app\n\nopen class Other {\n fun assist() { }\n}\n", + ) + .file( + "src/app/Spare.kt", + "package app\n\nopen class Spare {\n fun assist() { }\n}\n", + ) + .file( + "src/app/Base.kt", + "package app\n\nopen class Base : Root()\n", + ) + .file("src/app/Mid.kt", "package app\n\nopen class Mid : Base()\n") + .file( + "src/app/Child.kt", + "package app\n\nclass Child : Mid() {\n fun go() {\n assist()\n }\n}\n", + ) + .write( + "src/app/Base.kt", + "package app\n\nopen class Base : Other()\n", + ) + .write( + "src/app/Mid.kt", + "package app\n\nopen class Mid : Spare()\n", + ) + .edge_before("method app::Child::go", "calls", "method app::Root::assist") + .edge_after( + "method app::Child::go", + "calls", + "method app::Spare::assist", + ) +} + +/// Upstream #2206 through a typealias: the alias changing the receiver it +/// names moves the bare call inside an unchanged lambda, as on a fresh index. +pub fn kotlin_lambda_receiver_changes_through_a_typealias() -> Case { + Case::new(Change::BodyEdit, &[Language::Kotlin]) + .file( + "src/app/Scopes.kt", + "package app\n\nclass Scope {\n fun get(): Int = 1\n}\n\nclass Koin {\n fun get(): Int = 2\n}\n", + ) + .file( + "src/app/Definition.kt", + "package app\n\ntypealias Definition = Scope.() -> Int\n", + ) + .file( + "src/app/Module.kt", + "package app\n\nclass Module {\n fun single(definition: Definition) { }\n}\n", + ) + .file( + "src/app/Perfs.kt", + "package app\n\nfun perfModule(m: Module) {\n m.single { get() }\n}\n", + ) + .write( + "src/app/Definition.kt", + "package app\n\ntypealias Definition = Koin.() -> Int\n", + ) + .edge_before("function app::perfModule", "calls", "method app::Scope::get") + .edge_after("function app::perfModule", "calls", "method app::Koin::get") +} + +/// Upstream #2172 and #2232: a Gradle build script's walk starts from +/// `Project`, so a project class of that name changing its base moves which +/// outside type's extension an unchanged script's bare call takes, as on a +/// fresh index. +pub fn kotlin_gradle_script_follows_a_project_head_change() -> Case { + Case::new(Change::EditHeritage, &[Language::Kotlin]) + .file( + "build-logic/src/main/kotlin/Ext.kt", + "fun Base.foo() { }\n\nfun Other.foo() { }\n", + ) + .file( + "build-logic/src/main/kotlin/Project.kt", + "abstract class Project : Base()\n", + ) + .file("app/build.gradle.kts", "foo()\n") + .write( + "build-logic/src/main/kotlin/Project.kt", + "abstract class Project : Other()\n", + ) + .edge_before("file:app/build.gradle.kts", "calls", "method Base::foo") + .edge_after("file:app/build.gradle.kts", "calls", "method Other::foo") +} + +/// Upstream #912: Lombok's members are nodes of the class's file, so `@Data` +/// written onto a class gives an unchanged file's `user.getName()` its +/// target, as on a fresh index. +pub fn java_lombok_data_appears_on_a_class() -> Case { + Case::new(Change::AddCompetitor, &[Language::Java]) + .file( + "model/User.java", + "package model;\n\npublic class User {\n private String name;\n}\n", + ) + .file( + "svc/UserService.java", + "package svc;\n\nimport model.User;\n\nclass UserService {\n String describe(User user) {\n return user.getName();\n }\n}\n", + ) + .write( + "model/User.java", + "package model;\n\nimport lombok.Data;\n\n@Data\npublic class User {\n private String name;\n}\n", + ) + .edge_after( + "method svc::UserService::describe", + "calls", + "method model::User::getName", + ) +} + +/// Upstream #912: a hand-written accessor replaces the one Lombok would +/// generate, so an unchanged caller's edge moves to it, as on a fresh index. +pub fn java_lombok_hand_written_getter_replaces_the_generated_one() -> Case { + Case::new(Change::BodyEdit, &[Language::Java]) + .file( + "model/Account.java", + "package model;\n\nimport lombok.Getter;\n\n@Getter\npublic class Account {\n private int balance;\n}\n", + ) + .file( + "svc/Report.java", + "package svc;\n\nimport model.Account;\n\nclass Report {\n int total(Account account) {\n return account.getBalance();\n }\n}\n", + ) + .write( + "model/Account.java", + "package model;\n\nimport lombok.Getter;\n\n@Getter\npublic class Account {\n private int balance;\n\n public int getBalance() { return balance < 0 ? 0 : balance; }\n}\n", + ) + .edge_before("method svc::Report::total", "calls", "method model::Account::getBalance") + .edge_after("method svc::Report::total", "calls", "method model::Account::getBalance") +} + +/// Upstream #912: a field leaving a `@Data` class takes its accessors with +/// it, so an unchanged caller's edge goes, as on a fresh index (where the +/// accessor no longer exists). +pub fn java_lombok_field_removal_drops_its_accessor() -> Case { + Case::new(Change::BodyEdit, &[Language::Java]) + .file( + "model/User.java", + "package model;\n\nimport lombok.Data;\n\n@Data\npublic class User {\n private String name;\n private int age;\n}\n", + ) + .file( + "svc/UserService.java", + "package svc;\n\nimport model.User;\n\nclass UserService {\n int age(User user) {\n return user.getAge();\n }\n}\n", + ) + .write( + "model/User.java", + "package model;\n\nimport lombok.Data;\n\n@Data\npublic class User {\n private String name;\n}\n", + ) + .edge_before("method svc::UserService::age", "calls", "method model::User::getAge") +} + +/// Upstream #2194: a Java enum constant's body declares methods, so an edit +/// to that body gains the call it adds, as on a fresh index. +pub fn java_enum_constant_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Java]) + .file( + "src/app/Util.java", + "package app;\n\npublic class Util {\n public static int twice(int x) { return 2 * x; }\n}\n", + ) + .file( + "src/app/Op.java", + "package app;\n\npublic enum Op {\n PLUS {\n int apply(int a) { return a; }\n };\n\n abstract int apply(int a);\n}\n", + ) + .write( + "src/app/Op.java", + "package app;\n\npublic enum Op {\n PLUS {\n int apply(int a) { return Util.twice(a); }\n };\n\n abstract int apply(int a);\n}\n", + ) + .edge_after("method app::Op::PLUS::apply", "calls", "method app::Util::twice") +} + +/// Upstream #2194: a Kotlin enum entry's body declares methods, so an edit to +/// that body gains the call it adds, as on a fresh index. +pub fn kotlin_enum_entry_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Kotlin]) + .file( + "src/app/Tools.kt", + "package app\n\nfun size(): Int = 3\n", + ) + .file( + "src/app/Factory.kt", + "package app\n\nenum class Factory {\n NewBuffer {\n override fun pipe(): Int = 1\n };\n\n abstract fun pipe(): Int\n}\n", + ) + .write( + "src/app/Factory.kt", + "package app\n\nenum class Factory {\n NewBuffer {\n override fun pipe(): Int = size()\n };\n\n abstract fun pipe(): Int\n}\n", + ) + .edge_after("method app::Factory::NewBuffer::pipe", "calls", "function app::size") +} + +/// Upstream #2126: a bare call reaches a method of a supertype the classes +/// around it name in their heads, so a base changed in another file moves the +/// call of an unchanged subclass, as on a fresh index. +pub fn java_bare_call_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/app/Root.java", + "package app;\n\npublic class Root {\n protected void assist() { }\n}\n", + ) + .file( + "src/app/Other.java", + "package app;\n\npublic class Other {\n protected void assist() { }\n}\n", + ) + .file( + "src/app/Base.java", + "package app;\n\npublic class Base extends Root {\n}\n", + ) + .file( + "src/app/Child.java", + "package app;\n\npublic class Child extends Base {\n void go() {\n assist();\n }\n}\n", + ) + .write( + "src/app/Base.java", + "package app;\n\npublic class Base extends Other {\n}\n", + ) + .edge_before("method app::Child::go", "calls", "method app::Root::assist") + .edge_after("method app::Child::go", "calls", "method app::Other::assist") +} + +/// Upstream #2126: the scope walk reads every declaration of a type's name, +/// so a head changed in another package's same-named class moves the bare +/// call of an unchanged class of that name, as on a fresh index. +pub fn java_bare_call_follows_a_same_named_head_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/lib/Root.java", + "package lib;\n\npublic class Root {\n public void assist() { }\n}\n", + ) + .file( + "src/lib/Other.java", + "package lib;\n\npublic class Other {\n public void assist() { }\n}\n", + ) + .file( + "src/lib/Node.java", + "package lib;\n\npublic class Node extends Root {\n}\n", + ) + .file( + "src/app/Node.java", + "package app;\n\npublic class Node {\n void go() {\n assist();\n }\n}\n", + ) + .write( + "src/lib/Node.java", + "package lib;\n\npublic class Node extends Other {\n}\n", + ) + .edge_before("method app::Node::go", "calls", "method lib::Root::assist") + .edge_after("method app::Node::go", "calls", "method lib::Other::assist") +} + +/// #2126 with an anonymous class (KEEP-RUST): it derives from what it +/// instantiates, so that interface changing its base in another file moves +/// a bare call in the unchanged anonymous class, as on a fresh index. +pub fn java_anonymous_class_follows_an_interface_head_change() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/lib/Left.java", + "package lib;\n\npublic interface Left {\n default void step() { }\n}\n", + ) + .file( + "src/lib/Right.java", + "package lib;\n\npublic interface Right {\n default void step() { }\n}\n", + ) + .file( + "src/lib/Walker.java", + "package lib;\n\npublic interface Walker extends Left {\n}\n", + ) + .file( + "src/app/Tree.java", + "package app;\n\nimport lib.Walker;\n\npublic class Tree {\n Object walk() {\n return new Walker() {\n void next() {\n step();\n }\n };\n }\n}\n", + ) + .write( + "src/lib/Walker.java", + "package lib;\n\npublic interface Walker extends Right {\n}\n", + ) + .edge_before( + "method app::Tree::walk::::next", + "calls", + "method lib::Left::step", + ) + .edge_after( + "method app::Tree::walk::::next", + "calls", + "method lib::Right::step", + ) +} + +/// Upstream #2126 reads a head's six lines as written, a `? extends X` in +/// the class body's first lines included: editing that body text moves the +/// bare call of an unchanged subclass, though no declaration's `extends` +/// changed, as on a fresh index. +pub fn java_head_window_text_moves_a_subclass_bare_call() -> Case { + Case::new(Change::BodyEdit, &[Language::Java]) + .file( + "src/app/Root.java", + "package app;\n\npublic class Root {\n protected void assist() { }\n}\n", + ) + .file( + "src/app/Other.java", + "package app;\n\npublic class Other {\n protected void assist() { }\n}\n", + ) + .file( + "src/app/Base.java", + "package app;\n\npublic class Base {\n java.util.List items;\n void touch() {\n }\n}\n", + ) + .file( + "src/app/Child.java", + "package app;\n\npublic class Child extends Base {\n void go() {\n assist();\n }\n}\n", + ) + .write( + "src/app/Base.java", + "package app;\n\npublic class Base {\n java.util.List items;\n void touch() {\n }\n}\n", + ) + .edge_before("method app::Child::go", "calls", "method app::Root::assist") + .edge_after("method app::Child::go", "calls", "method app::Other::assist") +} + +/// Upstream #2126 in a batch: the base and the type between it and an +/// unchanged subclass both change in one sync; the subclass's bare call +/// reaches what the new middle type names, as on a fresh index. +pub fn java_batch_rewrites_a_base_and_a_middle_type() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/app/Root.java", + "package app;\n\npublic class Root {\n protected void assist() { }\n}\n", + ) + .file( + "src/app/Other.java", + "package app;\n\npublic class Other {\n protected void assist() { }\n}\n", + ) + .file( + "src/app/Spare.java", + "package app;\n\npublic class Spare {\n protected void assist() { }\n}\n", + ) + .file( + "src/app/Base.java", + "package app;\n\npublic class Base extends Root {\n}\n", + ) + .file( + "src/app/Mid.java", + "package app;\n\npublic class Mid extends Base {\n}\n", + ) + .file( + "src/app/Child.java", + "package app;\n\npublic class Child extends Mid {\n void go() {\n assist();\n }\n}\n", + ) + .write( + "src/app/Base.java", + "package app;\n\npublic class Base extends Other {\n}\n", + ) + .write( + "src/app/Mid.java", + "package app;\n\npublic class Mid extends Spare {\n}\n", + ) + .edge_before("method app::Child::go", "calls", "method app::Root::assist") + .edge_after("method app::Child::go", "calls", "method app::Spare::assist") +} + +/// A file with two same-named interfaces, each extending thirty names: the +/// scope walk of a bare call reads them in file order and stops at forty +/// names, so whichever comes first is the one whose names it reaches. +fn heads_at_the_limit(b_first: bool) -> String { + let names = |prefix: char| { + (1..=30) + .map(|n| format!("{prefix}{n}")) + .collect::>() + .join(", ") + }; + let a = format!( + "class Front {{\n interface Node extends {} {{ }}\n}}\n", + names('A') + ); + let b = format!( + "class Back {{\n interface Node extends {} {{ }}\n}}\n", + names('B') + ); + let (first, second) = if b_first { (b, a) } else { (a, b) }; + format!("package app;\n\n{first}\n{second}") +} + +static HEADS_B_FIRST: LazyLock = LazyLock::new(|| heads_at_the_limit(true)); +static HEADS_A_FIRST: LazyLock = LazyLock::new(|| heads_at_the_limit(false)); + +/// Upstream #2126's walk stops at forty names: two same-named interfaces in +/// one file trading places, nothing else changing, decide which of their +/// supertypes an unchanged caller's bare call can reach, as on a fresh +/// index. +pub fn java_same_named_heads_trade_places_at_the_walk_limit() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/app/B20.java", + "package app;\n\npublic interface B20 {\n default void reachB() { }\n}\n", + ) + .file( + "src/app/Caller.java", + "package app;\n\npublic class Caller implements Node {\n void go() {\n reachB();\n }\n}\n", + ) + .file("src/app/Heads.java", HEADS_B_FIRST.as_str()) + .write("src/app/Heads.java", HEADS_A_FIRST.as_str()) + .edge_before("method app::Caller::go", "calls", "method app::B20::reachB") + .no_edge_after("method app::Caller::go", "calls", "method app::B20::reachB") +} + +/// Upstream #2236: a nested type is in reach from a subclass of its owner, +/// the heads read from source, so a base changed in another file moves the +/// type reference of an unchanged subclass, as on a fresh index. +pub fn java_nested_type_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/lib/Widget.java", + "package lib;\n\npublic class Widget {\n public static class Part { }\n}\n", + ) + .file( + "src/other/Holder.java", + "package other;\n\npublic class Holder {\n public static class Part { }\n}\n", + ) + .file( + "src/app/Mid.java", + "package app;\n\npublic class Mid extends lib.Widget {\n}\n", + ) + .file( + "src/app/Sub.java", + "package app;\n\npublic class Sub extends Mid {\n Part part;\n}\n", + ) + .write( + "src/app/Mid.java", + "package app;\n\npublic class Mid extends other.Holder {\n}\n", + ) + .edge_before( + "field app::Sub::part", + "references", + "class lib::Widget::Part", + ) + .edge_after( + "field app::Sub::part", + "references", + "class other::Holder::Part", + ) +} + +/// Upstream #2236: a nested type of an unrelated class is no candidate, so a +/// namesake appearing in another package leaves the subclass's reference +/// where it was. +pub fn java_nested_type_ignores_an_added_namesake() -> Case { + Case::new(Change::AddCompetitor, &[Language::Java]) + .file( + "src/lib/Widget.java", + "package lib;\n\npublic class Widget {\n public static class Part { }\n}\n", + ) + .file( + "src/app/Sub.java", + "package app;\n\npublic class Sub extends lib.Widget {\n Part part;\n}\n", + ) + .write( + "src/app/Holder.java", + "package app;\n\npublic class Holder {\n public static class Part { }\n}\n", + ) + .edge_throughout( + "field app::Sub::part", + "references", + "class lib::Widget::Part", + ) +} + +/// Upstream #2446: a base class defined in a variable declaration (`class +/// Base { … } base_instance;`) is a node, so a heritage edit in another file +/// moves the `extends` edge from one such class to another, as on a fresh +/// index. +pub fn cpp_declared_base_class_heritage_edit() -> Case { + Case::new(Change::EditHeritage, &[Language::Cpp]) + .file( + "src/bases.hpp", + "namespace app {\nclass Base { public: virtual int run() { return 1; } } base_instance;\nclass Other { public: virtual int run() { return 2; } } other_instance;\n} // namespace app\n", + ) + .file( + "src/worker.cc", + "#include \"bases.hpp\"\n\nnamespace app {\nclass Worker : public Base {\n public:\n int run() override { return 3; }\n};\n} // namespace app\n", + ) + .write( + "src/worker.cc", + "#include \"bases.hpp\"\n\nnamespace app {\nclass Worker : public Other {\n public:\n int run() override { return 3; }\n};\n} // namespace app\n", + ) + .edge_before("class app::Worker", "extends", "class app::Base") + .edge_after("class app::Worker", "extends", "class app::Other") + .no_edge_after("class app::Worker", "extends", "class app::Base") +} + +/// Upstream #2378's identifier grammar: `export default $fmt;` that comes +/// to name `$other` moves an unchanged default importer, as on a fresh +/// index, rather than both falling back to the first exported function. +pub fn typescript_dollar_default_export_names_another_declaration() -> Case { + Case::new(Change::ReexportEdit, &[Language::TypeScript]) + .file( + "src/fmt.ts", + "export function helper(value: string) {\n return value;\n}\n\nconst $fmt = (value: string) => value + '!';\nconst $other = (value: string) => value + '?';\n\nexport default $fmt;\n", + ) + .file( + "src/use.ts", + "import fmt from './fmt';\n\nexport function show(value: string) {\n return fmt(value);\n}\n", + ) + .write( + "src/fmt.ts", + "export function helper(value: string) {\n return value;\n}\n\nconst $fmt = (value: string) => value + '!';\nconst $other = (value: string) => value + '?';\n\nexport default $other;\n", + ) + .edge_before("function show", "calls", "function $fmt") + .edge_after("function show", "calls", "function $other") + .no_edge_after("function show", "calls", "function helper") +} + +/// Upstream #2378: a `$`-named default import binds its module's default +/// export once the module appears, ahead of another file's namesake. +pub fn typescript_dollar_import_reaches_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file( + "src/a/other.ts", + "export function $fmt(value: string) {\n return value + '!';\n}\n", + ) + .file( + "src/use.ts", + "import $fmt from './fmt';\n\nexport function show(value: string) {\n return $fmt(value);\n}\n", + ) + .write( + "src/fmt.ts", + "export default function $fmt(value: string) {\n return value;\n}\n", + ) + .edge_after("function show", "calls", "function $fmt @src/fmt.ts") + .no_edge_after("function show", "calls", "function $fmt @src/a/other.ts") +} + +/// Upstream #2392 (3): a Svelte import links the component file once it appears. +pub fn svelte_import_of_an_added_component() -> Case { + Case::new(Change::AddImportTarget, &[Language::Svelte]) + .file( + "src/App.svelte", + "\n\n", + ) + .write("src/Child.svelte", "

      child

      \n") + .edge_after("file:src/App.svelte", "imports", "file:src/Child.svelte") +} + +/// Upstream #2392 (3): a Vue import links the component file once it appears. +pub fn vue_import_of_an_added_component() -> Case { + Case::new(Change::AddImportTarget, &[Language::Vue]) + .file( + "src/App.vue", + "\n\n", + ) + .write("src/Foo.vue", "\n") + .edge_after("file:src/App.vue", "imports", "file:src/Foo.vue") +} + +pub fn go_call_into_an_added_file_of_the_same_package() -> Case { + Case::new(Change::AddImportTarget, &[Language::Go]) + .file("go.mod", "module example.com/shop\n\ngo 1.22\n") + .file( + "main.go", + "package main\n\nfunc main() {\n\tprintln(Total(2, 3))\n}\n", + ) + .write( + "total.go", + "package main\n\nfunc Total(a, b int) int {\n\treturn a + b\n}\n", + ) + .edge_after("function main", "calls", "function Total @total.go") +} + +/// A file whose parse collapsed records the error on its row (the status +/// report's `filesWithParseErrors`); once fixed, the synced row and symbols +/// equal a fresh index's, error cleared. +pub fn typescript_collapsed_parse_is_fixed() -> Case { + Case::new(Change::AddImportTarget, &[Language::TypeScript]) + .file("src/util.ts", "@@@ ### ((( === }}} <<<\n") + .file( + "src/main.ts", + "import { helper } from './util';\n\nexport function main(): number {\n return helper();\n}\n", + ) + .write( + "src/util.ts", + "export function helper(): number {\n return 1;\n}\n", + ) + .edge_after("function main", "calls", "function helper @src/util.ts") +} + +/// G2: a Go composite literal instantiates its struct, which appears later. +pub fn go_composite_literal_of_an_added_struct() -> Case { + Case::new(Change::AddImportTarget, &[Language::Go]) + .file("go.mod", "module example.com/shop\n\ngo 1.22\n") + .file( + "main.go", + "package main\n\nfunc main() {\n\tw := Widget{N: 1}\n\tprintln(w.N)\n}\n", + ) + .write( + "widget.go", + "package main\n\ntype Widget struct {\n\tN int\n}\n", + ) + .edge_after("function main", "instantiates", "struct Widget @widget.go") +} + +/// G3: a Go parameter type is a reference to a struct that appears later. +pub fn go_parameter_type_of_an_added_struct() -> Case { + Case::new(Change::AddImportTarget, &[Language::Go]) + .file("go.mod", "module example.com/shop\n\ngo 1.22\n") + .file( + "main.go", + "package main\n\nfunc Ship(o Order) int {\n\treturn 1\n}\n", + ) + .write( + "order.go", + "package main\n\ntype Order struct {\n\tID int\n}\n", + ) + .edge_after("function Ship", "references", "struct Order @order.go") +} + +/// G3: a Java parameter type is a reference to a class that appears later in +/// the same package. +pub fn java_parameter_type_of_an_added_class() -> Case { + Case::new(Change::AddImportTarget, &[Language::Java]) + .file( + "src/zoo/Keeper.java", + "package zoo;\n\npublic class Keeper {\n public int feed(Animal animal) {\n return 1;\n }\n}\n", + ) + .write( + "src/zoo/Animal.java", + "package zoo;\n\npublic class Animal {\n}\n", + ) + .edge_after("method zoo::Keeper::feed", "references", "class zoo::Animal") +} + +/// G3: a C# parameter type is a reference to a class that appears later. +pub fn csharp_parameter_type_of_an_added_class() -> Case { + Case::new(Change::AddImportTarget, &[Language::CSharp]) + .file( + "src/Keeper.cs", + "namespace Zoo\n{\n public class Keeper\n {\n public int Feed(Animal animal) { return 1; }\n }\n}\n", + ) + .write( + "src/Animal.cs", + "namespace Zoo\n{\n public class Animal { }\n}\n", + ) + .edge_after("method Zoo::Keeper::Feed", "references", "class Zoo::Animal") +} + +/// W2-03d: a Kotlin property's declared type is a reference from the property +/// to a class that appears later in the same package. +pub fn kotlin_property_type_of_an_added_class() -> Case { + Case::new(Change::AddImportTarget, &[Language::Kotlin]) + .file( + "src/zoo/Keeper.kt", + "package zoo\n\nclass Keeper {\n private val animal: Animal? = null\n}\n", + ) + .write("src/zoo/Animal.kt", "package zoo\n\nclass Animal\n") + .edge_after( + "field zoo::Keeper::animal", + "references", + "class zoo::Animal", + ) +} + +/// W2-03d: a PHP property's declared type is a reference from its field to a +/// class that appears later in the same namespace. +pub fn php_property_type_of_an_added_class() -> Case { + Case::new(Change::AddImportTarget, &[Language::Php]) + .file( + "src/Keeper.php", + " Case { + Case::new(Change::AddImportTarget, &[Language::C]) + .file( + "main.c", + "#include \"util.h\"\n\nint main(void) {\n return helper(1);\n}\n", + ) + .file( + "util.c", + "int helper(int value) {\n return value + 1;\n}\n", + ) + .write("util.h", "int helper(int value);\n") + .edge_after("file:main.c", "imports", "file:util.h") + .edge_after("function main", "calls", "function helper @util.c") +} + +/// Upstream #2403: a Liquid `render` links the snippet a sync adds later. +pub fn liquid_render_of_an_added_snippet() -> Case { + Case::new(Change::AddImportTarget, &[Language::Liquid]) + .file( + "templates/product.liquid", + "
      \n {% render 'price' %}\n
      \n", + ) + .write( + "snippets/price.liquid", + "{{ product.price }}\n", + ) + .edge_after( + "file:templates/product.liquid", + "references", + "file:snippets/price.liquid", + ) +} + +/// Upstream #2403: a JSON template's section `type` links the section a sync +/// adds later, inside the template's theme. +pub fn liquid_json_template_section_of_an_added_section() -> Case { + Case::new(Change::AddImportTarget, &[Language::Liquid]) + .file("layout/theme.liquid", "{{ content_for_layout }}\n") + .file( + "templates/404.json", + "{\"sections\":{\"main\":{\"type\":\"404\"}},\"order\":[\"main\"]}", + ) + .write("sections/404.liquid", "

      404

      \n") + .edge_after( + "file:templates/404.json", + "references", + "file:sections/404.liquid", + ) +} + +pub fn razor_reference_to_an_added_class() -> Case { + Case::new(Change::AddImportTarget, &[Language::Razor, Language::CSharp]) + .file( + "Pages/Index.cshtml", + "@page\n@{\n var total = Calculator.Add(1, 2);\n}\n

      @total

      \n", + ) + .write( + "Pages/Calculator.cs", + "public static class Calculator\n{\n public static int Add(int a, int b) { return a + b; }\n}\n", + ) + .edge_after( + "component Pages/Index.cshtml::Index", + "references", + "class Calculator", + ) +} + +pub fn r_source_of_an_added_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::R]) + .file( + "analysis.R", + "source(\"utils.R\")\n\nsummarise <- function(values) {\n scale_values(values)\n}\n", + ) + .write( + "utils.R", + "scale_values <- function(values) {\n values / max(values)\n}\n", + ) + .edge_after("file:analysis.R", "imports", "file:utils.R") + .edge_after("function summarise", "calls", "function scale_values") +} + +const ERLANG_MAIN: &str = "-module(main).\n-export([run/1]).\n\nrun(X) -> util:double(X).\n"; +const ERLANG_UTIL: &str = "-module(util).\n-export([double/1]).\n\ndouble(X) -> X * 2.\n"; +const USER_MAPPER: &str = "\n\n \n\n"; +const COMMON_MAPPER: &str = "\n\n id, name, email\n\n"; + +/// Found by W0-04: the call is parked as `util::double/1`, which no added +/// node is named; it is retried by its tail (W5-05a) and by its whole +/// qualified name (W5-05). +pub fn erlang_remote_call_into_an_added_module() -> Case { + Case::new(Change::AddImportTarget, &[Language::Erlang]) + .file("src/main.erl", ERLANG_MAIN) + .write("src/util.erl", ERLANG_UTIL) + .edge_after("function main::run/1", "calls", "function util::double/1") +} + +/// Found by W0-04: the include is parked as `app.CommonMapper::userColumns`, +/// which no added node is named; it is retried by its tail (W5-05a) and by +/// its whole qualified name (W5-05). +pub fn mybatis_include_of_an_added_fragment() -> Case { + Case::new(Change::AddImportTarget, &[Language::Xml]) + .file("mapper/UserMapper.xml", USER_MAPPER) + .write("mapper/CommonMapper.xml", COMMON_MAPPER) + .edge_after( + "method app.UserMapper::findAll", + "references", + "method app.CommonMapper::userColumns", + ) +} + +/// More unresolved `….double` and `….userColumns` calls than one tail +/// retries in a sync: CFML calls on receivers nothing is named after. +static UNTYPED_DOUBLE_CALLS: LazyLock = LazyLock::new(|| untyped_calls("double")); +static UNTYPED_USER_COLUMNS_CALLS: LazyLock = + LazyLock::new(|| untyped_calls("userColumns")); + +fn untyped_calls(method: &str) -> String { + let mut text = String::from("component {\n function noise() {\n"); + for index in 0..501 { + text.push_str(&format!(" n{index}.{method}({index});\n")); + } + text.push_str(" return 0;\n }\n}\n"); + text +} + +/// W5-05: the parked remote call is found by its whole qualified name when +/// more parked calls share its tail than one tail retries. +pub fn erlang_remote_call_into_an_added_module_past_the_tail_ceiling() -> Case { + Case::new(Change::AddImportTarget, &[Language::Erlang, Language::Cfml]) + .file("src/main.erl", ERLANG_MAIN) + .file("noise/Noise.cfc", UNTYPED_DOUBLE_CALLS.as_str()) + .write("src/util.erl", ERLANG_UTIL) + .edge_after("function main::run/1", "calls", "function util::double/1") +} + +/// W5-05, found by its plan gate: the module renamed in place keeps every +/// node id (an id holds no qualified name), and the parked call is found by +/// the qualified name the rename gave the function, past the tail ceiling. +pub fn erlang_remote_call_follows_a_module_renamed_in_place_past_the_tail_ceiling() -> Case { + Case::new(Change::RenameSymbol, &[Language::Erlang, Language::Cfml]) + .file("src/main.erl", ERLANG_MAIN) + .file( + "src/util.erl", + "-module(utils).\n-export([double/1]).\n\ndouble(X) -> X * 2.\n", + ) + .file("noise/Noise.cfc", UNTYPED_DOUBLE_CALLS.as_str()) + .write("src/util.erl", ERLANG_UTIL) + .edge_after("function main::run/1", "calls", "function util::double/1") +} + +/// W5-05, found by its plan gate: a mapper namespace renamed in place, past +/// the tail ceiling. +pub fn mybatis_include_follows_a_namespace_renamed_in_place_past_the_tail_ceiling() -> Case { + Case::new(Change::RenameSymbol, &[Language::Xml, Language::Cfml]) + .file("mapper/UserMapper.xml", USER_MAPPER) + .file( + "mapper/CommonMapper.xml", + "\n\n id, name, email\n\n", + ) + .file("noise/Noise.cfc", UNTYPED_USER_COLUMNS_CALLS.as_str()) + .write("mapper/CommonMapper.xml", COMMON_MAPPER) + .edge_after( + "method app.UserMapper::findAll", + "references", + "method app.CommonMapper::userColumns", + ) +} + +/// W5-05: the module the remote call reaches is deleted. +pub fn erlang_remote_call_into_a_deleted_module() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Erlang]) + .file("src/main.erl", ERLANG_MAIN) + .file("src/util.erl", ERLANG_UTIL) + .edge_before("function main::run/1", "calls", "function util::double/1") + .remove("src/util.erl") +} + +/// W5-05: the mapper the include reaches is deleted. +pub fn mybatis_include_of_a_deleted_fragment() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Xml]) + .file("mapper/UserMapper.xml", USER_MAPPER) + .file("mapper/CommonMapper.xml", COMMON_MAPPER) + .edge_before( + "method app.UserMapper::findAll", + "references", + "method app.CommonMapper::userColumns", + ) + .remove("mapper/CommonMapper.xml") +} + +// ----- a body changes, every declaration keeps its identity ---------------- + +/// Upstream #2288: same-named methods of one file keep their typed callers +/// across a body-only edit of that file. +pub fn typescript_pr2288_same_named_methods_keep_typed_callers() -> Case { + const RUNNERS: &str = "export class Alpha {\n run(): number {\n return 1;\n }\n}\n\nexport class Beta {\n run(): number {\n return 2;\n }\n}\n"; + const RUNNERS_EDITED: &str = "export class Alpha {\n run(): number {\n return 10;\n }\n}\n\nexport class Beta {\n run(): number {\n return 20;\n }\n}\n"; + Case::new(Change::BodyEdit, &[Language::TypeScript]) + .file("src/runners.ts", RUNNERS) + .file( + "src/main.ts", + "import { Alpha, Beta } from './runners';\n\nexport function runBoth(alpha: Alpha, beta: Beta): number {\n return alpha.run() + beta.run();\n}\n", + ) + .write("src/runners.ts", RUNNERS_EDITED) + .edge_throughout("function runBoth", "calls", "method Alpha::run") + .edge_throughout("function runBoth", "calls", "method Beta::run") +} + +/// Upstream #2288's Java half: overloads keep their cross-file callers across +/// a body-only edit. +pub fn java_overloads_keep_their_callers() -> Case { + Case::new(Change::BodyEdit, &[Language::Java]) + .file( + "src/app/Picker.java", + "package app;\n\npublic class Picker {\n public int pick(int value) {\n return value;\n }\n\n public String pick(String value) {\n return value;\n }\n}\n", + ) + .file( + "src/app/Main.java", + "package app;\n\npublic class Main {\n public static void main(String[] args) {\n Picker picker = new Picker();\n picker.pick(1);\n picker.pick(\"one\");\n }\n}\n", + ) + .write( + "src/app/Picker.java", + "package app;\n\npublic class Picker {\n public int pick(int value) {\n return value + 1;\n }\n\n public String pick(String value) {\n return value.trim();\n }\n}\n", + ) + .edge_throughout( + "method app::Main::main", + "calls", + "method app::Picker::pick @src/app/Picker.java:4", + ) +} + +pub fn jsx_component_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Jsx]) + .file( + "src/Card.jsx", + "export function Card(props) {\n return
      {props.title}
      ;\n}\n", + ) + .file( + "src/Page.jsx", + "import { Card } from './Card';\n\nexport function Page() {\n return ;\n}\n", + ) + .write( + "src/Card.jsx", + "export function Card(props) {\n return
      {props.title}
      ;\n}\n", + ) + .edge_throughout("function Page", "references", "function Card") +} + +pub fn swift_method_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Swift]) + .file( + "Sources/Counter.swift", + "class Counter {\n var value = 0\n\n func increment() -> Int {\n value += 1\n return value\n }\n}\n", + ) + .file( + "Sources/App.swift", + "func tick() -> Int {\n let counter = Counter()\n return counter.increment()\n}\n", + ) + .write( + "Sources/Counter.swift", + "class Counter {\n var value = 0\n\n func increment() -> Int {\n value += 2\n return value\n }\n}\n", + ) + .edge_throughout("function tick", "calls", "method Counter::increment") + .edge_throughout("function tick", "instantiates", "class Counter") +} + +/// Upstream #2107 (W4-SW-01): an extension is not the type, so a reference +/// beside it reaches the declaration elsewhere; turned into a class of the +/// same name it keeps its node id, yet it is now the nearer declaration the +/// unchanged reference reaches. +pub fn swift_extension_becomes_a_declaration() -> Case { + Case::new(Change::EditHeritage, &[Language::Swift]) + .file( + "App/Models/Account.swift", + "struct Account {\n let id: String\n}\n", + ) + .file( + "App/Other/Account+Display.swift", + "extension Account {\n var display: String { id }\n}\n", + ) + .file( + "App/Other/AccountRow.swift", + "struct AccountRow {\n let account: Account\n}\n", + ) + .write( + "App/Other/Account+Display.swift", + "class Account {\n var display: String { \"x\" }\n}\n", + ) + .edge_before( + "struct AccountRow", + "references", + "struct Account @App/Models/Account.swift", + ) + .edge_after( + "struct AccountRow", + "references", + "class Account @App/Other/Account+Display.swift", + ) + .no_edge_after( + "struct AccountRow", + "references", + "struct Account @App/Models/Account.swift", + ) +} + +/// Upstream #2107 (W4-SW-01): a subclass names a nested type of its +/// superclass's superclass bare; once the middle class's head names another +/// superclass in its own file, the unchanged subclass's reference is out of +/// scope. +pub fn swift_subclass_scope_follows_a_superclass_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Swift]) + .file( + "Sources/Core/Channel.swift", + "class BaseChannel {\n enum ReadResult { case some, none }\n}\nclass OtherChannel {\n}\n", + ) + .file( + "Sources/Core/Middle.swift", + "class MiddleChannel: BaseChannel {\n}\n", + ) + .file( + "Sources/Core/Stream.swift", + "final class StreamChannel: MiddleChannel {\n var last: ReadResult?\n}\n", + ) + .write( + "Sources/Core/Middle.swift", + "class MiddleChannel: OtherChannel {\n}\n", + ) + .edge_before( + "class StreamChannel", + "references", + "enum BaseChannel::ReadResult", + ) + .no_edge_after( + "class StreamChannel", + "references", + "enum BaseChannel::ReadResult", + ) +} + +/// Upstream #2107 (W4-SW-01): a conformance to a type the project only +/// extends is read from the conformer's head, so an unchanged caller's +/// typed call follows the head to the other extension's method. +pub fn swift_conformer_head_moves_an_extension_method_call() -> Case { + Case::new(Change::EditHeritage, &[Language::Swift]) + .file( + "App/Card.swift", + "extension View {\n func cardStyle() -> Int { 1 }\n}\n", + ) + .file( + "App/ShapeCard.swift", + "extension Shape {\n func cardStyle() -> Int { 2 }\n}\n", + ) + .file("App/Home.swift", "struct HomeView: View {\n}\n") + .file( + "App/Main.swift", + "func makeHome() -> Int {\n return HomeView().cardStyle()\n}\n", + ) + .write("App/Home.swift", "struct HomeView: Shape {\n}\n") + .edge_before("function makeHome", "calls", "method View::cardStyle") + .edge_after("function makeHome", "calls", "method Shape::cardStyle") + .no_edge_after("function makeHome", "calls", "method View::cardStyle") +} + +/// Upstream #2107 (W4-SW-01): a conformance to a type the project only +/// extends is no edge, until a declaration of the type appears. +pub fn swift_declaration_of_an_extended_type_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Swift]) + .file( + "App/Card.swift", + "extension View {\n func cardStyle() -> Int { 1 }\n}\n", + ) + .file("App/Home.swift", "struct HomeView: View {\n}\n") + .write("App/View.swift", "protocol View {\n}\n") + .no_edge_after("struct HomeView", "extends", "class View @App/Card.swift") + .edge_after( + "struct HomeView", + "implements", + "interface View @App/View.swift", + ) +} + +/// Upstream #2107 (W4-SW-01): an extension appearing beside an unchanged +/// reference to its type is not the type; the reference keeps the +/// declaration. +pub fn swift_extension_beside_a_reference_keeps_the_declaration() -> Case { + Case::new(Change::AddCompetitor, &[Language::Swift]) + .file( + "App/Models/Account.swift", + "struct Account {\n let id: String\n}\n", + ) + .file( + "App/Rows/AccountRow.swift", + "struct AccountRow {\n let account: Account\n}\n", + ) + .write( + "App/Rows/Account+Display.swift", + "extension Account {\n var display: String { id }\n}\n", + ) + .edge_throughout( + "struct AccountRow", + "references", + "struct Account @App/Models/Account.swift", + ) +} + +/// Upstream #2118 (W4-SW-02): a type-path call's member is owned through +/// the path its extension writes; an edit to that path in another file keeps +/// every node id, yet the unchanged call no longer names the type. +pub fn swift_type_path_call_follows_an_extension_path_change() -> Case { + Case::new(Change::EditHeritage, &[Language::Swift]) + .file( + "Sources/App/API.swift", + "enum API {\n enum Ctl {}\n}\nenum Other {\n enum Ctl {}\n}\nextension API.Ctl {\n enum GetRoute {\n static func query() -> Int { 1 }\n }\n}\n", + ) + .file( + "Sources/App/Health.swift", + "enum Health {\n static func one() -> Int { API.Ctl.GetRoute.query() }\n}\n", + ) + .write( + "Sources/App/API.swift", + "enum API {\n enum Ctl {}\n}\nenum Other {\n enum Ctl {}\n}\nextension Other.Ctl {\n enum GetRoute {\n static func query() -> Int { 1 }\n }\n}\n", + ) + .edge_before("method Health::one", "calls", "method Ctl::GetRoute::query") + .no_edge_after("method Health::one", "calls", "method Ctl::GetRoute::query") +} + +/// Upstream #2118 (W4-SW-02): an unresolved type-path call waits for the +/// path an extension in a third file writes to name its type; no node id +/// moves, and the call's own qualifier binds a file that does not change. +pub fn swift_type_path_call_reaches_an_extension_that_now_writes_its_path() -> Case { + Case::new(Change::EditHeritage, &[Language::Swift]) + .file( + "Sources/App/API.swift", + "enum API {\n enum Ctl {}\n}\nenum Other {\n enum Ctl {}\n}\n", + ) + .file( + "Sources/App/Routes.swift", + "extension Other.Ctl {\n enum GetRoute {\n static func query() -> Int { 1 }\n }\n}\n", + ) + .file( + "Sources/App/Health.swift", + "enum Health {\n static func one() -> Int { API.Ctl.GetRoute.query() }\n}\n", + ) + .write( + "Sources/App/Routes.swift", + "extension API.Ctl {\n enum GetRoute {\n static func query() -> Int { 1 }\n }\n}\n", + ) + .edge_after("method Health::one", "calls", "method Ctl::GetRoute::query") +} + +/// More unresolved `….query` calls than one tail retries in a sync (the +/// ceiling counts every language's): CFML calls on receivers no `query` is +/// named after, cheap to resolve. +static UNTYPED_QUERY_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("component {\n function noise() {\n"); + for index in 0..501 { + text.push_str(&format!(" n{index}.query({index});\n")); + } + text.push_str(" return 0;\n }\n}\n"); + text +}); + +/// Upstream #2118 (W4-SW-02): a member a standalone extension gains +/// settles the parked type-path call of its name in a third file, however +/// many parked calls share its tail. +pub fn swift_type_path_call_reaches_a_member_an_extension_gains_past_the_tail_ceiling() -> Case { + Case::new(Change::BodyEdit, &[Language::Swift, Language::Cfml]) + .file("Sources/App/API.swift", "enum API {\n enum GetRoute {}\n}\n") + .file( + "Sources/App/Routes.swift", + "extension API.GetRoute {\n static func list() -> Int { 1 }\n}\n", + ) + .file( + "Sources/App/Health.swift", + "enum Health {\n static func one() -> Int { API.GetRoute.query() }\n}\n", + ) + .file("noise/Noise.cfc", UNTYPED_QUERY_CALLS.as_str()) + .write( + "Sources/App/Routes.swift", + "extension API.GetRoute {\n static func list() -> Int { 1 }\n static func query() -> Int { 2 }\n}\n", + ) + .edge_after("method Health::one", "calls", "method GetRoute::query") +} + +/// Upstream #2118 (W4-SW-02): a standalone extension already declaring a +/// `query` under another owner gains the path's own, and later loses it: the +/// parked type-path call in a third file follows both, however many parked +/// calls share the tail. +pub fn swift_type_path_call_follows_a_second_namesake_an_extension_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Swift, Language::Cfml]) + .file("Sources/App/API.swift", "enum API {\n enum GetRoute {}\n}\n") + .file( + "Sources/App/Routes.swift", + "extension API.GetRoute {\n static func list() -> Int { 1 }\n}\nenum Elsewhere {\n static func query() -> Int { 0 }\n}\n", + ) + .file( + "Sources/App/Health.swift", + "enum Health {\n static func one() -> Int { API.GetRoute.query() }\n}\n", + ) + .file("noise/Noise.cfc", UNTYPED_QUERY_CALLS.as_str()) + .write( + "Sources/App/Routes.swift", + "extension API.GetRoute {\n static func list() -> Int { 1 }\n static func query() -> Int { 2 }\n}\nenum Elsewhere {\n static func query() -> Int { 0 }\n}\n", + ) + .edge_after("method Health::one", "calls", "method GetRoute::query") + .no_edge_after("method Health::one", "calls", "method Elsewhere::query") +} + +/// Upstream #2118 (W4-SW-02): the reverse: the path's own `query` leaves the +/// extension, the other owner's stays. +pub fn swift_type_path_call_drops_a_namesake_an_extension_loses() -> Case { + Case::new(Change::BodyEdit, &[Language::Swift, Language::Cfml]) + .file("Sources/App/API.swift", "enum API {\n enum GetRoute {}\n}\n") + .file( + "Sources/App/Routes.swift", + "extension API.GetRoute {\n static func list() -> Int { 1 }\n static func query() -> Int { 2 }\n}\nenum Elsewhere {\n static func query() -> Int { 0 }\n}\n", + ) + .file( + "Sources/App/Health.swift", + "enum Health {\n static func one() -> Int { API.GetRoute.query() }\n}\n", + ) + .file("noise/Noise.cfc", UNTYPED_QUERY_CALLS.as_str()) + .write( + "Sources/App/Routes.swift", + "extension API.GetRoute {\n static func list() -> Int { 1 }\n}\nenum Elsewhere {\n static func query() -> Int { 0 }\n}\n", + ) + .edge_before("method Health::one", "calls", "method GetRoute::query") + .no_edge_after("method Health::one", "calls", "method Elsewhere::query") +} + +/// Upstream #2118 (W4-SW-02): a type-path call waits for the member the +/// path's type gains. +pub fn swift_type_path_call_reaches_a_member_its_type_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Swift]) + .file( + "Sources/App/API.swift", + "enum API {\n enum GetRoute {\n static func list() -> Int { 1 }\n }\n}\nenum Other {\n static func query() -> Int { 2 }\n}\n", + ) + .file( + "Sources/App/Health.swift", + "enum Health {\n static func one() -> Int { API.GetRoute.query() }\n}\n", + ) + .write( + "Sources/App/API.swift", + "enum API {\n enum GetRoute {\n static func list() -> Int { 1 }\n static func query() -> Int { 3 }\n }\n}\nenum Other {\n static func query() -> Int { 2 }\n}\n", + ) + .edge_after("method Health::one", "calls", "method API::GetRoute::query") + .no_edge_after("method Health::one", "calls", "method Other::query") +} + +/// Upstream #2170 (W4-SW-02): a bare call reaches the members of the types +/// the class around it inherits, read from their heads two levels up; the +/// middle class's head naming another base in its own file moves the +/// unchanged grandchild's call, though nothing the grandchild writes names +/// the middle class. +pub fn swift_bare_call_follows_a_superclass_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Swift]) + .file( + "Sources/Charts/Base.swift", + "class ChartViewBase {\n func reset() {}\n}\nclass OtherBase {\n func reset() {}\n}\n", + ) + .file("Sources/Charts/Middle.swift", "class Middle: ChartViewBase {\n}\n") + .file("Sources/Charts/Leaf.swift", "class Leaf: Middle {\n}\n") + .file( + "Sources/Charts/Grand.swift", + "class Grand: Leaf {\n func tap() {\n reset()\n }\n}\n", + ) + .write("Sources/Charts/Middle.swift", "class Middle: OtherBase {\n}\n") + .edge_before("method Grand::tap", "calls", "method ChartViewBase::reset") + .edge_after("method Grand::tap", "calls", "method OtherBase::reset") + .no_edge_after("method Grand::tap", "calls", "method ChartViewBase::reset") +} + +/// Upstream #2205 (W4-SW-02): a call on a property a superclass in another +/// file declares with a type follows the declared type. +pub fn swift_typed_property_follows_an_inherited_declaration() -> Case { + Case::new(Change::EditHeritage, &[Language::Swift]) + .file( + "Sources/Cache/Caches.swift", + "class ImageCache {\n func imageCachedType(forKey key: String) -> Int { 0 }\n}\nclass DiskBackend {\n func imageCachedType(forKey key: String) -> Int { 1 }\n}\n", + ) + .file( + "Tests/AppTests/BaseTests.swift", + "class BaseTests {\n var store: ImageCache!\n}\n", + ) + .file( + "Tests/AppTests/StoreTests.swift", + "class StoreTests: BaseTests {\n func testType() {\n _ = store.imageCachedType(forKey: \"a\")\n }\n}\n", + ) + .write( + "Tests/AppTests/BaseTests.swift", + "class BaseTests {\n var store: DiskBackend!\n}\n", + ) + .edge_before( + "method StoreTests::testType", + "calls", + "method ImageCache::imageCachedType", + ) + .edge_after( + "method StoreTests::testType", + "calls", + "method DiskBackend::imageCachedType", + ) + .no_edge_after( + "method StoreTests::testType", + "calls", + "method ImageCache::imageCachedType", + ) +} + +/// Upstream #2323 (W4-GO-01): a method a receiver's type promotes from a +/// type it embeds follows the embedding, changed in another file of the +/// package. +pub fn go_embedded_method_follows_an_embedding_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "h/base.go", + "package h\n\ntype BaseAPI struct{}\n\nfunc (b *BaseAPI) SendError() {}\n\ntype OtherAPI struct{}\n\nfunc (o *OtherAPI) SendError() {}\n", + ) + .file("h/server.go", "package h\n\ntype server struct {\n\tBaseAPI\n}\n") + .file( + "h/run.go", + "package h\n\nfunc (s *server) Run() {\n\ts.SendError()\n}\n", + ) + .write("h/server.go", "package h\n\ntype server struct {\n\tOtherAPI\n}\n") + .edge_before("method server::Run", "calls", "method BaseAPI::SendError") + .edge_after("method server::Run", "calls", "method OtherAPI::SendError") + .no_edge_after("method server::Run", "calls", "method BaseAPI::SendError") +} + +/// Upstream #1276 / #2361 (W4-GO-01): a call through a field chain follows +/// the field's declared type, changed in another file of the package. +pub fn go_field_chain_follows_a_field_type_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "store/store.go", + "package store\n\ntype Manager struct{}\n\nfunc (m *Manager) CreateItem(name string) error { return nil }\n\ntype Archive struct{}\n\nfunc (a *Archive) CreateItem(name string) error { return nil }\n", + ) + .file( + "domain/service.go", + "package domain\n\nimport \"example.com/app/store\"\n\ntype Service struct {\n\tdb *store.Manager\n}\n", + ) + .file( + "domain/add.go", + "package domain\n\nfunc (s *Service) Add(name string) error {\n\treturn s.db.CreateItem(name)\n}\n", + ) + .write( + "domain/service.go", + "package domain\n\nimport \"example.com/app/store\"\n\ntype Service struct {\n\tdb *store.Archive\n}\n", + ) + .edge_before("method Service::Add", "calls", "method Manager::CreateItem") + .edge_after("method Service::Add", "calls", "method Archive::CreateItem") + .no_edge_after("method Service::Add", "calls", "method Manager::CreateItem") +} + +/// Upstream #2361 (W4-GO-01): a field typed through a package qualifier +/// follows the import its file retargets under the same name (plan gate +/// round 1). +pub fn go_field_chain_follows_an_import_retarget_in_the_declaring_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "store1/store.go", + "package store1\n\ntype Manager struct{}\n\nfunc (m *Manager) CreateItem(name string) error { return nil }\n", + ) + .file( + "store2/store.go", + "package store2\n\ntype Manager struct{}\n\nfunc (m *Manager) CreateItem(name string) error { return nil }\n", + ) + .file( + "domain/service.go", + "package domain\n\nimport dep \"example.com/app/store1\"\n\ntype Service struct {\n\tdb *dep.Manager\n}\n", + ) + .file( + "domain/add.go", + "package domain\n\nfunc (s *Service) Add(name string) error {\n\treturn s.db.CreateItem(name)\n}\n", + ) + .write( + "domain/service.go", + "package domain\n\nimport dep \"example.com/app/store2\"\n\ntype Service struct {\n\tdb *dep.Manager\n}\n", + ) + .edge_before( + "method Service::Add", + "calls", + "method Manager::CreateItem @store1/store.go", + ) + .edge_after( + "method Service::Add", + "calls", + "method Manager::CreateItem @store2/store.go", + ) + .no_edge_after( + "method Service::Add", + "calls", + "method Manager::CreateItem @store1/store.go", + ) +} + +/// Upstream #2361 (W4-GO-01): an embedding written through a package +/// qualifier follows the import its file retargets under the same name. +pub fn go_embedded_method_follows_an_import_retarget_in_the_declaring_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "base1/api.go", + "package base1\n\ntype API struct{}\n\nfunc (a *API) Send() {}\n", + ) + .file( + "base2/api.go", + "package base2\n\ntype API struct{}\n\nfunc (a *API) Send() {}\n", + ) + .file( + "h/server.go", + "package h\n\nimport base \"example.com/app/base1\"\n\ntype server struct {\n\tbase.API\n}\n", + ) + .file("h/run.go", "package h\n\nfunc (s *server) Run() {\n\ts.Send()\n}\n") + .write( + "h/server.go", + "package h\n\nimport base \"example.com/app/base2\"\n\ntype server struct {\n\tbase.API\n}\n", + ) + .edge_before("method server::Run", "calls", "method API::Send @base1/api.go") + .edge_after("method server::Run", "calls", "method API::Send @base2/api.go") + .no_edge_after("method server::Run", "calls", "method API::Send @base1/api.go") +} + +/// More unresolved `….CreateItem` calls than one tail retries in a sync: +/// CFML calls on receivers no `CreateItem` is named after, cheap to resolve. +static UNTYPED_CREATE_ITEM_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("component {\n function noise() {\n"); + for index in 0..501 { + text.push_str(&format!(" n{index}.CreateItem({index});\n")); + } + text.push_str(" return 0;\n }\n}\n"); + text +}); + +/// Upstream #1276 / #2361 (W4-GO-01): a field retyped in another file to a +/// project type settles the parked call through it, however many parked +/// calls share the tail. +pub fn go_field_chain_reaches_a_retyped_field_past_the_tail_ceiling() -> Case { + Case::new(Change::EditHeritage, &[Language::Go, Language::Cfml]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "store/store.go", + "package store\n\ntype Archive struct{}\n\nfunc (a *Archive) CreateItem(name string) error { return nil }\n", + ) + .file( + "domain/service.go", + "package domain\n\nimport \"example.com/app/store\"\n\ntype Service struct {\n\tdb *store.Missing\n}\n", + ) + .file( + "domain/add.go", + "package domain\n\nfunc (s *Service) Add(name string) error {\n\treturn s.db.CreateItem(name)\n}\n", + ) + .file("noise/Noise.cfc", UNTYPED_CREATE_ITEM_CALLS.as_str()) + .write( + "domain/service.go", + "package domain\n\nimport \"example.com/app/store\"\n\ntype Service struct {\n\tdb *store.Archive\n}\n", + ) + .edge_after("method Service::Add", "calls", "method Archive::CreateItem") +} + +/// Upstream #2323 (W4-GO-01): a method an unexported receiver's own package +/// gains is what its call reaches, never another package's same-named +/// type's. +pub fn go_unexported_receiver_reaches_a_method_its_package_gains() -> Case { + Case::new(Change::AddCompetitor, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "handlers/server.go", + "package handlers\n\ntype server struct{}\n\nfunc (s *server) Create() {\n\ts.Close()\n}\n", + ) + .file( + "admin/server.go", + "package admin\n\ntype server struct{}\n\nfunc (s *server) Close() {}\n", + ) + .write( + "handlers/close.go", + "package handlers\n\nfunc (s *server) Close() {}\n", + ) + .edge_after( + "method server::Create", + "calls", + "method server::Close @handlers/close.go", + ) + .no_edge_after( + "method server::Create", + "calls", + "method server::Close @admin/server.go", + ) +} + +/// Upstream #2322 (W4-GO-01): a type a reference names through a package +/// lands on that package's type once it appears, never on a same-named +/// method beside the reference. +pub fn go_qualified_type_reference_reaches_a_package_type_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "impl/context.go", + "package impl\n\nimport \"example.com/app/job\"\n\ntype Context struct{}\n\nfunc (c *Context) OPCommand() (job.OPCommand, bool) {\n\treturn \"\", false\n}\n", + ) + .file("job/doc.go", "package job\n") + .write("job/op.go", "package job\n\ntype OPCommand string\n") + .edge_after("method Context::OPCommand", "references", "type_alias OPCommand") + .no_edge_after("method Context::OPCommand", "references", "method Context::OPCommand") +} + +/// Upstream #2417 (W4-GO-02): a method called on an alias is the aliased +/// type's, so it follows the alias retargeted in another file of the +/// package. +pub fn go_alias_method_follows_an_alias_target_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.24\n") + .file( + "a/event.go", + "package a\n\ntype Event struct{}\n\nfunc (e *Event) IsCreate() bool { return true }\n", + ) + .file( + "b/event.go", + "package b\n\ntype Event struct{}\n\nfunc (e *Event) IsCreate() bool { return false }\n", + ) + .file( + "w/alias.go", + "package w\n\nimport \"example.com/app/a\"\n\ntype Event = a.Event\n", + ) + .file( + "w/use.go", + "package w\n\nfunc isCreate(e *Event) bool { return e.IsCreate() }\n", + ) + .write( + "w/alias.go", + "package w\n\nimport \"example.com/app/b\"\n\ntype Event = b.Event\n", + ) + .edge_before("function isCreate", "calls", "method Event::IsCreate @a/event.go") + .edge_after("function isCreate", "calls", "method Event::IsCreate @b/event.go") + .no_edge_after("function isCreate", "calls", "method Event::IsCreate @a/event.go") +} + +/// Upstream #2417 (W4-GO-02): a method called through two aliases follows +/// the middle one, changed in another file of the package. +pub fn go_alias_chain_follows_a_middle_alias_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.24\n") + .file( + "w/types.go", + "package w\n\ntype A struct{}\n\nfunc (a *A) Err() error { return nil }\n\ntype B struct{}\n\nfunc (b *B) Err() error { return nil }\n", + ) + .file("w/ptr.go", "package w\n\ntype Ptr = *Local\n") + .file("w/local.go", "package w\n\ntype Local = A\n") + .file( + "w/use.go", + "package w\n\nfunc check(p Ptr) error { return p.Err() }\n", + ) + .write("w/local.go", "package w\n\ntype Local = B\n") + .edge_before("function check", "calls", "method A::Err") + .edge_after("function check", "calls", "method B::Err") + .no_edge_after("function check", "calls", "method A::Err") +} + +/// Upstream #2417 (W4-GO-02): a method called on a type an alias that +/// appears declares is the aliased type's, never a namesake's. +pub fn go_alias_method_reaches_the_aliased_type_once_the_alias_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.24\n") + .file( + "a/event.go", + "package a\n\ntype Event struct{}\n\nfunc (e *Event) IsCreate() bool { return true }\n", + ) + .file( + "b/event.go", + "package b\n\ntype Event struct{}\n\nfunc (e *Event) IsCreate() bool { return false }\n", + ) + .file( + "w/use.go", + "package w\n\nfunc isCreate(e *Event) bool { return e.IsCreate() }\n", + ) + .write( + "w/alias.go", + "package w\n\nimport \"example.com/app/b\"\n\ntype Event = b.Event\n", + ) + .edge_after("function isCreate", "calls", "method Event::IsCreate @b/event.go") + .no_edge_after("function isCreate", "calls", "method Event::IsCreate @a/event.go") +} + +/// Upstream #2441 (W4-GO-02): a defined type references the type its own +/// package gains, never another package's namesake. +pub fn go_defined_type_reaches_a_type_its_package_gains() -> Case { + Case::new(Change::AddCompetitor, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.24\n") + .file( + "alpha/names.go", + "package alpha\n\ntype WatchResponse struct{}\n", + ) + .file( + "w/chan.go", + "package w\n\ntype WatchChan <-chan WatchResponse\n", + ) + .write("w/resp.go", "package w\n\ntype WatchResponse struct{}\n") + .edge_after( + "type_alias WatchChan", + "references", + "struct WatchResponse @w/resp.go", + ) + .no_edge_after( + "type_alias WatchChan", + "references", + "struct WatchResponse @alpha/names.go", + ) +} + +/// Upstream #2233 (W4-GO-02): a name written through a package from +/// outside the project's modules never reaches a namesake that appears. +pub fn go_external_qualified_reference_never_reaches_a_namesake_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.24\n") + .file( + "w/handler.go", + "package w\n\nimport \"context\"\n\ntype Handler func(ctx context.Context) error\n\ntype Ctx = context.Context\n", + ) + .write("w/context.go", "package w\n\ntype Context struct{}\n") + .no_edge_after("type_alias Handler", "references", "struct Context") + .no_edge_after("type_alias Ctx", "references", "struct Context") +} + +/// Upstream #2444 (W4-GO-03): a call through a type assertion reaches the +/// asserted type's promoted method, so it follows the type's embedding, +/// changed in another file. +pub fn go_asserted_call_follows_an_embedding_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "w/base.go", + "package w\n\ntype Base struct{}\n\nfunc (b *Base) Flush() error { return nil }\n\ntype Other struct{}\n\nfunc (o *Other) Flush() error { return nil }\n", + ) + .file("w/wrapper.go", "package w\n\ntype Wrapper struct {\n\t*Base\n}\n") + .file("w/use.go", "package w\n\nfunc use(v any) {\n\tv.(*Wrapper).Flush()\n}\n") + .write("w/wrapper.go", "package w\n\ntype Wrapper struct {\n\t*Other\n}\n") + .edge_before("function use", "calls", "method Base::Flush") + .edge_after("function use", "calls", "method Other::Flush") + .no_edge_after("function use", "calls", "method Base::Flush") +} + +/// Upstream #2444 (W4-GO-03): a call through a type assertion reaches a +/// method the asserted interface gains, never a stub's namesake. +pub fn go_asserted_call_reaches_a_method_the_asserted_type_gains() -> Case { + Case::new(Change::AddCompetitor, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "pb/stub.go", + "package pb\n\ntype UnimplementedKVServer struct{}\n\nfunc (UnimplementedKVServer) Range() error { return nil }\n", + ) + .file("pb/server.go", "package pb\n\ntype KVServer interface {\n\tPut() error\n}\n") + .file( + "pb/handler.go", + "package pb\n\nfunc handle(srv interface{}) error {\n\treturn srv.(KVServer).Range()\n}\n", + ) + .write( + "pb/server.go", + "package pb\n\ntype KVServer interface {\n\tPut() error\n\tRange() error\n}\n", + ) + .edge_after("function handle", "calls", "method KVServer::Range") + .no_edge_after("function handle", "calls", "method UnimplementedKVServer::Range") +} + +/// Upstream #2416 (W4-GO-03): a type position reaches the type its package +/// gains, never the method of its name beside it. +pub fn go_type_position_reaches_a_type_its_package_gains() -> Case { + Case::new(Change::AddCompetitor, &[Language::Go]) + .file("go.mod", "module example.com/app\n\ngo 1.22\n") + .file( + "mvcc/index.go", + "package mvcc\n\ntype treeIndex struct{}\n\nfunc (ti *treeIndex) keyIndex() {}\n\nfunc (ti *treeIndex) KeyIndex(keyi *keyIndex) {}\n", + ) + .write("mvcc/key_index.go", "package mvcc\n\ntype keyIndex struct{}\n") + .edge_after( + "method treeIndex::KeyIndex", + "references", + "struct keyIndex @mvcc/key_index.go", + ) + .no_edge_after( + "method treeIndex::KeyIndex", + "references", + "method treeIndex::keyIndex", + ) +} + +/// Upstream #2150 (W4-SW-04): a message to `self` reaches the sender's +/// hierarchy, so it follows a middle class's superclass changed in another +/// file. +pub fn objc_self_send_follows_a_superclass_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::ObjC]) + .file( + "Core/SDBase.m", + "@interface SDBase : NSObject\n@end\n@implementation SDBase\n- (void)prepare {\n}\n@end\n", + ) + .file( + "Core/SDOther.m", + "@interface SDOther : NSObject\n@end\n@implementation SDOther\n- (void)prepare {\n}\n@end\n", + ) + .file("Core/SDMiddle.h", "@interface SDMiddle : SDBase\n@end\n") + .file( + "Core/SDCache.m", + "@interface SDCache : SDMiddle\n@end\n@implementation SDCache\n- (void)run {\n [self prepare];\n}\n@end\n", + ) + .write("Core/SDMiddle.h", "@interface SDMiddle : SDOther\n@end\n") + .edge_before("method SDCache::run", "calls", "method SDBase::prepare") + .edge_after("method SDCache::run", "calls", "method SDOther::prepare") + .no_edge_after("method SDCache::run", "calls", "method SDBase::prepare") +} + +/// Upstream #2179 (W4-SW-04): `[super init]` goes past the sender, to the +/// superclass its header, changed apart from the implementation, names. +pub fn objc_super_send_follows_the_header_of_its_class() -> Case { + Case::new(Change::EditHeritage, &[Language::ObjC]) + .file( + "Base.m", + "@interface Base : NSObject\n@end\n@implementation Base\n- (instancetype)init {\n return self;\n}\n@end\n", + ) + .file( + "Mid.m", + "@interface Mid : NSObject\n@end\n@implementation Mid\n- (instancetype)init {\n return self;\n}\n@end\n", + ) + .file("Child.h", "@interface Child : Base\n@end\n") + .file( + "Child.m", + "#import \"Child.h\"\n@implementation Child\n- (instancetype)init {\n self = [super init];\n return self;\n}\n@end\n", + ) + .write("Child.h", "@interface Child : Mid\n@end\n") + .edge_before("method Child::init", "calls", "method Base::init") + .edge_after("method Child::init", "calls", "method Mid::init") + .no_edge_after("method Child::init", "calls", "method Base::init") +} + +/// Upstream #2150 (W4-SW-04): an untyped receiver ending in a property keeps +/// the one method of its name while the property's declared type has the +/// method's owner, and loses it when another file retypes the property. +pub fn objc_typed_property_receiver_follows_a_declared_type_change() -> Case { + Case::new(Change::EditHeritage, &[Language::ObjC]) + .file( + "RLMSet.m", + "@interface RLMSet : NSObject\n@end\n@implementation RLMSet\n- (BOOL)containsObject:(id)object {\n return NO;\n}\n@end\n", + ) + .file( + "AllTypesObject.h", + "@interface AllTypesObject : NSObject\n@property (nonatomic) RLMSet *anyDataObj;\n@end\n", + ) + .file( + "Tests.m", + "@interface Tests : NSObject\n@end\n@implementation Tests\n- (void)run:(AllTypesObject *)managed {\n [managed.anyDataObj containsObject:@\"x\"];\n}\n@end\n", + ) + .write( + "AllTypesObject.h", + "@interface AllTypesObject : NSObject\n@property (nonatomic) NSSet *anyDataObj;\n@end\n", + ) + .edge_before("method Tests::run:", "calls", "method RLMSet::containsObject:") + .no_edge_after("method Tests::run:", "calls", "method RLMSet::containsObject:") +} + +/// Upstream #2150 (W4-SW-04): a property's declared type that comes to +/// inherit the method's owner in another file settles the parked message on +/// the property, written with a keyword selector. +pub fn objc_typed_property_receiver_reaches_an_owner_its_type_comes_to_inherit() -> Case { + Case::new(Change::EditHeritage, &[Language::ObjC]) + .file( + "RLMCollection.m", + "@interface RLMCollection : NSObject\n@end\n@implementation RLMCollection\n- (BOOL)containsObject:(id)object {\n return NO;\n}\n@end\n", + ) + .file("RLMArray.h", "@interface RLMArray : NSObject\n@end\n") + .file( + "Owner.h", + "@interface Owner : NSObject\n@property (nonatomic) RLMArray *items;\n@end\n", + ) + .file( + "Tests.m", + "@interface Tests : NSObject\n@end\n@implementation Tests\n- (void)run:(Owner *)owner {\n [owner.items containsObject:@\"x\"];\n}\n@end\n", + ) + .write("RLMArray.h", "@interface RLMArray : RLMCollection\n@end\n") + .edge_after("method Tests::run:", "calls", "method RLMCollection::containsObject:") +} + +/// More unresolved `….next` calls than one tail retries in a sync: CFML +/// calls on receivers no `next` is named after, cheap to resolve. +static UNTYPED_NEXT_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("component {\n function noise() {\n"); + for index in 0..501 { + text.push_str(&format!(" n{index}.next({index});\n")); + } + text.push_str(" return 0;\n }\n}\n"); + text +}); + +/// Upstream #2150 (W4-SW-04): a local typed by its declaration +/// (`FMResultSet *rs`) reaches the method its type comes to inherit in +/// another file, however many parked calls share the tail. +pub fn objc_typed_local_reaches_an_inherited_method_past_the_tail_ceiling() -> Case { + Case::new(Change::EditHeritage, &[Language::ObjC, Language::Cfml]) + .file( + "FMBase.m", + "@interface FMBase : NSObject\n@end\n@implementation FMBase\n- (BOOL)next {\n return NO;\n}\n@end\n", + ) + .file("FMResultSet.h", "@interface FMResultSet : NSObject\n@end\n") + .file( + "Reader.m", + "@interface Reader : NSObject\n@end\n@implementation Reader\n- (void)read {\n FMResultSet *rs = nil;\n [rs next];\n}\n@end\n", + ) + .file("noise/Noise.cfc", UNTYPED_NEXT_CALLS.as_str()) + .write("FMResultSet.h", "@interface FMResultSet : FMBase\n@end\n") + .edge_after("method Reader::read", "calls", "method FMBase::next") +} + +/// Upstream #2150's system table (W4-SW-04): a UIView category's method +/// that appears is what a UIImageView category's `[self …]` sends to. +pub fn objc_category_send_reaches_a_system_superclass_category_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::ObjC]) + .file( + "SDDecoy.m", + "@interface SDDecoy : NSObject\n@end\n@implementation SDDecoy\n- (void)sd_internalSetImageWithURL:(NSURL *)url {\n}\n@end\n", + ) + .file( + "UIImageView+WebCache.m", + "@implementation UIImageView (WebCache)\n- (void)sd_setImageWithURL:(NSURL *)url {\n [self sd_internalSetImageWithURL:url];\n}\n@end\n", + ) + .write( + "UIView+WebCache.m", + "@implementation UIView (WebCache)\n- (void)sd_internalSetImageWithURL:(NSURL *)url {\n}\n@end\n", + ) + .edge_after( + "method UIImageView::sd_setImageWithURL:", + "calls", + "method UIView::sd_internalSetImageWithURL:", + ) + .no_edge_after( + "method UIImageView::sd_setImageWithURL:", + "calls", + "method SDDecoy::sd_internalSetImageWithURL:", + ) +} + +/// Upstream #2242 (W4-SW-04): a class conforming to its namesake protocol +/// keeps implementing its own header's when a copy of the header appears. +pub fn objc_namesake_protocol_keeps_its_own_header_when_a_copy_appears() -> Case { + const HEADER: &str = "@protocol SDDiskCache \n- (NSData *)dataForKey:(NSString *)key;\n@end\n\n@interface SDDiskCache : NSObject \n@end\n"; + Case::new(Change::AddCompetitor, &[Language::ObjC]) + .file("Core/SDDiskCache.h", HEADER) + .write("include/SDDiskCache.h", HEADER) + .edge_throughout( + "class SDDiskCache @Core/SDDiskCache.h", + "implements", + "protocol SDDiskCache @Core/SDDiskCache.h", + ) + .no_edge_after( + "class SDDiskCache @Core/SDDiskCache.h", + "implements", + "protocol SDDiskCache @include/SDDiskCache.h", + ) +} + +/// A line added above a function moves its id: the edge into it follows it, +/// and an edge to a namesake in another file stays as a fresh index has it +/// (a moved TypeScript node still touches its name; W4-PHP-01's move-aware +/// touch is PHP's only). +pub fn typescript_line_shift_above_a_function_keeps_namesake_edges_elsewhere() -> Case { + Case::new(Change::BodyEdit, &[Language::TypeScript]) + .file("src/a.ts", "export function run() {\n return 1;\n}\n") + .file("src/b.ts", "export function run() {\n return 2;\n}\n") + .file( + "src/main.ts", + "import { run } from './b';\n\nexport function main() {\n return run();\n}\n", + ) + .file( + "src/other.ts", + "import { run } from './a';\n\nexport function other() {\n return run();\n}\n", + ) + .write( + "src/a.ts", + "// One line above the function moves its id.\nexport function run() {\n return 1;\n}\n", + ) + .edge_throughout("function main", "calls", "function run @src/b.ts") + .edge_throughout("function other", "calls", "function run @src/a.ts") +} + +/// A C function's `static` is read from its declaration's text, which no node +/// records: a blank line added above a `static` function that loses `static` +/// moves its id and nothing its node holds, and makes it visible to another +/// file's parked call, so a moved C node keeps touching its name (W4-PHP-01's +/// move-aware touch is PHP's only). A comment there would not do: it becomes +/// the node's docstring. +pub fn c_static_function_becomes_visible_with_a_line_added_above() -> Case { + Case::new(Change::AddCompetitor, &[Language::C]) + .file("a.c", "static int f(void) {\n return 1;\n}\n") + .file("b.c", "int g(void) {\n return f();\n}\n") + .write("a.c", "\nint f(void) {\n return 1;\n}\n") + .edge_after("function g", "calls", "function f @a.c") +} + +/// The same for a PHP base class: a line above its methods moves their ids; +/// the subclass's `$this->prepare()` follows the moved method. +pub fn php_line_shift_in_a_base_class_keeps_its_callers() -> Case { + Case::new(Change::BodyEdit, &[Language::Php]) + .file( + "src/Base.php", + "prepare();\n }\n}\n", + ) + .write( + "src/Base.php", + "m()` reaches the class's ancestry, so +/// it follows a middle class's `extends` changed in another file. +pub fn php_this_call_follows_an_ancestor_extends_change() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Base.php", + "prepare();\n }\n}\n", + ) + .write( + "src/Middle.php", + "m()` reaches a +/// subclass's method, so it leaves a deeper subclass's method once a middle +/// class between them is rebased in another file. +pub fn php_base_this_call_leaves_a_deeper_subclass_method_once_a_middle_class_is_rebased() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Entity.php", + "book();\n }\n}\n", + ) + .file( + "src/Other.php", + "m()` resolves once a middle +/// class in another file comes to extend the method's class. +pub fn php_this_call_reaches_a_method_once_a_middle_class_extends_its_class() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Base.php", + "prepare();\n }\n}\n", + ) + .write( + "src/Middle.php", + "m()` follows the trait an ancestor's body switches +/// to. +pub fn php_this_call_follows_a_trait_use_change_in_an_ancestor() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Greets.php", + "hello();\n }\n}\n", + ) + .write( + "src/Base.php", + " Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/One/Base.php", + "m()` leaves the trait +/// once the ancestor's declaration appears. +pub fn php_this_call_leaves_a_repository_trait_once_the_outside_base_appears() -> Case { + Case::new(Change::AddImportTarget, &[Language::Php]) + .file( + "src/Testing/InteractsWithTime.php", + "travelTo('2000-01-02');\n }\n}\n", + ) + .write( + "testbench/TestCase.php", + "m()` leaves the method once its +/// trait becomes a class. +pub fn php_this_call_leaves_a_method_once_its_trait_becomes_a_class() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Testing/InteractsWithTime.php", + "travelTo('2000-01-02');\n }\n}\n", + ) + .write( + "src/Testing/InteractsWithTime.php", + "m()` reaches the +/// method once its class becomes a trait. The call was parked, so nothing +/// resolved into the changed file, and the method itself did not change. +pub fn php_this_call_reaches_a_method_once_its_class_becomes_a_trait() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Testing/InteractsWithTime.php", + "travelTo('2000-01-02');\n }\n}\n", + ) + .write( + "src/Testing/InteractsWithTime.php", + " Case { + Case::new(Change::DeleteImportedModule, &[Language::Php]) + .file( + "src/Concerns/Travels.php", + "travel();\n }\n}\n", + ) + .remove("src/Base/Middle.php") + .edge_after( + "method App::Child::run", + "calls", + "method App\\Concerns::Travels::travel", + ) +} + +/// Upstream #2152 (W4-PHP-01): an untyped receiver keeps the one method of +/// its name while the class it is named after inherits it, so it leaves the +/// method when that class's `extends` changes in another file. +pub fn php_untyped_receiver_leaves_a_method_its_class_stops_inheriting() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Models/Entity.php", + "save();\n}\n", + ) + .write( + "src/Models/Page.php", + "save()`: 501 of them share the tail `save`, one past the +/// name lane's per-tail ceiling. +static UNTYPED_SAVE_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("save();\n")); + } + text.push_str("}\n"); + text +}); + +/// Upstream #2152 (W4-PHP-01): an untyped receiver reaches a method its +/// class's ancestor gains, with no head changing, however many parked calls +/// share the tail. +pub fn php_untyped_receiver_reaches_a_method_its_ancestor_gains_past_the_tail_ceiling() -> Case { + Case::new(Change::BodyEdit, &[Language::Php]) + .file( + "src/Models/Entity.php", + "save();\n}\n", + ) + .file("noise/Noise.php", UNTYPED_SAVE_CALLS.as_str()) + .write( + "src/Models/Entity.php", + " = LazyLock::new(|| { + let mut text = String::from(" = (1..=78).map(|index| format!("B{index}")).collect(); + text.push_str(&format!("trait B\n{{\n use {};\n}}\n", uses.join(", "))); + text +}); + +/// Upstream #2152 (W4-PHP-01) at the walk limit: a seed whose ancestry grows +/// pushes another branch of a subclass's ancestry past the resolver's 80 +/// types, so a receiver named after the subclass leaves that branch's method. +pub fn php_untyped_receiver_leaves_a_method_a_seed_pushes_past_the_walk_limit() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file("src/Traits.php", WIDGET_TRAITS.as_str()) + .file("src/S.php", "m77();\n}\n", + ) + .write( + "src/S.php", + " Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Models/Entity.php", + "save();\n}\n", + ) + .file("noise/Noise.php", UNTYPED_SAVE_CALLS.as_str()) + .write( + "src/Models/Page.php", + " Case { + Case::new(Change::AddCompetitor, &[Language::Php]) + .file( + "app/Http/Controllers/DocsController.php", + " Case { + Case::new(Change::AddCompetitor, &[Language::Php]) + .file( + "app/Http/Requests/API/Interaction/Request.php", + " Case { + Case::new(Change::AddImportTarget, &[Language::Php]) + .file( + "app/Other/FirstName.php", + " Case { + Case::new(Change::RenameSymbol, &[Language::Php]) + .file( + "app/Fields/FirstName.php", + " = LazyLock::new(|| { + let mut text = String::from(" Case { + Case::new(Change::AddImportTarget, &[Language::Php]) + .file( + "app/Personal.php", + " Case { + Case::new(Change::BodyEdit, &[Language::Php]) + .file( + "app/Fields/FirstName.php", + " Case { + Case::new(Change::AddCompetitor, &[Language::Php]) + .file( + "app/Decoy.php", + " Case { + Case::new(Change::AddCompetitor, &[Language::Php]) + .file( + "src/view.php", + " Case { + Case::new(Change::AddCompetitor, &[Language::Pascal]) + .file( + "View.pas", + "unit View;\ninterface\nfunction Render: Integer;\nimplementation\nfunction Render: Integer;\nbegin\n Result := formatprice(10);\nend;\nend.\n", + ) + .write( + "Prices.pas", + "unit Prices;\ninterface\nimplementation\nfunction FormatPrice(Amount: Integer): Integer;\nbegin\n Result := Amount;\nend;\nend.\n", + ) + .edge_after("file:View.pas", "calls", "function FormatPrice") +} + +/// The same call leaves `FormatPrice` once a method named `formatPrice` +/// appears elsewhere: the name now matches two nodes in some case, and the +/// last-resort match takes only a single one. +pub fn php_call_in_another_case_leaves_a_function_once_a_namesake_in_another_case_appears() -> Case +{ + Case::new(Change::AddCompetitor, &[Language::Php]) + .file( + "src/helpers.php", + " Case { + Case::new(Change::AddCompetitor, &[Language::Php]) + .file( + "app/Page.php", + " Case { + Case::new(Change::BodyEdit, &[Language::Lua]) + .file( + "spec/helpers.lua", + "function helper()\n return 1\nend\n\nreturn {}\n", + ) + .file("spec/plugin_spec.lua", "helper()\n") + .write( + "spec/helpers.lua", + "local function helper()\n return 1\nend\n\nreturn {}\n", + ) + .edge_before("file:spec/plugin_spec.lua", "calls", "function helper") + .no_edge_after("file:spec/plugin_spec.lua", "calls", "function helper") +} + +/// The reverse: a `local` function another file calls is reached once it is +/// made global. +pub fn lua_local_function_made_global_reaches_its_callers_in_another_file() -> Case { + Case::new(Change::BodyEdit, &[Language::Lua]) + .file( + "spec/helpers.lua", + "local function helper()\n return 1\nend\n\nreturn {}\n", + ) + .file("spec/plugin_spec.lua", "helper()\n") + .write( + "spec/helpers.lua", + "function helper()\n return 1\nend\n\nreturn {}\n", + ) + .edge_after("file:spec/plugin_spec.lua", "calls", "function helper") +} + +/// Upstream #2188 (W4-LUA-01): a call through `local cp = phases.check` +/// reaches the function the module starts handing out under `check`, though +/// no node of the module changes. +pub fn lua_alias_reaches_a_member_its_module_starts_exporting() -> Case { + Case::new(Change::BodyEdit, &[Language::Lua]) + .file( + "kong/pdk/phases.lua", + "local function check_phase(accepted)\n return accepted\nend\n\nreturn {\n}\n", + ) + .file( + "kong/handler.lua", + "local phases = require \"kong.pdk.phases\"\nlocal cp = phases.check\n\nlocal function run()\n cp(\"access\")\nend\n\nreturn { run = run }\n", + ) + .write( + "kong/pdk/phases.lua", + "local function check_phase(accepted)\n return accepted\nend\n\nreturn {\n check = check_phase,\n}\n", + ) + .edge_after("function run", "calls", "function check_phase") +} + +/// The same two modules away: `helpers` re-exports `misc.unindent`, and +/// `misc` starts handing out its `undent` under `unindent`. +pub fn lua_alias_reaches_a_member_re_exported_from_a_module_two_away() -> Case { + Case::new(Change::BodyEdit, &[Language::Lua]) + .file( + "spec/internal/misc.lua", + "local function undent(s)\n return s\nend\n\nreturn {}\n", + ) + .file( + "spec/helpers.lua", + "local misc = require(\"spec.internal.misc\")\n\nreturn {\n unindent = misc.unindent,\n}\n", + ) + .file( + "spec/handler_spec.lua", + "local helpers = require \"spec.helpers\"\nlocal unindent = helpers.unindent\n\nunindent(\"a\")\n", + ) + .write( + "spec/internal/misc.lua", + "local function undent(s)\n return s\nend\n\nreturn { unindent = undent }\n", + ) + .edge_after("file:spec/handler_spec.lua", "calls", "function undent") +} + +/// A call through an alias of a module that appears later reaches the +/// module's member (the alias is not named after it). +pub fn lua_alias_reaches_a_member_of_a_module_that_appears() -> Case { + Case::new(Change::AddImportTarget, &[Language::Lua]) + .file( + "kong/handler.lua", + "local phases = require \"kong.pdk.phases\"\nlocal cp = phases.check\n\nlocal function run()\n cp(\"access\")\nend\n\nreturn { run = run }\n", + ) + .write( + "kong/pdk/phases.lua", + "local function check_phase(accepted)\n return accepted\nend\n\nreturn {\n check = check_phase,\n}\n", + ) + .edge_after("function run", "calls", "function check_phase") +} + +/// A call through `local snooze = ngx.sleep` reaches the project's own +/// `function ngx.sleep` once one appears: the alias reads every node named +/// `sleep`, and the call is named `snooze`. +pub fn lua_alias_of_a_library_function_reaches_the_patch_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Lua]) + .file( + "app/main.lua", + "local snooze = ngx.sleep\n\nlocal function run()\n snooze(1)\nend\n\nreturn { run = run }\n", + ) + .write( + "lib/patch.lua", + "function ngx.sleep(seconds)\n return seconds\nend\n", + ) + .edge_after("function run", "calls", "method ngx::sleep") +} + +/// The reverse: the call leaves the patch once a second one appears, since +/// an alias of a library function names it only while the project has one. +pub fn lua_alias_of_a_library_function_leaves_the_patch_once_a_second_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Lua]) + .file( + "app/main.lua", + "local snooze = ngx.sleep\n\nlocal function run()\n snooze(1)\nend\n\nreturn { run = run }\n", + ) + .file( + "lib/patch.lua", + "function ngx.sleep(seconds)\n return seconds\nend\n", + ) + .write( + "lib/other_patch.lua", + "function ngx.sleep(seconds)\n return seconds + 1\nend\n", + ) + .edge_before("function run", "calls", "method ngx::sleep") + .no_edge_after("function run", "calls", "method ngx::sleep") +} + +/// The same through a module: `util` hands out `M.snooze = ngx.sleep`, so a +/// call through `local nap = util.snooze` reaches the patch that appears +/// though neither file changes, and the caller's own text never names +/// `sleep`. +pub fn lua_alias_through_a_module_reaches_the_library_patch_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Lua]) + .file("app/util.lua", "local M = {}\nM.snooze = ngx.sleep\nreturn M\n") + .file( + "app/main.lua", + "local util = require \"app.util\"\nlocal nap = util.snooze\n\nlocal function run()\n nap(1)\nend\n\nreturn { run = run }\n", + ) + .write( + "lib/patch.lua", + "function ngx.sleep(seconds)\n return seconds\nend\n", + ) + .edge_after("function run", "calls", "method ngx::sleep") +} + +/// The same with the module itself edited in that sync, its surface kept: +/// its callers still reach the patch that appears beside it. +pub fn lua_alias_through_a_changed_module_reaches_the_library_patch_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Lua]) + .file("app/util.lua", "local M = {}\nM.snooze = ngx.sleep\nreturn M\n") + .file( + "app/main.lua", + "local util = require \"app.util\"\nlocal nap = util.snooze\n\nlocal function run()\n nap(1)\nend\n\nreturn { run = run }\n", + ) + .write( + "app/util.lua", + "local M = {}\nM.snooze = ngx.sleep\nM.idle = 1\nreturn M\n", + ) + .write( + "lib/patch.lua", + "function ngx.sleep(seconds)\n return seconds\nend\n", + ) + .edge_after("function run", "calls", "method ngx::sleep") +} + +/// A call through `local respond = kong.response.exit` reaches the one +/// method `exit` of a table named for `response` once it appears. +pub fn lua_alias_of_a_host_table_member_reaches_the_method_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Lua]) + .file( + "app/main.lua", + "local respond = kong.response.exit\n\nlocal function run()\n respond(200)\nend\n\nreturn { run = run }\n", + ) + .write( + "kong/pdk/response.lua", + "local response = {}\n\nfunction response.exit(status)\n return status\nend\n\nreturn response\n", + ) + .edge_after("function run", "calls", "method response::exit") +} + +/// A `require` an alias reads through, written with a trailing comment, +/// spread over lines, or as one target of several: the module starting to +/// export the member reaches every call, as the resolver reads each one. +pub fn lua_alias_through_a_commented_spread_or_multi_target_require_reaches_a_new_export() -> Case { + Case::new(Change::BodyEdit, &[Language::Lua]) + .file( + "kong/pdk/phases.lua", + "local function check_phase(accepted)\n return accepted\nend\n\nreturn {\n}\n", + ) + .file( + "kong/handler.lua", + "local p1 = require \"kong.pdk.phases\" -- phases\nlocal p2 = require(\n \"kong.pdk.phases\"\n)\nlocal n, p3 = 1, require \"kong.pdk.phases\"\nlocal c1 = p1.check\nlocal c2 = p2.check\nlocal c3 = p3.check\n\nlocal function run()\n c1(\"a\")\n c2(\"b\")\n c3(\"c\")\nend\n\nreturn { run = run }\n", + ) + .write( + "kong/pdk/phases.lua", + "local function check_phase(accepted)\n return accepted\nend\n\nreturn {\n check = check_phase,\n}\n", + ) + .edge_after("function run", "calls", "function check_phase") +} + +/// A module two away starts handing out the member while the module in the +/// middle, which re-exports it, changes in the same sync without its +/// surface changing: the unchanged caller still reaches the member. +pub fn lua_alias_reaches_a_new_export_through_a_middle_module_edited_in_the_same_sync() -> Case { + Case::new(Change::BodyEdit, &[Language::Lua]) + .file( + "spec/internal/misc.lua", + "local function undent(s)\n return s\nend\n\nreturn {}\n", + ) + .file( + "spec/helpers.lua", + "local misc = require(\"spec.internal.misc\")\n\nreturn {\n unindent = misc.unindent,\n}\n", + ) + .file( + "spec/handler_spec.lua", + "local helpers = require \"spec.helpers\"\nlocal unindent = helpers.unindent\n\nunindent(\"a\")\n", + ) + .write( + "spec/internal/misc.lua", + "local function undent(s)\n return s\nend\n\nreturn { unindent = undent }\n", + ) + .write( + "spec/helpers.lua", + "local misc = require(\"spec.internal.misc\")\n\nreturn {\n unindent = misc.unindent,\n}\n-- reviewed\n", + ) + .edge_after("file:spec/handler_spec.lua", "calls", "function undent") +} + +/// Of two `local function helper` in one file, the first, which a name in +/// another file matches first, is made global: that file's call reaches it, +/// though the file still declares a local `helper`. +pub fn lua_the_first_of_two_local_namesakes_made_global_reaches_its_callers_in_another_file() -> Case +{ + Case::new(Change::BodyEdit, &[Language::Lua]) + .file( + "spec/helpers.lua", + "local function helper()\n return 0\nend\n\nlocal function helper()\n return 1\nend\n\nreturn {}\n", + ) + .file("spec/plugin_spec.lua", "helper()\n") + .write( + "spec/helpers.lua", + "function helper()\n return 0\nend\n\nlocal function helper()\n return 1\nend\n\nreturn {}\n", + ) + .edge_after("file:spec/plugin_spec.lua", "calls", "function helper") +} + +/// A module re-exports the first of two same-named `local` aliases; the two +/// swap places, so a caller through it reaches the other module's member, +/// though the module declares the same aliases as before. +pub fn lua_alias_follows_the_first_of_two_same_named_aliases_after_they_swap() -> Case { + Case::new(Change::ReexportEdit, &[Language::Lua]) + .file( + "spec/internal/misc.lua", + "local function undent(s)\n return s\nend\n\nreturn { unindent = undent }\n", + ) + .file( + "spec/internal/other.lua", + "local function dedent(s)\n return s\nend\n\nreturn { unindent = dedent }\n", + ) + .file( + "spec/helpers.lua", + "local unindent = require(\"spec.internal.misc\").unindent\nlocal unindent = require(\"spec.internal.other\").unindent\n\nreturn {}\n", + ) + .file( + "spec/handler_spec.lua", + "local helpers = require \"spec.helpers\"\nlocal unindent = helpers.unindent\n\nunindent(\"a\")\n", + ) + .write( + "spec/helpers.lua", + "local unindent = require(\"spec.internal.other\").unindent\nlocal unindent = require(\"spec.internal.misc\").unindent\n\nreturn {}\n", + ) + .edge_before("file:spec/handler_spec.lua", "calls", "function undent") + .edge_after("file:spec/handler_spec.lua", "calls", "function dedent") +} + +/// Upstream #2144 (W4-R-01): a variable a test file assigns at its top +/// level stays out of another test's call of its name when it appears. +pub fn r_test_file_variable_stays_out_of_another_test_that_calls_its_name() -> Case { + Case::new(Change::AddCompetitor, &[Language::R]) + .file("DESCRIPTION", "Package: demo\n") + .file( + "tests/testthat/test-other.R", + "test_that(\"other\", {\n fixture()\n})\n", + ) + .write( + "tests/testthat/test-scales.R", + "fixture <- data.frame(y = 2)\ntest_that(\"works\", {\n expect_true(TRUE)\n})\n", + ) + .no_edge_after( + "file:tests/testthat/test-other.R", + "calls", + "variable fixture", + ) +} + +/// The same variable, its file renamed to a `helper-*.R` every test +/// sources: the other test's call reaches it. +pub fn r_test_variable_moved_to_a_helper_reaches_the_other_tests() -> Case { + Case::new(Change::RenameSymbol, &[Language::R]) + .file("DESCRIPTION", "Package: demo\n") + .file( + "tests/testthat/test-data.R", + "fixture <- data.frame(y = 2)\n", + ) + .file( + "tests/testthat/test-other.R", + "test_that(\"other\", {\n fixture()\n})\n", + ) + .rename("tests/testthat/test-data.R", "tests/testthat/helper-data.R") + .edge_after( + "file:tests/testthat/test-other.R", + "calls", + "variable fixture", + ) +} + +/// Upstream #2239 (W4-R-01): a binding named like a base function is what a +/// call of the name means only while its line holds a function; the line +/// gaining one (`c <- list(f = function(x) x)`, still the same variable) +/// brings the call in another file to it. +pub fn r_base_named_binding_whose_line_gains_a_function_reaches_its_callers() -> Case { + Case::new(Change::BodyEdit, &[Language::R]) + .file("DESCRIPTION", "Package: demo\n") + .file("R/data.R", "c <- list(f = 1)\n") + .file("R/use.R", "use <- function() {\n c(1, 2)\n}\n") + .write("R/data.R", "c <- list(f = function(x) x)\n") + .edge_after("function use", "calls", "variable c") +} + +/// The reverse: the line losing its function takes the call off it. +pub fn r_base_named_binding_whose_line_loses_its_function_leaves_its_callers() -> Case { + Case::new(Change::BodyEdit, &[Language::R]) + .file("DESCRIPTION", "Package: demo\n") + .file("R/data.R", "c <- list(f = function(x) x)\n") + .file("R/use.R", "use <- function() {\n c(1, 2)\n}\n") + .write("R/data.R", "c <- list(f = 1)\n") + .edge_before("function use", "calls", "variable c") + .no_edge_after("function use", "calls", "variable c") +} + +/// Upstream #2177, the R part (W4-R-01): a plain call stays off an object's +/// method of its name when one appears. +pub fn r_bare_call_stays_off_an_objects_method_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::R]) + .file( + "R/scale.R", + "scale_limits <- function(x) {\n range(x)\n}\n", + ) + .write( + "R/coord.R", + "Coord <- ggproto(\"Coord\",\n range = function(panel_params) {\n list(x = panel_params$x$dimension())\n }\n)\n", + ) + .no_edge_after("function scale_limits", "calls", "method Coord::range") +} + +/// Upstream #2145 (W4-DART-01): a bare Dart call in a subclass reaches the +/// method of a base its superclass, in another file, starts to extend: the +/// hierarchy is read from the class heads, and no node of `emit` changed. +pub fn dart_bare_call_reaches_a_method_of_a_base_its_superclass_starts_to_extend() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/base.dart", "class Base {\n void emit(int x) {}\n}\n") + .file("lib/cubit.dart", "class Cubit {}\n") + .file( + "lib/sub.dart", + "class Sub extends Cubit {\n void run() {\n emit(1);\n }\n}\n", + ) + .write("lib/cubit.dart", "class Cubit extends Base {}\n") + .edge_after("method Sub::run", "calls", "method Base::emit") +} + +/// The reverse: the superclass stops extending the base, so the call leaves +/// its method. +pub fn dart_bare_call_leaves_a_method_of_a_base_its_superclass_stops_extending() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/base.dart", "class Base {\n void emit(int x) {}\n}\n") + .file("lib/cubit.dart", "class Cubit extends Base {}\n") + .file( + "lib/sub.dart", + "class Sub extends Cubit {\n void run() {\n emit(1);\n }\n}\n", + ) + .write("lib/cubit.dart", "class Cubit {}\n") + .edge_before("method Sub::run", "calls", "method Base::emit") + .no_edge_after("method Sub::run", "calls", "method Base::emit") +} + +/// An extension in another file comes to sit `on` the caller's base: a bare +/// call of its member there reaches it. +pub fn dart_bare_call_reaches_an_extension_member_once_it_is_on_the_callers_base() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/base.dart", "class Base {}\n\nclass Other {}\n") + .file( + "lib/ext.dart", + "extension Helpers on Other {\n void helper() {}\n}\n", + ) + .file( + "lib/sub.dart", + "class Sub extends Base {\n void run() {\n helper();\n }\n}\n", + ) + .write( + "lib/ext.dart", + "extension Helpers on Base {\n void helper() {}\n}\n", + ) + .edge_after("method Sub::run", "calls", "method Helpers::helper") +} + +/// A superclass in another file starts to extend the type an extension sits +/// `on`: a bare call of the extension's member in the subclass reaches it, +/// though neither the extension nor the call changed. +pub fn dart_bare_call_reaches_an_extension_member_once_its_superclass_extends_the_on_type() -> Case +{ + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/base.dart", "class Base {}\n") + .file( + "lib/ext.dart", + "extension Helpers on Base {\n void helper() {}\n}\n", + ) + .file("lib/cubit.dart", "class Cubit {}\n") + .file( + "lib/sub.dart", + "class Sub extends Cubit {\n void run() {\n helper();\n }\n}\n", + ) + .write("lib/cubit.dart", "class Cubit extends Base {}\n") + .edge_after("method Sub::run", "calls", "method Helpers::helper") +} + +/// A superclass in another file moves a base nearer without the base +/// coming or going: the subclass's bare call now reaches that base's member +/// rather than a farther one through its mixin. +pub fn dart_bare_call_reaches_a_member_whose_base_its_superclass_brings_nearer() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/c.dart", "class C {\n void m() {}\n}\n") + .file( + "lib/b.dart", + "class B2 extends C {}\n\nclass B extends B2 {}\n", + ) + .file( + "lib/d.dart", + "class D {\n void m() {}\n}\n\nclass F extends D {}\n\nclass E extends F {}\n", + ) + .file("lib/a.dart", "class A extends B {}\n") + .file( + "lib/sub.dart", + "class Sub extends A with E {\n void run() {\n m();\n }\n}\n", + ) + .write("lib/a.dart", "class A extends C {}\n") + .edge_before("method Sub::run", "calls", "method D::m") + .edge_after("method Sub::run", "calls", "method C::m") +} + +/// Forty types, so that a head naming them all pushes the rest of a +/// hierarchy past the resolver's walk limit. +static FORTY_TYPES: LazyLock = LazyLock::new(|| { + (1..=40) + .map(|index| format!("class X{index} {{}}\n")) + .collect() +}); + +/// A superclass in another file comes to implement forty types: the walk +/// from the subclass stops before reaching its mixin's base, so the bare +/// call leaves that base's member, though no member came or went. +pub fn dart_bare_call_leaves_a_member_pushed_past_the_walk_limit() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/m.dart", "class M1 {\n void m() {}\n}\n\nclass M extends M1 {}\n") + .file("lib/x.dart", FORTY_TYPES.as_str()) + .file("lib/mid.dart", "class Mid {}\n") + .file( + "lib/sub.dart", + "class Sub extends Mid with M {\n void run() {\n m();\n }\n}\n", + ) + .write( + "lib/mid.dart", + "class Mid implements X1, X2, X3, X4, X5, X6, X7, X8, X9, X10, X11, X12, X13, X14, X15, X16, X17, X18, X19, X20, X21, X22, X23, X24, X25, X26, X27, X28, X29, X30, X31, X32, X33, X34, X35, X36, X37, X38, X39, X40 {}\n", + ) + .edge_before("method Sub::run", "calls", "method M1::m") + .no_edge_after("method Sub::run", "calls", "method M1::m") +} + +/// Upstream #2207 (W4-DART-01): a core method name on an untyped receiver +/// reaches an extension's member only through a receiver named after the +/// type it is `on`; the extension moving to another type takes the call off +/// it, though no node of `endsWith` changed. +pub fn dart_core_method_call_leaves_an_extension_moved_to_another_type() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/rx.dart", + "extension RxStringExt on Rx {\n bool endsWith(String s) => true;\n}\n", + ) + .file( + "lib/use.dart", + "bool isVideo() {\n var stringValue = load();\n return stringValue.endsWith(\".avi\");\n}\n", + ) + .write( + "lib/rx.dart", + "extension RxStringExt on Rx {\n bool endsWith(String s) => true;\n}\n", + ) + .edge_before("function isVideo", "calls", "method RxStringExt::endsWith") + .no_edge_after("function isVideo", "calls", "method RxStringExt::endsWith") +} + +/// 501 parked calls of `endsWith` through receivers named after nothing, +/// so the shared tail retry skips that tail. +static ENDS_WITH_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("void noise() {\n"); + for index in 0..501 { + text.push_str(&format!(" missing{index}.endsWith('x');\n")); + } + text.push_str("}\n"); + text +}); + +/// The reverse, past the tail ceiling: the extension moving onto `Rx` +/// brings `stringValue.endsWith(…)` to it, however many parked calls share +/// the `endsWith` tail. +pub fn dart_core_method_call_reaches_an_extension_moved_onto_its_type_past_the_tail_ceiling() -> Case +{ + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/rx.dart", + "extension RxStringExt on Rx {\n bool endsWith(String s) => true;\n}\n", + ) + .file( + "lib/use.dart", + "bool isVideo() {\n var stringValue = load();\n return stringValue.endsWith(\".avi\");\n}\n", + ) + .file("noise/noise.dart", ENDS_WITH_CALLS.as_str()) + .write( + "lib/rx.dart", + "extension RxStringExt on Rx {\n bool endsWith(String s) => true;\n}\n", + ) + .edge_after("function isVideo", "calls", "method RxStringExt::endsWith") +} + +/// Upstream #2237 (W4-DART-01): an extension losing its name applies only in +/// its own file, so a subclass's bare call in another file leaves its member. +pub fn dart_bare_call_leaves_an_extension_member_once_the_extension_is_unnamed() -> Case { + Case::new(Change::RenameSymbol, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/foo_type.dart", "class Foo {}\n") + .file( + "lib/foo.dart", + "extension Helpers on Foo {\n void helper() {}\n}\n", + ) + .file( + "lib/bar.dart", + "class Bar extends Foo {\n void run() {\n helper();\n }\n}\n", + ) + .write( + "lib/foo.dart", + "extension on Foo {\n void helper() {}\n}\n", + ) + .edge_before("method Bar::run", "calls", "method Helpers::helper") + .no_edge_after("method Bar::run", "calls", "method ::helper") +} + +// ----- a call through a class name and the heads it reads (W5-07) ------------ +// +// Upstream #2180 (W3-06): a call through a class name reaches the method the +// class inherits, walking the supertypes each class's head names, read from +// source (`class_heads::declared_supertypes`). The `class_heads` lane records +// those heads, so a head changed in one file moves such a call in an +// unchanged file (W5-07). + +/// A PHP class head spread over more lines: `extends Base` past the head's +/// third line, which `declared_supertypes` reads, though within the fifth, +/// which the `php_heads` rows record. A fresh index no longer walks to `Base` +/// for `Child::method()` and resolves it by the method's name instead. +pub fn php_inherited_static_call_follows_a_class_head_spread_past_its_third_line() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Base.php", + " Case { + Case::new(Change::EditHeritage, &[Language::Python]) + .file( + "pkg/base.py", + "class Base:\n @classmethod\n def create(cls):\n return cls()\n", + ) + .file( + "pkg/other.py", + "class Other:\n @classmethod\n def create(cls):\n return cls()\n", + ) + .file( + "pkg/user.py", + "from pkg.base import Base\nfrom pkg.other import Other\n\n\nclass User(Base):\n pass\n", + ) + .file( + "pkg/service.py", + "from pkg.user import User\n\n\ndef make():\n return User.create()\n", + ) + .write( + "pkg/user.py", + "from pkg.base import Base\nfrom pkg.other import Other\n\n\nclass User(Other):\n pass\n", + ) + .edge_before("function make", "calls", "method Base::create") + .edge_after("function make", "calls", "method Other::create") +} + +/// The same with the call beside its old target: the unchanged file holds +/// both `Base.create` and `User.create()`, so the edge to move is a same-file +/// one. +pub fn python_inherited_classmethod_follows_a_base_change_beside_its_old_target() -> Case { + Case::new(Change::EditHeritage, &[Language::Python]) + .file( + "pkg/service.py", + "from pkg.user import User\n\n\nclass Base:\n @classmethod\n def create(cls):\n return cls()\n\n\ndef make():\n return User.create()\n", + ) + .file( + "pkg/other.py", + "class Other:\n @classmethod\n def create(cls):\n return cls()\n", + ) + .file( + "pkg/user.py", + "from pkg.service import Base\nfrom pkg.other import Other\n\n\nclass User(Base):\n pass\n", + ) + .write( + "pkg/user.py", + "from pkg.service import Base\nfrom pkg.other import Other\n\n\nclass User(Other):\n pass\n", + ) + .edge_before("function make", "calls", "method Base::create") + .edge_after("function make", "calls", "method Other::create") +} + +/// The same for a Java class's `extends`. +pub fn java_inherited_static_call_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/app/Base.java", + "package app;\n\npublic class Base {\n public static int create() { return 1; }\n}\n", + ) + .file( + "src/app/Other.java", + "package app;\n\npublic class Other {\n public static int create() { return 2; }\n}\n", + ) + .file( + "src/app/User.java", + "package app;\n\npublic class User extends Base {\n}\n", + ) + .file( + "src/app/Service.java", + "package app;\n\npublic class Service {\n public int make() {\n return User.create();\n }\n}\n", + ) + .write( + "src/app/User.java", + "package app;\n\npublic class User extends Other {\n}\n", + ) + .edge_before("method app::Service::make", "calls", "method app::Base::create") + .edge_after("method app::Service::make", "calls", "method app::Other::create") +} + +/// The same for a C# class's base list. +pub fn csharp_inherited_static_call_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::CSharp]) + .file( + "src/Base.cs", + "namespace App;\n\npublic class Base\n{\n public static int Create() => 1;\n}\n", + ) + .file( + "src/Other.cs", + "namespace App;\n\npublic class Other\n{\n public static int Create() => 2;\n}\n", + ) + .file( + "src/User.cs", + "namespace App;\n\npublic class User : Base\n{\n}\n", + ) + .file( + "src/Service.cs", + "namespace App;\n\npublic class Service\n{\n public int Make()\n {\n return User.Create();\n }\n}\n", + ) + .write( + "src/User.cs", + "namespace App;\n\npublic class User : Other\n{\n}\n", + ) + .edge_before("method App::Service::Make", "calls", "method App::Base::Create") + .edge_after("method App::Service::Make", "calls", "method App::Other::Create") +} + +/// The same for a Swift class's superclass. +pub fn swift_inherited_class_method_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Swift]) + .file( + "src/Base.swift", + "class Base {\n class func create() -> Int { return 1 }\n}\n", + ) + .file( + "src/Other.swift", + "class Other {\n class func create() -> Int { return 2 }\n}\n", + ) + .file("src/User.swift", "class User: Base {\n}\n") + .file( + "src/Service.swift", + "func make() -> Int {\n return User.create()\n}\n", + ) + .write("src/User.swift", "class User: Other {\n}\n") + .edge_before("function make", "calls", "method Base::create") + .edge_after("function make", "calls", "method Other::create") +} + +/// The same for a Ruby class's `<` (converged before W5-07: the Ruby lane's +/// rows hold the same heads). +pub fn ruby_class_method_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Ruby]) + .file( + "lib/base.rb", + "class Base\n def self.create\n new\n end\nend\n", + ) + .file( + "lib/other.rb", + "class Other\n def self.create\n new\n end\nend\n", + ) + .file("lib/user.rb", "class User < Base\nend\n") + .file("lib/service.rb", "def make\n User.create\nend\n") + .write("lib/user.rb", "class User < Other\nend\n") + .edge_before("function make", "calls", "method Base::create") + .edge_after("function make", "calls", "method Other::create") +} + +/// The same for a Kotlin class's supertype list. +pub fn kotlin_class_name_call_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Kotlin]) + .file( + "src/Base.kt", + "open class Base {\n fun create(): Int = 1\n}\n", + ) + .file( + "src/Other.kt", + "open class Other {\n fun create(): Int = 2\n}\n", + ) + .file("src/User.kt", "class User : Base()\n") + .file("src/Service.kt", "fun make(): Int = User.create()\n") + .write("src/User.kt", "class User : Other()\n") + .edge_before("function make", "calls", "method Base::create") + .edge_after("function make", "calls", "method Other::create") +} + +/// Not through the walk: a Dart call through a type's name reaches that +/// type's own static members only (W4-DART-03c), so `User.create()` stays +/// unresolved whichever base `User` extends. +pub fn dart_class_name_call_reaches_no_inherited_static_member() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file( + "lib/base.dart", + "class Base {\n static int create() => 1;\n}\n", + ) + .file( + "lib/other.dart", + "class Other {\n static int create() => 2;\n}\n", + ) + .file( + "lib/user.dart", + "import 'base.dart';\nimport 'other.dart';\n\nclass User extends Base {}\n", + ) + .file( + "lib/service.dart", + "import 'user.dart';\n\nint make() => User.create();\n", + ) + .write( + "lib/user.dart", + "import 'base.dart';\nimport 'other.dart';\n\nclass User extends Other {}\n", + ) + .no_edge_after("function make", "calls", "method Base::create") + .no_edge_after("function make", "calls", "method Other::create") +} + +/// The same for a Scala object's `extends`. +pub fn scala_object_call_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Scala]) + .file( + "src/Base.scala", + "abstract class Base {\n def create(): Int = 1\n}\n", + ) + .file( + "src/Other.scala", + "abstract class Other {\n def create(): Int = 2\n}\n", + ) + .file("src/User.scala", "object User extends Base\n") + .file( + "src/Service.scala", + "object Service {\n def make(): Int = User.create()\n}\n", + ) + .write("src/User.scala", "object User extends Other\n") + .edge_before("method Service::make", "calls", "method Base::create") + .edge_after("method Service::make", "calls", "method Other::create") +} + +/// The same for an Objective-C `@interface`'s superclass (converged before +/// W5-07: the Objective-C lane's rows hold the same superclass). +pub fn objc_class_message_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::ObjC]) + .file( + "src/Base.m", + "@interface Base : NSObject\n+ (int)create;\n@end\n\n@implementation Base\n+ (int)create { return 1; }\n@end\n", + ) + .file( + "src/Other.m", + "@interface Other : NSObject\n+ (int)create;\n@end\n\n@implementation Other\n+ (int)create { return 2; }\n@end\n", + ) + .file( + "src/User.m", + "@interface User : Base\n@end\n\n@implementation User\n@end\n", + ) + .file( + "src/Service.m", + "int make(void) {\n return [User create];\n}\n", + ) + .write( + "src/User.m", + "@interface User : Other\n@end\n\n@implementation User\n@end\n", + ) + .edge_before("function make", "calls", "method Base::create") + .edge_after("function make", "calls", "method Other::create") +} + +/// The same for a Pascal `class(…)` head. +pub fn pascal_class_function_call_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Pascal]) + .file( + "Base.pas", + "unit Base;\ninterface\ntype\n TBase = class\n class function Make: Integer;\n end;\nimplementation\nclass function TBase.Make: Integer;\nbegin\n Result := 1;\nend;\nend.\n", + ) + .file( + "Other.pas", + "unit Other;\ninterface\ntype\n TOther = class\n class function Make: Integer;\n end;\nimplementation\nclass function TOther.Make: Integer;\nbegin\n Result := 2;\nend;\nend.\n", + ) + .file( + "User.pas", + "unit User;\ninterface\nuses Base, Other;\ntype\n TUser = class(TBase)\n end;\nimplementation\nend.\n", + ) + .file( + "Service.pas", + "unit Service;\ninterface\nuses User;\nfunction Run: Integer;\nimplementation\nfunction Run: Integer;\nbegin\n Result := TUser.Make();\nend;\nend.\n", + ) + .write( + "User.pas", + "unit User;\ninterface\nuses Base, Other;\ntype\n TUser = class(TOther)\n end;\nimplementation\nend.\n", + ) + .edge_before("file:Service.pas", "calls", "method TBase::Make") + .edge_after("file:Service.pas", "calls", "method TOther::Make") +} + +/// Not through the walk: a TS call of a static method through an imported +/// class reads as an instantiation of that class, whichever base it +/// extends. +pub fn typescript_static_call_through_an_imported_subclass_reads_no_head() -> Case { + Case::new(Change::EditHeritage, &[Language::TypeScript]) + .file( + "src/base.ts", + "export class Base {\n static create(): number {\n return 1;\n }\n}\n", + ) + .file( + "src/other.ts", + "export class Other {\n static create(): number {\n return 2;\n }\n}\n", + ) + .file( + "src/user.ts", + "import { Base } from './base';\nimport { Other } from './other';\n\nexport class User extends Base {}\n", + ) + .file( + "src/service.ts", + "import { User } from './user';\n\nexport function make(): number {\n return User.create();\n}\n", + ) + .write( + "src/user.ts", + "import { Base } from './base';\nimport { Other } from './other';\n\nexport class User extends Other {}\n", + ) + .edge_throughout("function make", "instantiates", "class User") +} + +const PY_BASE: &str = "class Base:\n @classmethod\n def create(cls):\n return cls()\n"; +const PY_OTHER: &str = + "class Other:\n @classmethod\n def create(cls):\n return cls()\n"; +const PY_SERVICE: &str = "from pkg.user import User\n\n\ndef make():\n return User.create()\n"; + +/// The base's file deleted: `User.create()` leaves its classmethod. +pub fn python_inherited_classmethod_leaves_a_deleted_base() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Python]) + .file("pkg/base.py", PY_BASE) + .file( + "pkg/user.py", + "from pkg.base import Base\n\n\nclass User(Base):\n pass\n", + ) + .file("pkg/service.py", PY_SERVICE) + .edge_before("function make", "calls", "method Base::create") + .remove("pkg/base.py") +} + +/// Two levels up: the middle class's base changes, and `User.create()` +/// through it follows. +pub fn python_inherited_classmethod_follows_a_middle_class_base_change() -> Case { + Case::new(Change::EditHeritage, &[Language::Python]) + .file("pkg/base.py", PY_BASE) + .file("pkg/other.py", PY_OTHER) + .file( + "pkg/mid.py", + "from pkg.base import Base\nfrom pkg.other import Other\n\n\nclass Mid(Base):\n pass\n", + ) + .file( + "pkg/user.py", + "from pkg.mid import Mid\n\n\nclass User(Mid):\n pass\n", + ) + .file("pkg/service.py", PY_SERVICE) + .write( + "pkg/mid.py", + "from pkg.base import Base\nfrom pkg.other import Other\n\n\nclass Mid(Other):\n pass\n", + ) + .edge_before("function make", "calls", "method Base::create") + .edge_after("function make", "calls", "method Other::create") +} + +/// A call parked while no class above `User` has `create` is retried once +/// the middle class's new base has it: the walk from `User` reads the middle +/// class's head, one level down from the change. +pub fn python_parked_class_method_reaches_a_base_its_middle_class_gains() -> Case { + Case::new(Change::EditHeritage, &[Language::Python]) + .file("pkg/base.py", "class Base:\n pass\n") + .file("pkg/other.py", PY_OTHER) + .file( + "pkg/mid.py", + "from pkg.base import Base\nfrom pkg.other import Other\n\n\nclass Mid(Base):\n pass\n", + ) + .file( + "pkg/user.py", + "from pkg.mid import Mid\n\n\nclass User(Mid):\n pass\n", + ) + .file("pkg/service.py", PY_SERVICE) + .write( + "pkg/mid.py", + "from pkg.base import Base\nfrom pkg.other import Other\n\n\nclass Mid(Other):\n pass\n", + ) + .edge_after("function make", "calls", "method Other::create") +} + +/// The changed head's old and new targets two levels above it: `User` +/// moves from a middle class over `Base` to one over `Other`. +pub fn python_inherited_classmethod_follows_a_base_two_levels_above_the_changed_head() -> Case { + Case::new(Change::EditHeritage, &[Language::Python]) + .file("pkg/base.py", PY_BASE) + .file("pkg/other.py", PY_OTHER) + .file( + "pkg/mids.py", + "from pkg.base import Base\nfrom pkg.other import Other\n\n\nclass Mid1(Base):\n pass\n\n\nclass Mid2(Other):\n pass\n", + ) + .file( + "pkg/user.py", + "from pkg.mids import Mid1, Mid2\n\n\nclass User(Mid1):\n pass\n", + ) + .file("pkg/service.py", PY_SERVICE) + .write( + "pkg/user.py", + "from pkg.mids import Mid1, Mid2\n\n\nclass User(Mid2):\n pass\n", + ) + .edge_before("function make", "calls", "method Base::create") + .edge_after("function make", "calls", "method Other::create") +} + +/// Five levels up the base is in reach; the fourth class's head changed to +/// one more level puts it at the sixth, past the walk. +pub fn python_inherited_classmethod_leaves_a_base_pushed_past_five_levels() -> Case { + Case::new(Change::EditHeritage, &[Language::Python]) + .file("pkg/base.py", PY_BASE) + .file( + "pkg/levels.py", + "from pkg.base import Base\n\n\nclass L5(Base):\n pass\n\n\nclass L3(L4):\n pass\n\n\nclass L2(L3):\n pass\n\n\nclass L1(L2):\n pass\n", + ) + .file( + "pkg/l4.py", + "from pkg.base import Base\n\n\nclass L4(Base):\n pass\n", + ) + .file( + "pkg/user.py", + "from pkg.levels import L1\n\n\nclass User(L1):\n pass\n", + ) + .file("pkg/service.py", PY_SERVICE) + .edge_before("function make", "calls", "method Base::create") + .write( + "pkg/l4.py", + "from pkg.levels import L5\n\n\nclass L4(L5):\n pass\n", + ) +} + +/// #897: a Kotlin function's value reference follows the object constant it +/// reads. +pub fn kotlin_object_constant_reader_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Kotlin]) + .file( + "src/Config.kt", + "object Config {\n const val MAX_RETRIES = 3\n const val MAX_WAIT = 30\n\n fun limit(): Int = MAX_RETRIES\n}\n", + ) + .write( + "src/Config.kt", + "object Config {\n const val MAX_RETRIES = 3\n const val MAX_WAIT = 30\n\n fun limit(): Int = MAX_WAIT\n}\n", + ) + .edge_before("method Config::limit", "references", "constant Config::MAX_RETRIES") + .edge_after("method Config::limit", "references", "constant Config::MAX_WAIT") +} + +/// #897: a Ruby method's value reference follows the class constant it reads. +pub fn ruby_class_constant_reader_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Ruby]) + .file( + "lib/config.rb", + "class Config\n MAX_RETRIES = 3\n MAX_WAIT = 30\n\n def limit\n MAX_RETRIES\n end\nend\n", + ) + .write( + "lib/config.rb", + "class Config\n MAX_RETRIES = 3\n MAX_WAIT = 30\n\n def limit\n MAX_WAIT\n end\nend\n", + ) + .edge_before("method Config::limit", "references", "variable Config::MAX_RETRIES") + .edge_after("method Config::limit", "references", "variable Config::MAX_WAIT") +} + +/// G4: a static field read references the class it reads through. +pub fn java_static_field_read_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Java]) + .file( + "src/app/Limits.java", + "package app;\n\nclass Limits {\n static final int MAX = 10;\n}\n", + ) + .file( + "src/app/Bounds.java", + "package app;\n\nclass Bounds {\n static final int MAX = 20;\n}\n", + ) + .file( + "src/app/Reader.java", + "package app;\n\nclass Reader {\n int peek() {\n return Limits.MAX;\n }\n}\n", + ) + .write( + "src/app/Reader.java", + "package app;\n\nclass Reader {\n int peek() {\n return Bounds.MAX;\n }\n}\n", + ) + .edge_before( + "method app::Reader::peek", + "references", + "class app::Limits", + ) + .edge_after( + "method app::Reader::peek", + "references", + "class app::Bounds", + ) +} + +/// G4: a C++ read through a capitalized scope references the type it names. +pub fn cpp_scoped_read_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "src/colors.hpp", + "enum Color { RED, GREEN };\nenum Shade { DARK, LIGHT };\n", + ) + .file( + "src/paint.cpp", + "#include \"colors.hpp\"\n\nint paint() {\n return Color::RED;\n}\n", + ) + .write( + "src/paint.cpp", + "#include \"colors.hpp\"\n\nint paint() {\n return Shade::DARK;\n}\n", + ) + .edge_before("function paint", "references", "enum Color") + .edge_after("function paint", "references", "enum Shade") +} + +/// #895: a Go package value reads what its initializer names. +pub fn go_value_initializer_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Go]) + .file("go.mod", "module example.com/limits\n\ngo 1.22\n") + .file( + "limits.go", + "package limits\n\nconst BaseLimit = 10\nconst WideLimit = 50\n\nvar ScaledLimit = BaseLimit * 2\n", + ) + .write( + "limits.go", + "package limits\n\nconst BaseLimit = 10\nconst WideLimit = 50\n\nvar ScaledLimit = WideLimit * 2\n", + ) + .edge_before("variable ScaledLimit", "references", "constant BaseLimit") + .edge_after("variable ScaledLimit", "references", "constant WideLimit") +} + +/// #2268: an Astro frontmatter's top-level call is the component's. +pub fn astro_frontmatter_call_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Astro]) + .file( + "src/lib/posts.ts", + "export function getPosts(): string[] {\n return [];\n}\n\nexport function getDrafts(): string[] {\n return [];\n}\n", + ) + .file( + "src/pages/index.astro", + "---\nimport { getPosts, getDrafts } from '../lib/posts';\nconst posts = getPosts();\n---\n
        {posts.length}
      \n", + ) + .write( + "src/pages/index.astro", + "---\nimport { getPosts, getDrafts } from '../lib/posts';\nconst posts = getDrafts();\n---\n
        {posts.length}
      \n", + ) + .edge_before( + "component src/pages/index.astro::index", + "calls", + "function getPosts", + ) + .edge_after( + "component src/pages/index.astro::index", + "calls", + "function getDrafts", + ) +} + +/// #895: a function's value reference follows the constant its body reads. +pub fn typescript_value_reader_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::TypeScript]) + .file( + "src/paging.ts", + "export const PAGE_SIZE = 20;\nexport const PAGE_LIMIT = 50;\n\nexport function pages(total: number): number {\n return Math.ceil(total / PAGE_SIZE);\n}\n", + ) + .write( + "src/paging.ts", + "export const PAGE_SIZE = 20;\nexport const PAGE_LIMIT = 50;\n\nexport function pages(total: number): number {\n return Math.ceil(total / PAGE_LIMIT);\n}\n", + ) + .edge_before("function pages", "references", "constant PAGE_SIZE") + .edge_after("function pages", "references", "constant PAGE_LIMIT") +} + +/// G13: a C++ call keeps its scope, so `ns::compute()` and `alt::compute()` +/// reach different functions of one name. +pub fn cpp_qualified_call_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Cpp]) + .file( + "src/compute.hpp", + "namespace ns {\nint compute();\n}\n\nnamespace alt {\nint compute();\n}\n", + ) + .file( + "src/compute.cpp", + "#include \"compute.hpp\"\n\nnamespace ns {\nint compute() { return 1; }\n}\n\nnamespace alt {\nint compute() { return 2; }\n}\n", + ) + .file( + "src/run.cpp", + "#include \"compute.hpp\"\n\nint run() {\n return ns::compute();\n}\n", + ) + .write( + "src/run.cpp", + "#include \"compute.hpp\"\n\nint run() {\n return alt::compute();\n}\n", + ) + .edge_before("function run", "calls", "function ns::compute") + .edge_after("function run", "calls", "function alt::compute") +} + +pub fn vue_component_template_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Vue]) + .file("package.json", "{\"name\":\"shop\",\"dependencies\":{\"vue\":\"3.4.0\"}}\n") + .file( + "src/components/Badge.vue", + "\n\n\n", + ) + .file( + "src/App.vue", + "\n\n\n", + ) + .write( + "src/components/Badge.vue", + "\n\n\n", + ) + .edge_throughout( + "component src/App.vue::App", + "references", + "component src/components/Badge.vue::Badge", + ) +} + +pub fn liquid_snippet_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Liquid]) + .file( + "templates/product.liquid", + "
      \n {% render 'price' %}\n
      \n", + ) + .file( + "snippets/price.liquid", + "{{ product.price }}\n", + ) + .write( + "snippets/price.liquid", + "{{ product.price | money }}\n", + ) + .edge_throughout( + "file:templates/product.liquid", + "references", + "file:snippets/price.liquid", + ) +} + +pub fn luau_module_function_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Luau]) + .file( + "src/Inventory.luau", + "local Inventory = {}\n\nfunction Inventory.count(items: { string }): number\n return #items\nend\n\nreturn Inventory\n", + ) + .file( + "src/Shop.luau", + "local Inventory = require(script.Parent.Inventory)\n\nlocal function stock(items: { string }): number\n return Inventory.count(items)\nend\n\nreturn stock\n", + ) + .write( + "src/Inventory.luau", + "local Inventory = {}\n\nfunction Inventory.count(items: { string }): number\n return #items + 0\nend\n\nreturn Inventory\n", + ) + .edge_throughout("function stock", "calls", "method Inventory::count") + .edge_throughout("file:src/Shop.luau", "imports", "file:src/Inventory.luau") +} + +/// G8: a Vue script's function bodies are extracted, so editing a call in one +/// moves its edge. +pub fn vue_script_function_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Vue, Language::TypeScript]) + .file( + "src/actions.ts", + "export function run(): number {\n return 1;\n}\n\nexport function stop(): number {\n return 0;\n}\n", + ) + .file( + "src/App.vue", + "\n\n\n", + ) + .write( + "src/App.vue", + "\n\n\n", + ) + .edge_before("function go", "calls", "function run") + .edge_after("function go", "calls", "function stop") +} + +/// G9 / upstream #2268: a Svelte instance script's top-level call is the +/// component's. +pub fn svelte_instance_script_call_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Svelte, Language::TypeScript]) + .file( + "src/setup.ts", + "export function doSetup(): void {}\n\nexport function doTeardown(): void {}\n", + ) + .file( + "src/Widget.svelte", + "\n\n

      hi

      \n", + ) + .write( + "src/Widget.svelte", + "\n\n

      hi

      \n", + ) + .edge_before("component src/Widget.svelte::Widget", "calls", "function doSetup") + .edge_after("component src/Widget.svelte::Widget", "calls", "function doTeardown") +} + +/// G11: a Ruby call keeps its receiver, so a local built by `Logger.new` +/// types it (ahead of `Audit`'s same-named methods), and an edit moves the +/// edge to the other method. +pub fn ruby_receiver_call_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Ruby]) + .file( + "audit.rb", + "class Audit\n def log(m)\n m\n end\n\n def warn(m)\n m\n end\nend\n", + ) + .file( + "logger.rb", + "class Logger\n def log(m)\n m\n end\n\n def warn(m)\n m\n end\nend\n", + ) + .file( + "service.rb", + "require_relative \"logger\"\n\nclass Service\n def run\n lg = Logger.new\n lg.log(1)\n end\nend\n", + ) + .write( + "service.rb", + "require_relative \"logger\"\n\nclass Service\n def run\n lg = Logger.new\n lg.warn(1)\n end\nend\n", + ) + .edge_before("method Service::run", "calls", "method Logger::log") + .edge_after("method Service::run", "calls", "method Logger::warn") +} + +pub fn objc_implementation_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::ObjC]) + .file( + "Greeter.h", + "#import \n\n@interface Greeter : NSObject\n- (NSString *)greet:(NSString *)name;\n@end\n", + ) + .file( + "Greeter.m", + "#import \"Greeter.h\"\n\n@implementation Greeter\n- (NSString *)greet:(NSString *)name {\n return name;\n}\n@end\n", + ) + .file( + "main.m", + "#import \"Greeter.h\"\n\nint main(void) {\n Greeter *greeter = [[Greeter alloc] init];\n [greeter greet:@\"there\"];\n return 0;\n}\n", + ) + .write( + "Greeter.m", + "#import \"Greeter.h\"\n\n@implementation Greeter\n- (NSString *)greet:(NSString *)name {\n return [name copy];\n}\n@end\n", + ) + .edge_throughout("file:main.m", "imports", "file:Greeter.h") + // `[[Greeter alloc] init]` is a message to the class: a reference to + // it (G5), as upstream records it. + .edge_throughout("function main", "references", "class Greeter") +} + +pub fn gdscript_autoload_method_body_edit() -> Case { + Case::new( + Change::BodyEdit, + &[Language::Gdscript, Language::GodotProject, Language::GodotScene], + ) + .file( + "project.godot", + "config_version=5\n\n[application]\n\nconfig/name=\"Fixture\"\n\n[autoload]\n\nGameFlow=\"*res://game_flow.gd\"\n", + ) + .file( + "main.tscn", + "[gd_scene load_steps=2 format=3]\n\n[ext_resource type=\"Script\" path=\"res://stage.gd\" id=\"1_stage\"]\n\n[node name=\"Main\" type=\"Node\"]\nscript = ExtResource(\"1_stage\")\n", + ) + .file( + "game_flow.gd", + "extends Node\n\nfunc return_to_map() -> void:\n\tpass\n", + ) + .file( + "stage.gd", + "extends Node\n\nfunc _goto_map() -> void:\n\tGameFlow.return_to_map()\n", + ) + .write( + "game_flow.gd", + "extends Node\n\nfunc return_to_map() -> void:\n\tprint(\"map\")\n", + ) + .edge_throughout("function _goto_map", "calls", "function return_to_map") +} + +// ----- a declaration is renamed under unchanged references ----------------- + +pub fn typescript_rename_an_imported_function() -> Case { + Case::new(Change::RenameSymbol, &[Language::TypeScript]) + .file( + "src/util.ts", + "export function helper(): number {\n return 1;\n}\n", + ) + .file( + "src/main.ts", + "import { helper } from './util';\n\nexport function main(): number {\n return helper();\n}\n", + ) + .write( + "src/util.ts", + "export function assist(): number {\n return 1;\n}\n", + ) + .edge_before("function main", "calls", "function helper") +} + +pub fn javascript_rename_a_function_used_by_import_and_require() -> Case { + Case::new(Change::RenameSymbol, &[Language::JavaScript]) + .file( + "lib/util.js", + "export function format(value) {\n return String(value);\n}\n", + ) + .file( + "main.js", + "import { format } from './lib/util.js';\n\nexport function show(value) {\n return format(value);\n}\n", + ) + .file( + "legacy.js", + "const util = require('./lib/util.js');\n\nfunction legacy(value) {\n return util.format(value);\n}\n\nmodule.exports = { legacy };\n", + ) + .write( + "lib/util.js", + "export function render(value) {\n return String(value);\n}\n", + ) + .edge_before("function show", "calls", "function format") + .edge_before("function legacy", "calls", "function format") +} + +pub fn cpp_rename_a_member_function() -> Case { + Case::new(Change::RenameSymbol, &[Language::Cpp]) + .file( + "widget.hpp", + "class Widget {\npublic:\n int size() const;\n};\n", + ) + .file( + "widget.cpp", + "#include \"widget.hpp\"\n\nint Widget::size() const {\n return 1;\n}\n", + ) + .file( + "main.cpp", + "#include \"widget.hpp\"\n\nint measure(const Widget &widget) {\n return widget.size();\n}\n", + ) + .write( + "widget.hpp", + "class Widget {\npublic:\n int area() const;\n};\n", + ) + .write( + "widget.cpp", + "#include \"widget.hpp\"\n\nint Widget::area() const {\n return 1;\n}\n", + ) + .edge_before("function measure", "calls", "method Widget::size") +} + +pub fn csharp_rename_a_called_method() -> Case { + Case::new(Change::RenameSymbol, &[Language::CSharp]) + .file( + "Models/Widget.cs", + "namespace Shop.Models\n{\n public class Widget\n {\n public int Size() { return 1; }\n }\n}\n", + ) + .file( + "Models/Factory.cs", + "namespace Shop.Models\n{\n public class Factory\n {\n public int Build()\n {\n var widget = new Widget();\n return widget.Size();\n }\n }\n}\n", + ) + .write( + "Models/Widget.cs", + "namespace Shop.Models\n{\n public class Widget\n {\n public int Measure() { return 1; }\n }\n}\n", + ) + .edge_before( + "method Shop.Models::Factory::Build", + "calls", + "method Shop.Models::Widget::Size", + ) +} + +pub fn svelte_rename_an_imported_function() -> Case { + Case::new(Change::RenameSymbol, &[Language::Svelte]) + .file( + "src/format.ts", + "export function formatName(name: string): string {\n return name.trim();\n}\n", + ) + .file( + "src/Hello.svelte", + "\n\n

      Hello {shown}

      \n", + ) + .write( + "src/format.ts", + "export function tidyName(name: string): string {\n return name.trim();\n}\n", + ) + // An instance script's top-level constant's initializer is the + // component's call (upstream #2268). + .edge_before("component src/Hello.svelte::Hello", "calls", "function formatName") +} + +pub fn pascal_rename_a_unit_function() -> Case { + Case::new(Change::RenameSymbol, &[Language::Pascal]) + .file( + "Utils.pas", + "unit Utils;\n\ninterface\n\nfunction Double(Value: Integer): Integer;\n\nimplementation\n\nfunction Double(Value: Integer): Integer;\nbegin\n Result := Value * 2;\nend;\n\nend.\n", + ) + .file( + "Main.pas", + "unit Main;\n\ninterface\n\nuses Utils;\n\nfunction Quadruple(Value: Integer): Integer;\n\nimplementation\n\nfunction Quadruple(Value: Integer): Integer;\nbegin\n Result := Double(Double(Value));\nend;\n\nend.\n", + ) + .write( + "Utils.pas", + "unit Utils;\n\ninterface\n\nfunction Twice(Value: Integer): Integer;\n\nimplementation\n\nfunction Twice(Value: Integer): Integer;\nbegin\n Result := Value * 2;\nend;\n\nend.\n", + ) + .edge_before("file:Main.pas", "calls", "function Double") +} + +pub fn scala_rename_a_called_method() -> Case { + Case::new(Change::RenameSymbol, &[Language::Scala]) + .file( + "src/Shapes.scala", + "package shapes\n\nclass Circle(radius: Double) {\n def area(): Double = radius * radius * 3.14\n}\n", + ) + .file( + "src/Main.scala", + "package shapes\n\nobject Main {\n def total(): Double = {\n val circle = new Circle(1.0)\n circle.area()\n }\n}\n", + ) + .write( + "src/Shapes.scala", + "package shapes\n\nclass Circle(radius: Double) {\n def surface(): Double = radius * radius * 3.14\n}\n", + ) + .edge_before("method Main::total", "calls", "method Circle::area") +} + +pub fn terraform_rename_a_variable() -> Case { + Case::new(Change::RenameSymbol, &[Language::Terraform]) + .file( + "variables.tf", + "variable \"region\" {\n type = string\n default = \"us-east-1\"\n}\n", + ) + .file( + "main.tf", + "provider \"aws\" {\n region = var.region\n}\n\nresource \"aws_s3_bucket\" \"logs\" {\n bucket = \"logs-${var.region}\"\n}\n", + ) + .write( + "variables.tf", + "variable \"aws_region\" {\n type = string\n default = \"us-east-1\"\n}\n", + ) + .edge_before( + "class aws_s3_bucket.logs", + "references", + "variable var.region", + ) +} + +/// Upstream #2178 (b): a bare call another module's function answers only +/// through `-import`: the imported module gains it, and the call links it, +/// never the namesake of a module it does not import. +pub fn erlang_imported_call_reaches_a_function_its_module_gains() -> Case { + Case::new(Change::AddImportTarget, &[Language::Erlang]) + .file( + "test/req_SUITE.erl", + "-module(req_SUITE).\n-export([other/0]).\n\nother() -> ok.\n", + ) + .file( + "test/compress_SUITE.erl", + "-module(compress_SUITE).\n-export([do_get/3]).\n\ndo_get(Path, Headers, Config) -> {Path, Headers, Config}.\n", + ) + .file( + "test/static_handler_SUITE.erl", + "-module(static_handler_SUITE).\n-export([etag/1]).\n-import(req_SUITE, [do_get/3]).\n\netag(Config) -> do_get(\"/etag\", [], Config).\n", + ) + .write( + "test/req_SUITE.erl", + "-module(req_SUITE).\n-export([other/0, do_get/3]).\n\nother() -> ok.\n\ndo_get(Path, Headers, Config) -> {Path, Headers, Config}.\n", + ) + .edge_after( + "function static_handler_SUITE::etag/1", + "calls", + "function req_SUITE::do_get/3", + ) + .no_edge_after( + "function static_handler_SUITE::etag/1", + "calls", + "function compress_SUITE::do_get/3", + ) +} + +/// A bare call with no `-import` reaches a function a header gains, and no +/// other module's. +pub fn erlang_bare_call_reaches_a_function_its_header_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Erlang]) + .file("include/helpers.hrl", "-define(TIMEOUT, 100).\n") + .file( + "src/other.erl", + "-module(other).\n-export([helper/1]).\n\nhelper(Config) -> Config.\n", + ) + .file( + "src/plain.erl", + "-module(plain).\n-export([run/1]).\n-include(\"../include/helpers.hrl\").\n\nrun(Config) -> helper(Config).\n", + ) + .write( + "include/helpers.hrl", + "-define(TIMEOUT, 100).\n\nhelper(Config) -> Config.\n", + ) + .edge_after("function plain::run/1", "calls", "function helper/1") + .no_edge_after("function plain::run/1", "calls", "function other::helper/1") +} + +/// 501 parked bare calls of `do_get/3` that no module imports. +static PARKED_DO_GET_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("-module(noise).\n-export([]).\n\n"); + for index in 0..501 { + text.push_str(&format!("n{index}(C) -> do_get(\"/{index}\", [], C).\n")); + } + text +}); + +/// The imported module gains the function past the tail ceiling: 501 parked +/// calls share the tail `do_get`. +pub fn erlang_imported_call_reaches_a_function_its_module_gains_past_the_tail_ceiling() -> Case { + Case::new(Change::AddImportTarget, &[Language::Erlang]) + .file( + "test/req_SUITE.erl", + "-module(req_SUITE).\n-export([other/0]).\n\nother() -> ok.\n", + ) + .file( + "test/static_handler_SUITE.erl", + "-module(static_handler_SUITE).\n-export([etag/1]).\n-import(req_SUITE, [do_get/3]).\n\netag(Config) -> do_get(\"/etag\", [], Config).\n", + ) + .file("test/noise.erl", PARKED_DO_GET_CALLS.as_str()) + .write( + "test/req_SUITE.erl", + "-module(req_SUITE).\n-export([other/0, do_get/3]).\n\nother() -> ok.\n\ndo_get(Path, Headers, Config) -> {Path, Headers, Config}.\n", + ) + .edge_after( + "function static_handler_SUITE::etag/1", + "calls", + "function req_SUITE::do_get/3", + ) +} + +pub fn erlang_rename_a_remotely_called_function() -> Case { + Case::new(Change::RenameSymbol, &[Language::Erlang]) + .file( + "src/util.erl", + "-module(util).\n-export([double/1]).\n\ndouble(X) -> X * 2.\n", + ) + .file( + "src/main.erl", + "-module(main).\n-export([run/1]).\n\nrun(X) -> util:double(X).\n", + ) + .write( + "src/util.erl", + "-module(util).\n-export([twice/1]).\n\ntwice(X) -> X * 2.\n", + ) + .edge_before("function main::run/1", "calls", "function util::double/1") +} + +pub fn mybatis_rename_an_included_fragment() -> Case { + Case::new(Change::RenameSymbol, &[Language::Xml]) + .file( + "mapper/CommonMapper.xml", + "\n\n id, name, email\n\n", + ) + .file( + "mapper/UserMapper.xml", + "\n\n \n\n", + ) + .write( + "mapper/CommonMapper.xml", + "\n\n id, name, email\n\n", + ) + .edge_before( + "method app.UserMapper::findAll", + "references", + "method app.CommonMapper::userColumns", + ) +} + +// ----- a module an unchanged file imports is deleted ----------------------- + +pub fn typescript_delete_an_imported_module() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::TypeScript]) + .file( + "src/util.ts", + "export function helper(): number {\n return 1;\n}\n", + ) + .file( + "src/main.ts", + "import { helper } from './util';\n\nexport function main(): number {\n return helper();\n}\n", + ) + .remove("src/util.ts") + .edge_before("function main", "calls", "function helper") +} + +pub fn rust_delete_a_used_module() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Rust]) + .file( + "src/lib.rs", + "mod util;\n\nuse crate::util::helper;\n\npub fn run() -> u32 {\n helper(1)\n}\n", + ) + .file( + "src/util.rs", + "pub fn helper(value: u32) -> u32 {\n value + 1\n}\n", + ) + .remove("src/util.rs") + .edge_before("function run", "calls", "function helper") + .edge_before("file:src/lib.rs", "imports", "function helper") +} + +/// Upstream #2214 (W4-RS-01): another file's item is in scope through a glob +/// over its module, so a bare call links the item that module gains. +pub fn rust_glob_module_gains_an_item() -> Case { + Case::new(Change::AddCompetitor, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod util;\nmod other;\nmod run;\n") + .file("src/util.rs", "pub fn other_helper() {}\n") + .file("src/other.rs", "pub fn helper() {}\n") + .file( + "src/run.rs", + "use crate::util::*;\n\npub fn run() {\n helper();\n}\n", + ) + .write("src/util.rs", "pub fn helper() {}\n") + .edge_after("function run", "calls", "function helper @src/util.rs") + .no_edge_after("function run", "calls", "function helper @src/other.rs") +} + +/// Upstream #2214 (W4-RS-01): a namesake that appears in a module the file +/// neither imports nor globs is out of scope. +pub fn rust_out_of_scope_namesake_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod util;\nmod other;\nmod run;\n") + .file("src/util.rs", "pub fn helper() {}\n") + .file("src/other.rs", "pub fn unrelated() {}\n") + .file( + "src/run.rs", + "use crate::util::*;\n\npub fn run() {\n helper();\n}\n", + ) + .write("src/other.rs", "pub fn helper() {}\n") + .edge_before("function run", "calls", "function helper @src/util.rs") + .edge_after("function run", "calls", "function helper @src/util.rs") + .no_edge_after("function run", "calls", "function helper @src/other.rs") +} + +/// Upstream #2124 (W4-RS-01): a bare prelude name is the prelude's, so a +/// project `drop` that appears without a `use` of it is not what it calls. +pub fn rust_prelude_name_ignores_a_project_namesake_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod util;\nmod release;\n") + .file("src/util.rs", "pub fn keep() {}\n") + .file( + "src/release.rs", + "pub fn release(v: Vec) {\n drop(v);\n}\n", + ) + .write( + "src/util.rs", + "pub fn keep() {}\n\npub fn drop(_value: T) {}\n", + ) + .no_edge_after("function release", "calls", "function drop") +} + +/// Upstream #2124 (W4-RS-01): an enum variant is in scope through a glob over +/// its enum, so renaming the enum in another file takes it out of scope. +pub fn rust_glob_variant_leaves_when_its_enum_is_renamed() -> Case { + Case::new(Change::RenameSymbol, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod lowargs;\nmod modes;\n") + .file( + "src/lowargs.rs", + "pub enum Mode {\n Auto,\n Some(u8),\n}\n", + ) + .file( + "src/modes.rs", + "use crate::lowargs::Mode::*;\n\npub fn pick() {\n let _ = Some(1);\n}\n", + ) + .write( + "src/lowargs.rs", + "pub enum Kind {\n Auto,\n Some(u8),\n}\n", + ) + .edge_before("function pick", "calls", "enum_member Mode::Some") + .no_edge_after("function pick", "calls", "enum_member Kind::Some") +} + +/// Upstream #2354 (W4-RS-02): a variant path references the enum only once +/// the enum, in another file, declares that variant. +pub fn rust_variant_path_reaches_the_enum_once_it_declares_the_variant() -> Case { + Case::new(Change::AddCompetitor, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod mode;\nmod pick;\n") + .file("src/mode.rs", "pub enum Mode {\n A,\n B,\n}\n") + .file( + "src/pick.rs", + "use crate::mode::Mode;\n\npub fn pick() {\n let _ = Mode::C;\n}\n", + ) + .write( + "src/mode.rs", + "pub enum Mode {\n A,\n B,\n C,\n}\n", + ) + .edge_after("function pick", "references", "enum Mode") +} + +/// Upstream #2354 (W4-RS-02): a variant path leaves the enum when the enum, +/// in another file, renames that variant. +pub fn rust_variant_path_leaves_the_enum_when_the_variant_is_renamed() -> Case { + Case::new(Change::RenameSymbol, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod mode;\nmod pick;\n") + .file( + "src/mode.rs", + "pub enum Mode {\n A,\n B,\n C,\n}\n", + ) + .file( + "src/pick.rs", + "use crate::mode::Mode;\n\npub fn pick() {\n let _ = Mode::C;\n}\n", + ) + .write( + "src/mode.rs", + "pub enum Mode {\n A,\n B,\n D,\n}\n", + ) + .edge_before("function pick", "references", "enum Mode") + .no_edge_after("function pick", "references", "enum Mode") +} + +/// Upstream #2177 (W4-RS-03): a CamelCase path receiver the project does +/// not declare is an outside type; once another file declares it, the call +/// is the project's. +pub fn rust_outside_type_receiver_reaches_a_type_the_project_gains() -> Case { + Case::new(Change::AddCompetitor, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod config;\nmod run;\nmod builder;\n") + .file( + "src/config.rs", + "pub struct BuilderConfig;\n\nimpl BuilderConfig {\n pub fn new() -> Self {\n BuilderConfig\n }\n}\n", + ) + .file("src/builder.rs", "pub fn unrelated() {}\n") + .file( + "src/run.rs", + "use crate::builder::Builder;\n\npub fn run() {\n let _b = Builder::new();\n}\n", + ) + .write( + "src/builder.rs", + "pub struct Builder;\n\nimpl Builder {\n pub fn new() -> Self {\n Builder\n }\n}\n", + ) + .no_edge_after("function run", "calls", "method BuilderConfig::new") + .edge_after("function run", "calls", "method Builder::new") +} + +/// Upstream #2177 (W4-RS-03): once the project type a path receiver names is +/// renamed away, the receiver is an outside type, and the call reaches no +/// project method of another type by word overlap. +pub fn rust_outside_type_receiver_leaves_when_the_project_type_is_renamed() -> Case { + Case::new(Change::RenameSymbol, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod config;\nmod run;\nmod builder;\n") + .file( + "src/config.rs", + "pub struct BuilderConfig;\n\nimpl BuilderConfig {\n pub fn new() -> Self {\n BuilderConfig\n }\n}\n", + ) + .file( + "src/builder.rs", + "pub struct Builder;\n\nimpl Builder {\n pub fn new() -> Self {\n Builder\n }\n}\n", + ) + .file( + "src/run.rs", + "use crate::builder::Builder;\n\npub fn run() {\n let _b = Builder::new();\n}\n", + ) + .write( + "src/builder.rs", + "pub struct Assembler;\n\nimpl Assembler {\n pub fn new() -> Self {\n Assembler\n }\n}\n", + ) + .edge_before("function run", "calls", "method Builder::new") + .no_edge_after("function run", "calls", "method BuilderConfig::new") + .no_edge_after("function run", "calls", "method Assembler::new") +} + +/// Upstream #2177 (W4-RS-03): a path receiver is a declared type once a +/// type alias of its name appears in another file, which changes no node of +/// the method's name. +pub fn rust_alias_receiver_reaches_a_method_once_the_alias_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod widget;\nmod alias;\nmod run;\n") + .file( + "src/widget.rs", + "pub struct Widget;\n\nimpl Widget {\n pub fn fresh() -> Self {\n Widget\n }\n}\n", + ) + .file("src/alias.rs", "pub fn unrelated() {}\n") + .file( + "src/run.rs", + "use crate::alias::WidgetRef;\n\npub fn run() {\n let _w = WidgetRef::fresh();\n}\n", + ) + .write( + "src/alias.rs", + "pub type WidgetRef = crate::widget::Widget;\n\npub fn unrelated() {}\n", + ) + .edge_after("function run", "calls", "method Widget::fresh") +} + +/// Upstream #2177 (W4-RS-03): a path receiver is an outside type once the +/// type alias of its name leaves another file, which changes no node of the +/// method's name. +pub fn rust_alias_receiver_leaves_the_method_when_the_alias_goes() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod widget;\nmod alias;\nmod run;\n") + .file( + "src/widget.rs", + "pub struct Widget;\n\nimpl Widget {\n pub fn fresh() -> Self {\n Widget\n }\n}\n", + ) + .file( + "src/alias.rs", + "pub type WidgetRef = crate::widget::Widget;\n\npub fn unrelated() {}\n", + ) + .file( + "src/run.rs", + "use crate::alias::WidgetRef;\n\npub fn run() {\n let _w = WidgetRef::fresh();\n}\n", + ) + .write("src/alias.rs", "pub fn unrelated() {}\n") + .edge_before("function run", "calls", "method Widget::fresh") + .no_edge_after("function run", "calls", "method Widget::fresh") +} + +/// Upstream #2214 (W4-RS-01): a crate the manifest comes to depend on is +/// outside the project, so a name the file imports from it is no project +/// item. `Cargo.toml` is not a source file; W5-06 makes its change +/// re-resolve the project's Rust files. +pub fn rust_cargo_dependency_appears() -> Case { + Case::new(Change::ControlFile, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .file("src/lib.rs", "mod stream;\nmod source;\n") + .file("src/stream.rs", "pub trait Stream {}\n") + .file( + "src/source.rs", + "use futures::Stream;\n\npub fn drain(s: impl Stream) {\n let _ = s;\n}\n", + ) + .write( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nfutures = \"0.3\"\n", + ) + .edge_before("function drain", "references", "trait Stream") + .no_edge_after("function drain", "references", "trait Stream") +} + +/// W5-06: the dependency leaving the manifest, the name is the project's +/// item again. +pub fn rust_cargo_dependency_leaves() -> Case { + Case::new(Change::ControlFile, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nfutures = \"0.3\"\n", + ) + .file("src/lib.rs", "mod stream;\nmod source;\n") + .file("src/stream.rs", "pub trait Stream {}\n") + .file( + "src/source.rs", + "use futures::Stream;\n\npub fn drain(s: impl Stream) {\n let _ = s;\n}\n", + ) + .write( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .edge_after("function drain", "references", "trait Stream") +} + +/// W5-06: a workspace member's manifest appearing makes its crate the +/// project's, which the other member depended on as an outside one. +pub fn rust_workspace_member_becomes_a_project_crate() -> Case { + Case::new(Change::ControlFile, &[Language::Rust]) + .file( + "Cargo.toml", + "[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\n", + ) + .file( + "crates/a/Cargo.toml", + "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\n\n[dependencies]\nbeta = \"0.1\"\n", + ) + .file( + "crates/a/src/lib.rs", + "use beta::Thing;\n\npub fn make() -> Thing {\n Thing\n}\n", + ) + .file("crates/b/src/lib.rs", "pub struct Thing;\n") + .write( + "crates/b/Cargo.toml", + "[package]\nname = \"beta\"\nversion = \"0.1.0\"\n", + ) + .edge_after("function make", "references", "struct Thing") +} + +const GAMMA_MAIN: &str = + "mod thing;\nuse gamma::Thing;\n\npub fn make() -> Thing {\n Thing\n}\n"; + +/// W5-06: the first `.rs` file under an existing nested manifest makes that +/// manifest's crate the project's: an unchanged file's `use` of it resolves. +/// The name lane retries `Thing`, the added struct's name, as well, so this +/// case converges without the control lane; the deletion below needs it. +pub fn rust_first_source_under_a_nested_manifest_makes_its_crate_the_projects() -> Case { + Case::new(Change::AddImportTarget, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ngamma = \"0.1\"\n", + ) + .file( + "crates/c/Cargo.toml", + "[package]\nname = \"gamma\"\nversion = \"0.1.0\"\n", + ) + .file( + "src/main.rs", + "use gamma::Thing;\n\npub fn make() -> Thing {\n Thing\n}\n", + ) + .write("crates/c/src/lib.rs", "pub struct Thing;\n") + .edge_after("function make", "references", "struct Thing") +} + +/// W5-06: the last `.rs` file under a nested manifest deleted: its crate is +/// an outside dependency again, so the `use` no longer binds the project's +/// namesake. +pub fn rust_last_source_under_a_nested_manifest_leaves_its_crate_outside() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Rust]) + .file( + "Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ngamma = \"0.1\"\n", + ) + .file( + "crates/c/Cargo.toml", + "[package]\nname = \"gamma\"\nversion = \"0.1.0\"\n", + ) + .file("crates/c/src/lib.rs", "pub struct Other;\n") + .file("src/thing.rs", "pub struct Thing;\n") + .file("src/main.rs", GAMMA_MAIN) + .edge_before("function make", "references", "struct Thing") + .remove("crates/c/src/lib.rs") + .no_edge_after("function make", "references", "struct Thing") +} + +pub fn dart_delete_an_imported_library() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Dart]) + .file( + "lib/math.dart", + "int square(int value) {\n return value * value;\n}\n", + ) + .file( + "lib/main.dart", + "import 'math.dart';\n\nint area(int side) {\n return square(side);\n}\n", + ) + .remove("lib/math.dart") + .edge_before("file:lib/main.dart", "imports", "file:lib/math.dart") + .edge_before("function area", "calls", "function square") +} + +pub fn astro_delete_an_imported_module() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Astro]) + .file( + "src/lib/date.ts", + "export function formatDate(date: Date): string {\n return date.toISOString();\n}\n", + ) + .file( + "src/pages/index.astro", + "---\nimport { formatDate } from '../lib/date';\nconst today = formatDate(new Date());\n---\n

      {today}

      \n", + ) + .remove("src/lib/date.ts") + // The frontmatter runs per render: its top-level constant's + // initializer call is the component's (#2268). + .edge_before( + "component src/pages/index.astro::index", + "calls", + "function formatDate", + ) +} + +pub fn lua_delete_a_required_module() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Lua]) + .file( + "util.lua", + "local M = {}\n\nfunction M.clamp(value, low, high)\n return math.max(low, math.min(high, value))\nend\n\nreturn M\n", + ) + .file( + "main.lua", + "local util = require('util')\n\nlocal function run()\n return util.clamp(5, 0, 3)\nend\n\nreturn run\n", + ) + .remove("util.lua") + .edge_before("file:main.lua", "imports", "file:util.lua") +} + +pub fn nix_delete_an_imported_file() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Nix]) + .file("lib.nix", "{\n double = x: x * 2;\n}\n") + .file( + "default.nix", + "let\n lib = import ./lib.nix;\nin\n{\n value = lib.double 21;\n}\n", + ) + .remove("lib.nix") + .edge_before("file:default.nix", "imports", "file:lib.nix") +} + +pub fn python_move_an_imported_module_away() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Python]) + .file("lib.py", "def helper():\n return 1\n") + .file( + "main.py", + "from lib import helper\n\n\ndef main():\n return helper()\n", + ) + .rename("lib.py", "tools.py") + .edge_before("function main", "calls", "function helper @lib.py") +} + +// ----- an extends/implements clause changes -------------------------------- + +pub fn typescript_change_a_superclass() -> Case { + Case::new(Change::EditHeritage, &[Language::TypeScript]) + .file( + "src/base.ts", + "export class Animal {\n speak(): string {\n return '...';\n }\n}\n\nexport class Robot {\n speak(): string {\n return 'beep';\n }\n}\n", + ) + .file( + "src/dog.ts", + "import { Animal, Robot } from './base';\n\nexport class Dog extends Animal {}\n", + ) + .write( + "src/dog.ts", + "import { Animal, Robot } from './base';\n\nexport class Dog extends Robot {}\n", + ) + .edge_before("class Dog", "extends", "class Animal") + .edge_after("class Dog", "extends", "class Robot") +} + +/// Upstream #2195: an import is the project's when a project file declares +/// its package. A file appearing in that package makes an unchanged file's +/// `Tools.make()` under `import com.lib.Tools;` a project name again, as on a +/// fresh index. +pub fn java_import_package_appears_in_the_project() -> Case { + Case::new(Change::AddImportTarget, &[Language::Java]) + .file( + "src/app/Tools.java", + "package app;\n\npublic class Tools {\n public static void make() { }\n}\n", + ) + .file( + "src/app/Use.java", + "package app;\n\nimport com.lib.Tools;\n\npublic class Use {\n void go() {\n Tools.make();\n }\n}\n", + ) + .write( + "src/com/lib/Other.java", + "package com.lib;\n\npublic class Other { }\n", + ) + .edge_after("method app::Use::go", "calls", "method app::Tools::make") +} + +/// Upstream #2195: the only file of a package moving to another package +/// makes an import from the first an outside one, which owns its name in an +/// unchanged file, as on a fresh index. +pub fn java_import_package_is_renamed_away() -> Case { + Case::new(Change::RenameSymbol, &[Language::Java]) + .file( + "src/app/Tools.java", + "package app;\n\npublic class Tools {\n public static void make() { }\n}\n", + ) + .file( + "src/app/Use.java", + "package app;\n\nimport com.lib.Tools;\n\npublic class Use {\n void go() {\n Tools.make();\n }\n}\n", + ) + .file( + "src/com/lib/Other.java", + "package com.lib;\n\npublic class Other { }\n", + ) + .write( + "src/com/lib/Other.java", + "package com.misc;\n\npublic class Other { }\n", + ) + .edge_before("method app::Use::go", "calls", "method app::Tools::make") + .no_edge_after("method app::Use::go", "calls", "method app::Tools::make") +} + +/// Upstream #2195: the last file of a package leaving makes an import from it +/// an outside one, which owns its name in an unchanged file, as on a fresh +/// index. +pub fn java_import_package_leaves_the_project() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Java]) + .file( + "src/app/Tools.java", + "package app;\n\npublic class Tools {\n public static void make() { }\n}\n", + ) + .file( + "src/app/Use.java", + "package app;\n\nimport com.lib.Tools;\n\npublic class Use {\n void go() {\n Tools.make();\n }\n}\n", + ) + .file( + "src/com/lib/Other.java", + "package com.lib;\n\npublic class Other { }\n", + ) + .remove("src/com/lib/Other.java") + .edge_before("method app::Use::go", "calls", "method app::Tools::make") + .no_edge_after("method app::Use::go", "calls", "method app::Tools::make") +} + +pub fn java_change_a_superclass_and_interface() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/zoo/Animal.java", + "package zoo;\n\npublic class Animal {\n public String sound() {\n return \"...\";\n }\n}\n", + ) + .file( + "src/zoo/Creature.java", + "package zoo;\n\npublic class Creature {\n public String sound() {\n return \"?\";\n }\n}\n", + ) + .file( + "src/zoo/Pet.java", + "package zoo;\n\npublic interface Pet {\n String name();\n}\n", + ) + .file( + "src/zoo/Named.java", + "package zoo;\n\npublic interface Named {\n String name();\n}\n", + ) + .file( + "src/zoo/Dog.java", + "package zoo;\n\npublic class Dog extends Animal implements Pet {\n public String name() {\n return \"rex\";\n }\n}\n", + ) + .write( + "src/zoo/Dog.java", + "package zoo;\n\npublic class Dog extends Creature implements Named {\n public String name() {\n return \"rex\";\n }\n}\n", + ) + .edge_before("class zoo::Dog", "extends", "class zoo::Animal") + .edge_before("class zoo::Dog", "implements", "interface zoo::Pet") + .edge_after("class zoo::Dog", "extends", "class zoo::Creature") + .edge_after("class zoo::Dog", "implements", "interface zoo::Named") +} + +/// G1: a C# `base_list` names the base class and the interfaces in one list. +pub fn csharp_base_list_gains_an_interface() -> Case { + Case::new(Change::EditHeritage, &[Language::CSharp]) + .file( + "src/Shapes.cs", + "namespace Shapes\n{\n public class Shape { }\n\n public interface IDrawable { }\n}\n", + ) + .file( + "src/Square.cs", + "namespace Shapes\n{\n public class Square : Shape { }\n}\n", + ) + .write( + "src/Square.cs", + "namespace Shapes\n{\n public class Square : Shape, IDrawable { }\n}\n", + ) + .edge_throughout("class Shapes::Square", "extends", "class Shapes::Shape") + .edge_after("class Shapes::Square", "implements", "interface Shapes::IDrawable") +} + +/// G5: an Objective-C `@interface` names its superclass. +pub fn objc_change_a_superclass() -> Case { + Case::new(Change::EditHeritage, &[Language::ObjC]) + .file( + "Sources/Base.h", + "@interface Base\n- (void)ping;\n@end\n\n@interface Other\n- (void)ping;\n@end\n", + ) + .file( + "Sources/Sub.m", + "#import \"Base.h\"\n\n@interface Sub : Base\n@end\n", + ) + .write( + "Sources/Sub.m", + "#import \"Base.h\"\n\n@interface Sub : Other\n@end\n", + ) + .edge_before("class Sub", "extends", "class Base") + .edge_after("class Sub", "extends", "class Other") +} + +/// G6: a Python class's bases are its supertypes. +pub fn python_change_a_base_class() -> Case { + Case::new(Change::EditHeritage, &[Language::Python]) + .file( + "zoo/base.py", + "class Animal:\n def speak(self):\n return '...'\n\n\nclass Robot:\n def speak(self):\n return 'beep'\n", + ) + .file( + "zoo/dog.py", + "from zoo.base import Animal, Robot\n\n\nclass Dog(Animal):\n pass\n", + ) + .write( + "zoo/dog.py", + "from zoo.base import Animal, Robot\n\n\nclass Dog(Robot):\n pass\n", + ) + .edge_before("class Dog", "extends", "class Animal") + .edge_after("class Dog", "extends", "class Robot") +} + +/// G7: a PHP class extends its `base_clause` parent. +pub fn php_change_a_parent_class() -> Case { + Case::new(Change::EditHeritage, &[Language::Php]) + .file( + "src/Shapes.php", + " Case { + Case::new(Change::EditHeritage, &[Language::Go]) + .file("go.mod", "module example.com/db\n\ngo 1.22\n") + .file( + "parts.go", + "package db\n\ntype Head struct{}\n\ntype Tail struct{}\n", + ) + .file("db.go", "package db\n\ntype DB struct {\n\t*Head\n}\n") + .write("db.go", "package db\n\ntype DB struct {\n\t*Tail\n}\n") + .edge_before("struct DB", "extends", "struct Head") + .edge_after("struct DB", "extends", "struct Tail") +} + +/// G12: a Dart class implements each `with` mixin; its base stays `extends`. +pub fn dart_change_a_mixin() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file( + "lib/parts.dart", + "class Bird {}\n\nmixin Walker {}\n\nmixin Swimmer {}\n", + ) + .file( + "lib/duck.dart", + "import 'parts.dart';\n\nclass Duck extends Bird with Walker {}\n", + ) + .write( + "lib/duck.dart", + "import 'parts.dart';\n\nclass Duck extends Bird with Swimmer {}\n", + ) + .edge_before("class Duck", "implements", "class Walker") + .edge_after("class Duck", "implements", "class Swimmer") + .edge_throughout("class Duck", "extends", "class Bird") +} + +/// Upstream #2160: a bare Scala call reaches the members of the supertypes +/// the heads of the types around it name, read from source by name, so +/// another file changing a base's head moves an unchanged file's call, as +/// on a fresh index. +pub fn scala_bare_call_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Scala]) + .file( + "src/app/Base.scala", + "package app\n\ntrait Base extends HelperA\n", + ) + .file( + "src/app/HelperA.scala", + "package app\n\ntrait HelperA {\n def helper(): Int = 1\n}\n", + ) + .file( + "src/app/HelperB.scala", + "package app\n\ntrait HelperB {\n def helper(): Int = 2\n}\n", + ) + .file( + "src/app/Use.scala", + "package app\n\nobject Use extends Base {\n def local(): Int = helper()\n}\n", + ) + .write( + "src/app/Base.scala", + "package app\n\ntrait Base extends HelperB\n", + ) + .edge_before("method Use::local", "calls", "method HelperA::helper") + .edge_after("method Use::local", "calls", "method HelperB::helper") +} + +/// Upstream #2193: an imported package object's supertypes are in scope, and +/// the graph holds no node for `package object api extends Api`, so a change +/// of what it extends moves an unchanged importer's bare call, as on a fresh +/// index. +pub fn scala_imported_package_object_changes_its_supertype() -> Case { + Case::new(Change::EditHeritage, &[Language::Scala]) + .file( + "src/app/Api.scala", + "package app\n\ntrait Api {\n def multipart(s: String): String = s\n}\n", + ) + .file( + "src/app/Api2.scala", + "package app\n\ntrait Api2 {\n def multipart(s: String): String = s\n}\n", + ) + .file( + "src/app/api/package.scala", + "package app\n\npackage object api extends Api\n", + ) + .file( + "src/app/client/Client.scala", + "package app.client\n\nimport app.api._\n\nclass Client {\n def send(): String = multipart(\"x\")\n}\n", + ) + .write( + "src/app/api/package.scala", + "package app\n\npackage object api extends Api2\n", + ) + .edge_before("method Client::send", "calls", "method Api::multipart") + .edge_after("method Client::send", "calls", "method Api2::multipart") +} + +/// Upstream #2193 through a package object: an imported package object's +/// supertypes are walked by their heads, so a head change of what the +/// package object extends, in another file, moves an unchanged importer's +/// bare call, as on a fresh index (the closure runs through the package +/// object's entry). +pub fn scala_package_object_supertype_head_changes_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Scala]) + .file( + "src/app/BaseA.scala", + "package app\n\ntrait BaseA {\n def multipart(s: String): String = s\n}\n", + ) + .file( + "src/app/BaseB.scala", + "package app\n\ntrait BaseB {\n def multipart(s: String): String = s\n}\n", + ) + .file("src/app/Api.scala", "package app\n\ntrait Api extends BaseA\n") + .file( + "src/app/api/package.scala", + "package app\n\npackage object api extends Api\n", + ) + .file( + "src/app/client/Client.scala", + "package app.client\n\nimport app.api._\n\nclass Client {\n def send(): String = multipart(\"x\")\n}\n", + ) + .write("src/app/Api.scala", "package app\n\ntrait Api extends BaseB\n") + .edge_before("method Client::send", "calls", "method BaseA::multipart") + .edge_after("method Client::send", "calls", "method BaseB::multipart") +} + +// ----- C# namespaces, usings and nested types (W4-CS-01) -------------------- + +const UNIT_CSPROJ: &str = "\n"; +// `public`: until W4-CS-03 (#2171) reads a C# type without a modifier as +// `internal`, it is `private` to other files. +const UNIT_SOME: &str = "namespace Unit.Support;\n\npublic static class Some\n{\n public static int InformationEvent() => 1;\n}\n"; +const UNIT_LOG_TESTS: &str = + "namespace Unit.Context;\n\nclass LogTests\n{\n Some Make() => null;\n}\n"; + +/// Upstream #2216/#2220: a project's `global using` brings a namespace into +/// every file of the project, so a file that comes to declare one makes a +/// bare type name in an unchanged file of that project visible, as on a fresh +/// index. +pub fn csharp_global_using_appears_for_its_project() -> Case { + Case::new(Change::AddImportTarget, &[Language::CSharp]) + .file("test/Unit/Unit.csproj", UNIT_CSPROJ) + .file("test/Unit/Support/Some.cs", UNIT_SOME) + .file("test/Unit/LogTests.cs", UNIT_LOG_TESTS) + .write("test/Unit/GlobalUsings.cs", "global using Unit.Support;\n") + .edge_after( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) +} + +/// Upstream #2216/#2220: the file holding a project's `global using` leaves, +/// and the bare type name it made visible in an unchanged file is no longer +/// in reach. +pub fn csharp_global_using_leaves_its_project() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::CSharp]) + .file("test/Unit/Unit.csproj", UNIT_CSPROJ) + .file("test/Unit/Support/Some.cs", UNIT_SOME) + .file("test/Unit/LogTests.cs", UNIT_LOG_TESTS) + .file("test/Unit/GlobalUsings.cs", "global using Unit.Support;\n") + .remove("test/Unit/GlobalUsings.cs") + .edge_before( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) + .no_edge_after( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) +} + +/// Upstream #2216: a project file's `` imports a namespace +/// into the project's files. A `.csproj` is no source file, so a sync that +/// sees only it re-resolves nothing. +pub fn csharp_csproj_using_appears() -> Case { + Case::new(Change::ControlFile, &[Language::CSharp]) + .file("test/Unit/Unit.csproj", UNIT_CSPROJ) + .file("test/Unit/Support/Some.cs", UNIT_SOME) + .file("test/Unit/LogTests.cs", UNIT_LOG_TESTS) + .write( + "test/Unit/Unit.csproj", + "\n \n \n \n\n", + ) + .edge_after( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) +} + +/// Upstream #2220: a nested type is reached by bare name from a type +/// deriving from its owner through any partial part, so a partial part in +/// another file gaining the base brings the unchanged part's reference in. +pub fn csharp_partial_part_gains_a_base() -> Case { + Case::new(Change::EditHeritage, &[Language::CSharp]) + .file( + "src/Lib/Reader.cs", + "namespace Lib\n{\n public abstract class Reader\n {\n internal enum State { Start, Done }\n }\n\n public partial class TextReader\n {\n }\n}\n", + ) + .file( + "src/Lib/TextReader.Async.cs", + "namespace Lib\n{\n public partial class TextReader\n {\n object Peek() => State.Start;\n }\n}\n", + ) + .write( + "src/Lib/Reader.cs", + "namespace Lib\n{\n public abstract class Reader\n {\n internal enum State { Start, Done }\n }\n\n public partial class TextReader : Reader\n {\n }\n}\n", + ) + .edge_after( + "method Lib::TextReader::Peek", + "references", + "enum Lib::Reader::State", + ) +} + +/// Upstream #2220 through a middle type: the type between an unchanged +/// subclass and the owner of a nested type drops its base, so the subclass's +/// reference to the nested type leaves, though neither its file nor the +/// owner's changed. +pub fn csharp_middle_type_drops_its_base() -> Case { + Case::new(Change::EditHeritage, &[Language::CSharp]) + .file( + "src/Lib/Base.cs", + "namespace Lib\n{\n public class Reader\n {\n internal enum State { Start, Done }\n }\n}\n", + ) + .file( + "src/Lib/Middle.cs", + "namespace Lib\n{\n public class Middle : Reader\n {\n }\n}\n", + ) + .file( + "src/Lib/Leaf.cs", + "namespace Lib\n{\n public class Leaf : Middle\n {\n object Peek() => State.Start;\n }\n}\n", + ) + .write( + "src/Lib/Middle.cs", + "namespace Lib\n{\n public class Middle\n {\n }\n}\n", + ) + .edge_before( + "method Lib::Leaf::Peek", + "references", + "enum Lib::Reader::State", + ) + .no_edge_after( + "method Lib::Leaf::Peek", + "references", + "enum Lib::Reader::State", + ) +} + +/// Upstream #2220: a type declared in a class is nested, one declared in a +/// namespace of the same name is not. The class becomes the namespace; the +/// declaration keeps its node and qualified name, and an unchanged file in a +/// namespace under it now reaches it, as on a fresh index. +pub fn csharp_nested_owner_becomes_a_namespace() -> Case { + Case::new(Change::EditHeritage, &[Language::CSharp]) + .file( + "src/Owner.cs", + "class Owner\n{\n public class Nested { }\n}\n", + ) + .file( + "src/Use.cs", + "namespace Owner.Users\n{\n class Use\n {\n Nested Make() => null;\n }\n}\n", + ) + .write( + "src/Owner.cs", + "namespace Owner\n{\n public class Nested { }\n}\n", + ) + .edge_after( + "method Owner.Users::Use::Make", + "references", + "class Owner::Nested", + ) +} + +/// Upstream #2216: a type moving into a namespace an unchanged file imports +/// comes into its reach (its qualified name changes, which the name lane +/// already follows). +pub fn csharp_type_moves_into_an_imported_namespace() -> Case { + Case::new(Change::RenameSymbol, &[Language::CSharp]) + .file( + "src/Models/User.cs", + "namespace App.Old\n{\n public class User { }\n}\n", + ) + .file( + "src/Svc.cs", + "using App.Models;\n\nnamespace App.Services\n{\n class Svc\n {\n User Find() => null;\n }\n}\n", + ) + .write( + "src/Models/User.cs", + "namespace App.Models\n{\n public class User { }\n}\n", + ) + .edge_after( + "method App.Services::Svc::Find", + "references", + "class App.Models::User", + ) +} + +const SPEC_A: &str = + "namespace App.Specs;\n\npublic abstract class SpecA\n{\n protected void Check() { }\n}\n"; +const SPEC_B: &str = + "namespace App.Specs;\n\npublic abstract class SpecB\n{\n protected void Check() { }\n}\n"; +const SPEC_LEAF: &str = "namespace App.Specs;\n\npublic class Leaf : Middle\n{\n public void Runs()\n {\n Check();\n }\n}\n"; + +/// Upstream #2151: a bare C# call reaches a member of the bases the heads +/// around it list, so a middle type in another file changing its base moves +/// an unchanged subclass's call. +pub fn csharp_bare_call_follows_a_base_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::CSharp]) + .file("src/Specs/SpecA.cs", SPEC_A) + .file("src/Specs/SpecB.cs", SPEC_B) + .file( + "src/Specs/Middle.cs", + "namespace App.Specs;\n\npublic abstract class Middle : SpecA\n{\n}\n", + ) + .file("src/Specs/Leaf.cs", SPEC_LEAF) + .write( + "src/Specs/Middle.cs", + "namespace App.Specs;\n\npublic abstract class Middle : SpecB\n{\n}\n", + ) + .edge_before( + "method App.Specs::Leaf::Runs", + "calls", + "method App.Specs::SpecA::Check", + ) + .edge_after( + "method App.Specs::Leaf::Runs", + "calls", + "method App.Specs::SpecB::Check", + ) +} + +/// Upstream #2151 reads a head nine lines deep, two more than #2220's +/// reader: a base list on a head's eighth line moves an unchanged +/// subclass's bare call. +pub fn csharp_bare_call_follows_a_long_head_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::CSharp]) + .file("src/Specs/SpecA.cs", SPEC_A) + .file("src/Specs/SpecB.cs", SPEC_B) + .file( + "src/Specs/Middle.cs", + "namespace App.Specs;\n\npublic abstract class Middle\n\n\n\n\n\n\n : SpecA\n{\n}\n", + ) + .file("src/Specs/Leaf.cs", SPEC_LEAF) + .write( + "src/Specs/Middle.cs", + "namespace App.Specs;\n\npublic abstract class Middle\n\n\n\n\n\n\n : SpecB\n{\n}\n", + ) + .edge_before( + "method App.Specs::Leaf::Runs", + "calls", + "method App.Specs::SpecA::Check", + ) + .edge_after( + "method App.Specs::Leaf::Runs", + "calls", + "method App.Specs::SpecB::Check", + ) +} + +const BUILDER: &str = "namespace App.Execution;\n\npublic static class ExpressionBuilder\n{\n public static object Call(object target) => target;\n}\n"; +const CATALOG: &str = "namespace App.Specs;\n\npublic class CatalogSpec\n{\n public object Runs(object token)\n {\n return Call(token);\n }\n}\n"; + +/// Upstream #2151: a `global using static` in any file brings its type's +/// members into reach of every file, so it appearing reaches an unchanged +/// file's bare call. +pub fn csharp_global_using_static_appears() -> Case { + Case::new(Change::AddImportTarget, &[Language::CSharp]) + .file("src/Execution/ExpressionBuilder.cs", BUILDER) + .file("src/Specs/CatalogSpec.cs", CATALOG) + .write( + "src/GlobalUsings.cs", + "global using static App.Execution.ExpressionBuilder;\n", + ) + .edge_after( + "method App.Specs::CatalogSpec::Runs", + "calls", + "method App.Execution::ExpressionBuilder::Call", + ) +} + +/// Upstream #2151: the file holding a `global using static` leaves, and the +/// unchanged file's bare call is out of reach again. +pub fn csharp_global_using_static_leaves() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::CSharp]) + .file("src/Execution/ExpressionBuilder.cs", BUILDER) + .file("src/Specs/CatalogSpec.cs", CATALOG) + .file( + "src/GlobalUsings.cs", + "global using static App.Execution.ExpressionBuilder;\n", + ) + .remove("src/GlobalUsings.cs") + .edge_before( + "method App.Specs::CatalogSpec::Runs", + "calls", + "method App.Execution::ExpressionBuilder::Call", + ) + .no_edge_after( + "method App.Specs::CatalogSpec::Runs", + "calls", + "method App.Execution::ExpressionBuilder::Call", + ) +} + +/// Upstream #2151: a project file's `` brings its +/// type's members into reach. A `.csproj` is no source file, so a sync that +/// sees only it re-resolves nothing. +pub fn csharp_csproj_static_using_appears() -> Case { + Case::new(Change::ControlFile, &[Language::CSharp]) + .file("src/App.csproj", UNIT_CSPROJ) + .file("src/Execution/ExpressionBuilder.cs", BUILDER) + .file("src/Specs/CatalogSpec.cs", CATALOG) + .write( + "src/App.csproj", + "\n \n \n \n\n", + ) + .edge_after( + "method App.Specs::CatalogSpec::Runs", + "calls", + "method App.Execution::ExpressionBuilder::Call", + ) +} + +/// W5-06: a `Directory.Build.props` gaining a `` imports the +/// namespace into the project's files. +pub fn csharp_props_using_appears() -> Case { + Case::new(Change::ControlFile, &[Language::CSharp]) + .file("Directory.Build.props", "\n\n") + .file("test/Unit/Unit.csproj", UNIT_CSPROJ) + .file("test/Unit/Support/Some.cs", UNIT_SOME) + .file("test/Unit/LogTests.cs", UNIT_LOG_TESTS) + .write( + "Directory.Build.props", + "\n \n \n \n\n", + ) + .edge_after( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) +} + +/// W5-06: `` turned on brings the SDK's namespaces in, a +/// project type in one of them with them. +pub fn csharp_implicit_usings_turn_on() -> Case { + Case::new(Change::ControlFile, &[Language::CSharp]) + .file("src/App.csproj", UNIT_CSPROJ) + .file( + "src/Tasks/Helper.cs", + "namespace System.Threading.Tasks;\n\npublic class Helper\n{\n}\n", + ) + .file( + "src/App/Consumer.cs", + "namespace App;\n\npublic class Consumer\n{\n Helper Make() => null;\n}\n", + ) + .write( + "src/App.csproj", + "\n \n enable\n \n\n", + ) + .edge_after( + "method App::Consumer::Make", + "references", + "class System.Threading.Tasks::Helper", + ) +} + +const GLOBALS: &str = "global using Unit.Support;\n"; +const TEST_LOG: &str = + "namespace Unit.Context;\n\nclass LogTests\n{\n Some Make() => null;\n}\n"; + +/// W5-06: a new `.csproj` makes its folder a project of its own, so another +/// project's `global using` no longer reaches its files. +pub fn csharp_new_csproj_hides_another_projects_global_using() -> Case { + Case::new(Change::ControlFile, &[Language::CSharp]) + .file("src/App.csproj", UNIT_CSPROJ) + .file("src/Globals.cs", GLOBALS) + .file("src/Unit/Support/Some.cs", UNIT_SOME) + .file("test/LogTests.cs", TEST_LOG) + .write("test/Tests.csproj", UNIT_CSPROJ) + .edge_before( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) + .no_edge_after( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) +} + +/// W5-06: the folder's `.csproj` deleted, the other project's `global using` +/// reaches its files again. +pub fn csharp_deleted_csproj_lets_another_projects_global_using_in() -> Case { + Case::new(Change::ControlFile, &[Language::CSharp]) + .file("src/App.csproj", UNIT_CSPROJ) + .file("src/Globals.cs", GLOBALS) + .file("src/Unit/Support/Some.cs", UNIT_SOME) + .file("test/LogTests.cs", TEST_LOG) + .file("test/Tests.csproj", UNIT_CSPROJ) + .remove("test/Tests.csproj") + .edge_after( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) +} + +/// W5-06: the project file renamed (here to a `.props`), its `` read +/// under the new name. +pub fn csharp_renamed_project_file_keeps_its_using() -> Case { + Case::new(Change::ControlFile, &[Language::CSharp]) + .file( + "test/Unit/Unit.csproj", + "\n \n \n \n\n", + ) + .file("test/Unit/Support/Some.cs", UNIT_SOME) + .file("test/Unit/LogTests.cs", UNIT_LOG_TESTS) + .rename("test/Unit/Unit.csproj", "test/Unit/Renamed.props") + .edge_throughout( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) +} + +/// W5-06: C# files named with an upper-case `.CS` are read as C# from the +/// index, and their folder's project file is an input like any other's. +pub fn csharp_upper_case_files_read_their_project_file() -> Case { + Case::new(Change::ControlFile, &[Language::CSharp]) + .file("test/Unit/Unit.csproj", UNIT_CSPROJ) + .file("test/Unit/Support/Some.CS", UNIT_SOME) + .file("test/Unit/LogTests.CS", UNIT_LOG_TESTS) + .write( + "test/Unit/Unit.csproj", + "\n \n \n \n\n", + ) + .edge_after( + "method Unit.Context::LogTests::Make", + "references", + "class Unit.Support::Some", + ) +} + +/// Upstream #2151: a bare name reaches only a member in scope, so another +/// class gaining a property of that name is no competitor. +pub fn csharp_bare_name_ignores_an_added_property() -> Case { + Case::new(Change::AddCompetitor, &[Language::CSharp]) + .file( + "src/Specs/SpecBase.cs", + "namespace App.Specs;\n\npublic abstract class SpecBase\n{\n protected TimeSpan Level { get; set; }\n}\n", + ) + .file( + "src/Specs/CatalogSpec.cs", + "namespace App.Specs;\n\npublic class CatalogSpec : SpecBase\n{\n public int Runs()\n {\n return Level.Seconds;\n }\n}\n", + ) + .write( + "tests/Other.cs", + "namespace App.Tests;\n\npublic class Other\n{\n public TimeSpan Level { get; set; }\n}\n", + ) + .edge_throughout( + "method App.Specs::CatalogSpec::Runs", + "references", + "property App.Specs::SpecBase::Level", + ) + .no_edge_after( + "method App.Specs::CatalogSpec::Runs", + "references", + "property App.Tests::Other::Level", + ) +} + +const CS_JSON_WRITER: &str = "namespace App.Json;\n\npublic class JsonWriter\n{\n public void WriteValue(string value) { }\n}\n"; +const CS_OTHER_WRITER: &str = "namespace App.Json;\n\npublic class OtherWriter\n{\n public void WriteValue(string value) { }\n}\n"; +const CS_TRACE_WRITER: &str = "namespace App.Json;\n\npublic class TraceWriter : WriterBase\n{\n public void Trace(string value)\n {\n Writer.WriteValue(value);\n }\n}\n"; + +/// Upstream #2171: a call through a member a class inherits is a call on +/// the type the base declares it with, so the base changing that type in +/// another file moves an unchanged subclass's call. +pub fn csharp_inherited_member_type_changes_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::CSharp]) + .file("src/Json/JsonWriter.cs", CS_JSON_WRITER) + .file("src/Json/OtherWriter.cs", CS_OTHER_WRITER) + .file( + "src/Json/WriterBase.cs", + "namespace App.Json;\n\npublic abstract class WriterBase\n{\n protected JsonWriter Writer { get; set; }\n}\n", + ) + .file("src/Json/TraceWriter.cs", CS_TRACE_WRITER) + .write( + "src/Json/WriterBase.cs", + "namespace App.Json;\n\npublic abstract class WriterBase\n{\n protected OtherWriter Writer { get; set; }\n}\n", + ) + .edge_before( + "method App.Json::TraceWriter::Trace", + "calls", + "method App.Json::JsonWriter::WriteValue", + ) + .edge_after( + "method App.Json::TraceWriter::Trace", + "calls", + "method App.Json::OtherWriter::WriteValue", + ) +} + +/// Upstream #2171: a method body edit in the base changes no member line, +/// so no unchanged file re-resolves and the subclass's typed call stays. +pub fn csharp_inherited_member_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::CSharp]) + .file("src/Json/JsonWriter.cs", CS_JSON_WRITER) + .file( + "src/Json/WriterBase.cs", + "namespace App.Json;\n\npublic abstract class WriterBase\n{\n protected JsonWriter Writer { get; set; }\n\n protected int Depth()\n {\n return 1;\n }\n}\n", + ) + .file("src/Json/TraceWriter.cs", CS_TRACE_WRITER) + .write( + "src/Json/WriterBase.cs", + "namespace App.Json;\n\npublic abstract class WriterBase\n{\n protected JsonWriter Writer { get; set; }\n\n protected int Depth()\n {\n return 2;\n }\n}\n", + ) + .edge_throughout( + "method App.Json::TraceWriter::Trace", + "calls", + "method App.Json::JsonWriter::WriteValue", + ) +} + +/// Upstream #2171: a member typed by a base's type parameter is the type +/// argument a class between it and the caller gives, so that middle class +/// changing its argument in another file moves an unchanged leaf's call. +pub fn csharp_middle_type_argument_changes_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::CSharp]) + .file( + "src/Tests/IntegrationTest.cs", + "namespace App.Tests;\n\npublic abstract class IntegrationTest where TFixture : new()\n{\n protected TFixture Fixture { get; private set; }\n}\n", + ) + .file( + "src/Tests/FirstInit.cs", + "namespace App.Tests;\n\npublic class FirstInit\n{\n public object CreateContext() => null;\n}\n", + ) + .file( + "src/Tests/SecondInit.cs", + "namespace App.Tests;\n\npublic class SecondInit\n{\n public object CreateContext() => null;\n}\n", + ) + .file( + "src/Tests/Middle.cs", + "namespace App.Tests;\n\npublic abstract class Middle : IntegrationTest\n{\n}\n", + ) + .file( + "src/Tests/Leaf.cs", + "namespace App.Tests;\n\npublic class Leaf : Middle\n{\n public object Runs()\n {\n return Fixture.CreateContext();\n }\n}\n", + ) + .write( + "src/Tests/Middle.cs", + "namespace App.Tests;\n\npublic abstract class Middle : IntegrationTest\n{\n}\n", + ) + .edge_before( + "method App.Tests::Leaf::Runs", + "calls", + "method App.Tests::FirstInit::CreateContext", + ) + .edge_after( + "method App.Tests::Leaf::Runs", + "calls", + "method App.Tests::SecondInit::CreateContext", + ) +} + +/// Upstream #2171, Java: a member typed by a type parameter the subclass +/// gives no argument for is the parameter's bound, so the base changing the +/// bound in another file moves an unchanged subclass's call. +pub fn java_type_parameter_bound_changes_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/main/java/app/IntegrationTest.java", + "package app;\n\npublic abstract class IntegrationTest {\n protected T fixture;\n}\n", + ) + .file( + "src/main/java/app/FirstInit.java", + "package app;\n\npublic class FirstInit {\n public Object createContext() { return null; }\n}\n", + ) + .file( + "src/main/java/app/SecondInit.java", + "package app;\n\npublic class SecondInit {\n public Object createContext() { return null; }\n}\n", + ) + .file( + "src/main/java/app/Leaf.java", + "package app;\n\npublic class Leaf extends IntegrationTest {\n public Object runs() {\n return fixture.createContext();\n }\n}\n", + ) + .write( + "src/main/java/app/IntegrationTest.java", + "package app;\n\npublic abstract class IntegrationTest {\n protected T fixture;\n}\n", + ) + .edge_before("method app::Leaf::runs", "calls", "method app::FirstInit::createContext") + .edge_after("method app::Leaf::runs", "calls", "method app::SecondInit::createContext") +} + +/// Upstream #2345: a C# property's accessor body is walked as the property, +/// so its call on a class added later in another file links after the sync, +/// as on a fresh index. +pub fn csharp_accessor_body_call_of_an_added_class() -> Case { + Case::new(Change::AddImportTarget, &[Language::CSharp]) + .file( + "src/Box.cs", + "namespace App\n{\n public class Box\n {\n public int Value\n {\n get { return Helper.Compute(1); }\n }\n public int Arrow => Helper.Compute(2);\n }\n}\n", + ) + .write( + "src/Helper.cs", + "namespace App\n{\n public static class Helper\n {\n public static int Compute(int n) => n;\n }\n}\n", + ) + .edge_after("property App::Box::Value", "calls", "method App::Helper::Compute") + .edge_after("property App::Box::Arrow", "calls", "method App::Helper::Compute") +} + +/// Upstream #2348: a C# field's initializer is walked as the field, so +/// editing which method it calls moves the field's edge. +pub fn csharp_field_initializer_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::CSharp]) + .file( + "src/Helper.cs", + "namespace App\n{\n public static class Helper\n {\n public static int Compute(int n) => n;\n public static int Seed() => 1;\n }\n}\n", + ) + .file( + "src/Box.cs", + "namespace App\n{\n public class Box\n {\n private int _a = 1, _b = Helper.Compute(2);\n public int Seeded { get; } = Helper.Compute(3);\n }\n}\n", + ) + .write( + "src/Box.cs", + "namespace App\n{\n public class Box\n {\n private int _a = 1, _b = Helper.Seed();\n public int Seeded { get; } = Helper.Seed();\n }\n}\n", + ) + .edge_before("field App::Box::_b", "calls", "method App::Helper::Compute") + .edge_before("property App::Box::Seeded", "calls", "method App::Helper::Compute") + .edge_after("field App::Box::_b", "calls", "method App::Helper::Seed") + .edge_after("property App::Box::Seeded", "calls", "method App::Helper::Seed") + .no_edge_after("field App::Box::_b", "calls", "method App::Helper::Compute") +} + +/// Upstream #2348: a C# target-typed `new()` initializing a field or +/// property instantiates the declared type, so a class of that name added +/// later in another file is instantiated after the sync, as on a fresh index. +pub fn csharp_target_typed_new_of_an_added_class() -> Case { + Case::new(Change::AddImportTarget, &[Language::CSharp]) + .file( + "src/Box.cs", + "namespace App\n{\n public class Box\n {\n private readonly Widget _w = new();\n public Widget? Made { get; } = new();\n }\n}\n", + ) + .write( + "src/Widget.cs", + "namespace App\n{\n public class Widget { }\n}\n", + ) + .edge_after("field App::Box::_w", "instantiates", "class App::Widget") + .edge_after("property App::Box::Made", "instantiates", "class App::Widget") +} + +/// Upstream #2348 (issue #2337, its three-file repro): a C# type position +/// never names a constant, so a `private const string Station` added beside +/// the consumer leaves `new Station { … }` on the class it imports, after the +/// sync as on a fresh index. +pub fn csharp_type_position_ignores_an_added_constant() -> Case { + Case::new(Change::AddCompetitor, &[Language::CSharp]) + .file( + "src/Core/Station.cs", + "namespace Demo.Core;\n\npublic class Station\n{\n public string Id { get; set; }\n}\n", + ) + .file( + "tests/App.Tests/Consumer.cs", + "using Demo.Core;\n\nnamespace Demo.Tests\n{\n public class Consumer\n {\n public Station Make() => new Station { Id = \"a\" };\n }\n}\n", + ) + .write( + "tests/App.Tests/ConstHolder.cs", + "namespace Demo.Tests\n{\n public class ConstHolder\n {\n private const string Station = \"S1\";\n\n public string Get() => Station;\n }\n}\n", + ) + .edge_throughout( + "method Demo.Tests::Consumer::Make", + "instantiates", + "class Demo.Core::Station", + ) + .no_edge_after( + "method Demo.Tests::Consumer::Make", + "instantiates", + "constant Demo.Tests::ConstHolder::Station", + ) +} + +/// Upstream #2171, Java: a field the base declares types the subclass's +/// call through it, so the base changing the field's type in another file +/// moves the unchanged subclass's call. +pub fn java_inherited_field_type_changes_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Java]) + .file( + "src/main/java/app/JsonWriter.java", + "package app;\n\npublic class JsonWriter {\n public void writeValue(String value) { }\n}\n", + ) + .file( + "src/main/java/app/OtherWriter.java", + "package app;\n\npublic class OtherWriter {\n public void writeValue(String value) { }\n}\n", + ) + .file( + "src/main/java/app/WriterBase.java", + "package app;\n\npublic abstract class WriterBase {\n protected JsonWriter writer;\n}\n", + ) + .file( + "src/main/java/app/TraceWriter.java", + "package app;\n\npublic class TraceWriter extends WriterBase {\n public void trace(String value) {\n writer.writeValue(value);\n }\n}\n", + ) + .write( + "src/main/java/app/WriterBase.java", + "package app;\n\npublic abstract class WriterBase {\n protected OtherWriter writer;\n}\n", + ) + .edge_before( + "method app::TraceWriter::trace", + "calls", + "method app::JsonWriter::writeValue", + ) + .edge_after( + "method app::TraceWriter::trace", + "calls", + "method app::OtherWriter::writeValue", + ) +} + +/// Upstream #2171, Kotlin: a property the base declares types the +/// subclass's call through it, across files. +pub fn kotlin_inherited_property_type_changes_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Kotlin]) + .file( + "src/main/kotlin/app/JsonWriter.kt", + "package app\n\nclass JsonWriter {\n fun writeValue(value: String) {}\n}\n", + ) + .file( + "src/main/kotlin/app/OtherWriter.kt", + "package app\n\nclass OtherWriter {\n fun writeValue(value: String) {}\n}\n", + ) + .file( + "src/main/kotlin/app/WriterBase.kt", + "package app\n\nopen class WriterBase {\n protected val writer: JsonWriter = JsonWriter()\n}\n", + ) + .file( + "src/main/kotlin/app/TraceWriter.kt", + "package app\n\nclass TraceWriter : WriterBase() {\n fun trace(value: String) {\n writer.writeValue(value)\n }\n}\n", + ) + .write( + "src/main/kotlin/app/WriterBase.kt", + "package app\n\nopen class WriterBase {\n protected val writer: OtherWriter = OtherWriter()\n}\n", + ) + .edge_before( + "method app::TraceWriter::trace", + "calls", + "method app::JsonWriter::writeValue", + ) + .edge_after( + "method app::TraceWriter::trace", + "calls", + "method app::OtherWriter::writeValue", + ) +} + +/// Upstream #2121: a type position names a type, so a property of the same +/// name appearing elsewhere is no competitor. +pub fn csharp_type_position_ignores_an_added_property() -> Case { + Case::new(Change::AddCompetitor, &[Language::CSharp]) + .file( + "src/TypeMap.cs", + "namespace App;\npublic class TypeMap { }\n", + ) + .file( + "src/Profile.cs", + "namespace App;\npublic class Profile {\n public void Configure(TypeMap typeMap) { }\n}\n", + ) + .write( + "src/Holder.cs", + "namespace App;\npublic class Holder {\n public TypeMap TypeMap { get; set; }\n}\n", + ) + .edge_throughout( + "method App::Profile::Configure", + "references", + "class App::TypeMap", + ) + .no_edge_after( + "method App::Profile::Configure", + "references", + "property App::Holder::TypeMap", + ) +} + +/// Upstream #2230: a package object's member is in scope through an import +/// of its package, read from its file's text, so renaming the package object +/// (its members keep their nodes) drops an unchanged importer's reference, +/// as on a fresh index. +pub fn scala_package_object_is_renamed_under_its_members() -> Case { + Case::new(Change::RenameSymbol, &[Language::Scala]) + .file( + "src/algebra/package.scala", + "package object algebra {\n type Eq[A] = Int\n}\n", + ) + .file( + "src/algebra/laws/RingLaws.scala", + "package algebra.laws\n\nimport algebra._\n\ntrait RingLaws {\n def eqv[A](eq: Eq[A]): Boolean = true\n}\n", + ) + .write( + "src/algebra/package.scala", + "package object algebra2 {\n type Eq[A] = Int\n}\n", + ) + .edge_before("method RingLaws::eqv", "references", "type_alias Eq") +} + +/// Upstream #2199: a type as a chain link is named by what holds it, a +/// top-level type's file's package, read from its text, so moving the type's +/// file to another package (its node unchanged) drops an unchanged caller's +/// `app.client.Response(200)`, as on a fresh index. +pub fn scala_chain_link_type_changes_its_package() -> Case { + Case::new(Change::BodyEdit, &[Language::Scala]) + .file( + "src/app/client/Response.scala", + "package app.client\n\ncase class Response(code: Int)\n", + ) + .file( + "src/app/Use.scala", + "package app\n\nclass Use {\n def make(): Any = app.client.Response(200)\n}\n", + ) + .write( + "src/app/client/Response.scala", + "package app.clients\n\ncase class Response(code: Int)\n", + ) + .edge_before("method Use::make", "instantiates", "class Response") +} + +/// Upstream #2193: a Scala `val` written in a block of a class body is that +/// block's, read from the class's braces, so wrapping a member `val` in a +/// block (its node unchanged) drops a subclass's call in another file, as on +/// a fresh index. +pub fn scala_member_val_becomes_block_local() -> Case { + Case::new(Change::BodyEdit, &[Language::Scala]) + .file( + "src/app/ASuite.scala", + "package app\n\nclass ASuite {\n def test(name: String)(body: => Unit): Unit = ()\n // block\n val f = (i: Int) => i\n // end\n}\n", + ) + .file( + "src/app/BSuite.scala", + "package app\n\nclass BSuite extends ASuite {\n def go(): Int = f(1)\n}\n", + ) + .write( + "src/app/ASuite.scala", + "package app\n\nclass ASuite {\n def test(name: String)(body: => Unit): Unit = ()\n test(\"a\") {\n val f = (i: Int) => i\n }\n}\n", + ) + .edge_before("method BSuite::go", "calls", "field ASuite::f") +} + +/// Upstream #2160: a chain link reaches a package object's function only +/// when it is an `extension` method, read from the lines above it, so an +/// `extension` line appearing above an unchanged function lets an unchanged +/// file's chain link reach it, as on a fresh index. +pub fn scala_function_becomes_an_extension_method() -> Case { + Case::new(Change::BodyEdit, &[Language::Scala]) + .file( + "src/app/tools/package.scala", + "package app\n\npackage object tools {\n val x = 1\n def double(n: Int): Int = n * 2\n}\n", + ) + .file( + "src/app/Use.scala", + "package app\n\nimport app.tools._\n\nobject Use {\n def run(): Int = List(1).double(2)\n}\n", + ) + .write( + "src/app/tools/package.scala", + "package app\n\npackage object tools {\n extension (s: String) def shout: String = s\n def double(n: Int): Int = n * 2\n}\n", + ) + .edge_after("method Use::run", "calls", "function double") +} + +/// Upstream #2193: two same-named `val f` of one file, `A`'s and `C`'s, +/// trade their block and member states with every node in place; a +/// subclass of `C` in another file gains its call to `C::f`, as on a fresh +/// index (the facts compare by node id, not by name). +pub fn scala_same_named_vals_trade_block_and_member_states() -> Case { + Case::new(Change::BodyEdit, &[Language::Scala]) + .file( + "src/app/Suites.scala", + "package app\n\nclass A {\n def test(n: String)(b: => Unit): Unit = ()\n // block\n val f = (i: Int) => i\n // end\n}\n\nclass C {\n def test(n: String)(b: => Unit): Unit = ()\n test(\"c\") {\n val f = (i: Int) => i\n }\n}\n", + ) + .file( + "src/app/D.scala", + "package app\n\nclass D extends C {\n def go(): Int = f(1)\n}\n", + ) + .write( + "src/app/Suites.scala", + "package app\n\nclass A {\n def test(n: String)(b: => Unit): Unit = ()\n test(\"a\") {\n val f = (i: Int) => i\n }\n}\n\nclass C {\n def test(n: String)(b: => Unit): Unit = ()\n // block\n val f = (i: Int) => i\n // end\n}\n", + ) + .edge_after("method D::go", "calls", "field C::f") +} + +/// Upstream #2199 past the name lane's per-tail ceiling: 501 parked +/// `app.client.Response(…)` calls all reach `Response` once its file moves +/// into `app.client`, as on a fresh index (the lane re-resolves their file +/// whole, however many share the tail). +pub fn scala_holder_change_reaches_past_the_tail_ceiling() -> Case { + static CALLS: std::sync::LazyLock = std::sync::LazyLock::new(|| { + let mut calls = String::from("package app\n\nclass Use {\n"); + for i in 0..501 { + calls.push_str(&format!( + " def make{i}(): Any = app.client.Response({i})\n" + )); + } + calls.push_str("}\n"); + calls + }); + let calls: &'static str = CALLS.as_str(); + Case::new(Change::BodyEdit, &[Language::Scala]) + .file( + "src/app/client/Response.scala", + "package app.clients\n\ncase class Response(code: Int)\n", + ) + .file("src/app/Use.scala", calls) + .write( + "src/app/client/Response.scala", + "package app.client\n\ncase class Response(code: Int)\n", + ) + .edge_after("method Use::make500", "instantiates", "class Response") +} + +/// Upstream #2160: a body edit in a base changes no head, so no unchanged +/// file re-resolves, and the subclass's bare call stays. +pub fn scala_base_body_edit_keeps_a_bare_call() -> Case { + Case::new(Change::BodyEdit, &[Language::Scala]) + .file( + "src/app/Base.scala", + "package app\n\ntrait Base {\n def assist(): Int = 1\n}\n", + ) + .file( + "src/app/Child.scala", + "package app\n\nclass Child extends Base {\n def go(): Int = assist()\n}\n", + ) + .write( + "src/app/Base.scala", + "package app\n\ntrait Base {\n def assist(): Int = {\n 2\n }\n}\n", + ) + .edge_throughout("method Child::go", "calls", "method Base::assist") +} + +/// Upstream #2160 in a batch: the base and the type between it and an +/// unchanged subclass both change in one sync; the subclass's bare call +/// reaches what the new middle type names, as on a fresh index. +pub fn scala_batch_rewrites_a_base_and_a_middle_type() -> Case { + Case::new(Change::EditHeritage, &[Language::Scala]) + .file( + "src/app/Root.scala", + "package app\n\ntrait Root {\n def assist(): Int = 1\n}\n", + ) + .file( + "src/app/Other.scala", + "package app\n\ntrait Other {\n def assist(): Int = 2\n}\n", + ) + .file( + "src/app/Spare.scala", + "package app\n\ntrait Spare {\n def assist(): Int = 3\n}\n", + ) + .file( + "src/app/Base.scala", + "package app\n\ntrait Base extends Root\n", + ) + .file( + "src/app/Mid.scala", + "package app\n\ntrait Mid extends Base\n", + ) + .file( + "src/app/Child.scala", + "package app\n\nclass Child extends Mid {\n def go(): Int = assist()\n}\n", + ) + .write( + "src/app/Base.scala", + "package app\n\ntrait Base extends Other\n", + ) + .write( + "src/app/Mid.scala", + "package app\n\ntrait Mid extends Spare\n", + ) + .edge_before("method Child::go", "calls", "method Root::assist") + .edge_after("method Child::go", "calls", "method Spare::assist") +} + +/// Upstream #2230, the other way round: a package object taking the name +/// an unchanged file imports (its members keep their nodes) gives that +/// file's reference its target, as on a fresh index. +pub fn scala_package_object_takes_an_imported_name() -> Case { + Case::new(Change::RenameSymbol, &[Language::Scala]) + .file( + "src/algebra/package.scala", + "package object algebra2 {\n type Eq[A] = Int\n}\n", + ) + .file( + "src/algebra/laws/RingLaws.scala", + "package algebra.laws\n\nimport algebra._\n\ntrait RingLaws {\n def eqv[A](eq: Eq[A]): Boolean = true\n}\n", + ) + .write( + "src/algebra/package.scala", + "package object algebra {\n type Eq[A] = Int\n}\n", + ) + .edge_after("method RingLaws::eqv", "references", "type_alias Eq") +} + +/// Upstream #2199, the other way round: a type's file moving into the +/// package an unchanged caller's chain names (the type's node unchanged) +/// gives `app.client.Response(200)` its target, as on a fresh index. +pub fn scala_chain_link_type_moves_into_the_named_package() -> Case { + Case::new(Change::BodyEdit, &[Language::Scala]) + .file( + "src/app/client/Response.scala", + "package app.clients\n\ncase class Response(code: Int)\n", + ) + .file( + "src/app/Use.scala", + "package app\n\nclass Use {\n def make(): Any = app.client.Response(200)\n}\n", + ) + .write( + "src/app/client/Response.scala", + "package app.client\n\ncase class Response(code: Int)\n", + ) + .edge_after("method Use::make", "instantiates", "class Response") +} + +/// Upstream #2193, the other way round: a block's `val` becoming a member of +/// the class body (its node unchanged) gives a subclass's call in another +/// file its target, as on a fresh index. +pub fn scala_block_local_val_becomes_a_member() -> Case { + Case::new(Change::BodyEdit, &[Language::Scala]) + .file( + "src/app/ASuite.scala", + "package app\n\nclass ASuite {\n def test(name: String)(body: => Unit): Unit = ()\n test(\"a\") {\n val f = (i: Int) => i\n }\n}\n", + ) + .file( + "src/app/BSuite.scala", + "package app\n\nclass BSuite extends ASuite {\n def go(): Int = f(1)\n}\n", + ) + .write( + "src/app/ASuite.scala", + "package app\n\nclass ASuite {\n def test(name: String)(body: => Unit): Unit = ()\n // block\n val f = (i: Int) => i\n // end\n}\n", + ) + .edge_after("method BSuite::go", "calls", "field ASuite::f") +} + +/// Upstream #2123's type scope reads the heads of every declaration named +/// like a type around the reference, another file's included, so a +/// same-named class's head changing in another file moves an unchanged +/// file's type reference, as on a fresh index. +pub fn scala_type_reference_follows_a_namesake_head_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Scala]) + .file( + "src/app/HasA.scala", + "package app\n\ntrait HasA {\n type A = Int\n}\n", + ) + .file( + "src/app/HasB.scala", + "package app\n\ntrait HasB {\n type A = Long\n}\n", + ) + .file( + "src/other/Child.scala", + "package other\n\nclass Child extends HasA\n", + ) + .file( + "src/app/Child.scala", + "package app\n\nclass Child {\n def get(): A = ???\n}\n", + ) + .write( + "src/other/Child.scala", + "package other\n\nclass Child extends HasB\n", + ) + .edge_before("method Child::get", "references", "type_alias HasA::A") + .edge_after("method Child::get", "references", "type_alias HasB::A") +} + +pub fn scala_change_a_parent_trait() -> Case { + Case::new(Change::EditHeritage, &[Language::Scala]) + .file( + "src/Shape.scala", + "package shapes\n\ntrait Shape {\n def area(): Double\n}\n\ntrait Figure {\n def area(): Double\n}\n", + ) + .file( + "src/Square.scala", + "package shapes\n\nclass Square(side: Double) extends Shape {\n def area(): Double = side * side\n}\n", + ) + .write( + "src/Square.scala", + "package shapes\n\nclass Square(side: Double) extends Figure {\n def area(): Double = side * side\n}\n", + ) + .edge_before("class Square", "extends", "trait Shape") + .edge_after("class Square", "extends", "trait Figure") +} + +pub fn solidity_change_a_base_contract() -> Case { + Case::new(Change::EditHeritage, &[Language::Solidity]) + .file( + "contracts/Ownable.sol", + "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.0;\n\ncontract Ownable {\n address internal owner;\n\n function transferOwnership(address next) public {\n owner = next;\n }\n}\n", + ) + .file( + "contracts/Pausable.sol", + "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.0;\n\ncontract Pausable {\n bool internal paused;\n\n function pause() public {\n paused = true;\n }\n}\n", + ) + .file( + "contracts/Token.sol", + "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.0;\n\nimport \"./Ownable.sol\";\nimport \"./Pausable.sol\";\n\ncontract Token is Ownable {\n function handOver(address next) public {\n transferOwnership(next);\n }\n}\n", + ) + .write( + "contracts/Token.sol", + "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.0;\n\nimport \"./Ownable.sol\";\nimport \"./Pausable.sol\";\n\ncontract Token is Pausable {\n function handOver(address next) public {\n transferOwnership(next);\n }\n}\n", + ) + .edge_before("class Token", "extends", "class Ownable") + .edge_after("class Token", "extends", "class Pausable") +} + +/// OpenZeppelin's `Context` and its `ERC2771Context` override, which a bare +/// call reaches only through the caller's `is` chain (upstream #2178). +fn solidity_context_case(change: Change, middle: &'static str) -> Case { + Case::new(change, &[Language::Solidity]) + .file( + "contracts/utils/Context.sol", + "pragma solidity ^0.8.20;\n\nabstract contract Context {\n function _msgSender() internal view virtual returns (address) {\n return msg.sender;\n }\n}\n", + ) + .file( + "contracts/metatx/ERC2771Context.sol", + "pragma solidity ^0.8.20;\n\nimport {Context} from \"../utils/Context.sol\";\n\nabstract contract ERC2771Context is Context {\n function _msgSender() internal view virtual override returns (address) {\n return msg.sender;\n }\n}\n", + ) + .file("contracts/Middle.sol", middle) + .file( + "contracts/Inner.sol", + "pragma solidity ^0.8.20;\n\nimport \"./Middle.sol\";\n\nabstract contract Inner is Middle {}\n", + ) + .file( + "contracts/governance/Governor.sol", + "pragma solidity ^0.8.20;\n\nimport \"../Inner.sol\";\n\nabstract contract Governor is Inner {\n function castVote() public returns (address) {\n return _msgSender();\n }\n}\n", + ) +} + +const MIDDLE_ALONE: &str = "pragma solidity ^0.8.20;\n\nabstract contract Middle {}\n"; +const MIDDLE_IS_CONTEXT: &str = "pragma solidity ^0.8.20;\n\nimport \"./utils/Context.sol\";\n\nabstract contract Middle is Context {}\n"; +const LEGACY_MIDDLE_IS_CONTEXT: &str = "pragma solidity ^0.8.20;\n\nimport \"../utils/Context.sol\";\n\nabstract contract Middle is Context {}\n"; + +/// A base two steps up an unchanged contract's chain comes to inherit +/// `Context`: the unchanged contract's bare call reaches Context's function, +/// never the override of a contract it does not inherit (upstream #2178). +pub fn solidity_bare_call_reaches_a_function_its_chain_comes_to_inherit() -> Case { + solidity_context_case(Change::EditHeritage, MIDDLE_ALONE) + .write("contracts/Middle.sol", MIDDLE_IS_CONTEXT) + .edge_after( + "method Governor::castVote", + "calls", + "method Context::_msgSender", + ) + .no_edge_after( + "method Governor::castVote", + "calls", + "method ERC2771Context::_msgSender", + ) +} + +/// The reverse: the base stops inheriting it, and the call links nothing. +pub fn solidity_bare_call_leaves_a_function_its_chain_stops_inheriting() -> Case { + solidity_context_case(Change::EditHeritage, MIDDLE_IS_CONTEXT) + .write("contracts/Middle.sol", MIDDLE_ALONE) + .edge_before( + "method Governor::castVote", + "calls", + "method Context::_msgSender", + ) + .no_edge_after( + "method Governor::castVote", + "calls", + "method Context::_msgSender", + ) + .no_edge_after( + "method Governor::castVote", + "calls", + "method ERC2771Context::_msgSender", + ) +} + +/// A second declaration of a base's name appears in another file: the chain +/// reads every declaration of a name, so the unchanged contract's call +/// reaches what the new one inherits. +pub fn solidity_bare_call_follows_a_namesake_base_that_appears() -> Case { + solidity_context_case(Change::AddCompetitor, MIDDLE_ALONE) + .write("contracts/legacy/Middle.sol", LEGACY_MIDDLE_IS_CONTEXT) + .edge_after( + "method Governor::castVote", + "calls", + "method Context::_msgSender", + ) +} + +/// Upstream #2148: a bare call in a component reaches the components its +/// `extends` chain names, read from their source, so a middle component +/// extending another base in another file moves an unchanged leaf's call. +pub fn cfml_bare_call_follows_an_extends_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Cfml]) + .file( + "system/First.cfc", + "component {\n function getSetting( name ) {\n return 1;\n }\n}\n", + ) + .file( + "system/Second.cfc", + "component {\n function getSetting( name ) {\n return 2;\n }\n}\n", + ) + .file("system/Middle.cfc", "component extends=\"First\" {\n}\n") + .file( + "system/Handler.cfc", + "component extends=\"Middle\" {\n function index() {\n return getSetting( \"appName\" );\n }\n}\n", + ) + .write("system/Middle.cfc", "component extends=\"Second\" {\n}\n") + .edge_before("method Handler::index", "calls", "method First::getSetting") + .edge_after("method Handler::index", "calls", "method Second::getSetting") +} + +/// Upstream #2148: a bare `extends` name means a component of the same +/// directory first, so one appearing beside the handler takes the chain +/// over, and the call to the farther base's method is gone, as on a fresh +/// index. +pub fn cfml_bare_extends_takes_a_component_that_appears_beside_it() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cfml]) + .file( + "lib/Base.cfc", + "component {\n function getSetting( name ) {\n return 1;\n }\n}\n", + ) + .file( + "app/Handler.cfc", + "component extends=\"Base\" {\n function index() {\n return getSetting( \"appName\" );\n }\n}\n", + ) + .write("app/Base.cfc", "component {\n}\n") + .edge_before("method Handler::index", "calls", "method Base::getSetting") + .no_edge_after("method Handler::index", "calls", "method Base::getSetting") +} + +/// Upstream #2148: a bare call in a component never reaches a method +/// outside its chain, so a date helper's `now` added in another file leaves +/// the built-in `now()` unresolved. +pub fn cfml_bare_call_ignores_an_added_built_in_namesake() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cfml]) + .file( + "system/Base.cfc", + "component {\n function getSetting( name ) {\n return 1;\n }\n}\n", + ) + .file( + "system/Handler.cfc", + "component extends=\"Base\" {\n function index() {\n var stamp = now();\n return getSetting( \"appName\" );\n }\n}\n", + ) + .write( + "system/core/DateTimeHelper.cfc", + "component {\n function now() {\n return 0;\n }\n}\n", + ) + .edge_throughout("method Handler::index", "calls", "method Base::getSetting") + .no_edge_after("method Handler::index", "calls", "method DateTimeHelper::now") +} + +/// Upstream #2148: a CFML call through an untyped receiver takes the one +/// method of its name only when the receiver is named after its component, +/// so an interceptor buffer's lone `keyExists` added in another file is not +/// `server.keyExists`. +pub fn cfml_untyped_receiver_ignores_a_lone_added_method() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cfml]) + .file( + "system/models/UserService.cfc", + "component {\n function list() {\n return [];\n }\n}\n", + ) + .file( + "system/Handler.cfc", + "component {\n function index( userService ) {\n var lucee = server.keyExists( \"lucee\" );\n return userService.list();\n }\n}\n", + ) + .write( + "system/core/InterceptorBuffer.cfc", + "component {\n function keyExists( required key ) {\n return false;\n }\n}\n", + ) + .edge_throughout("method Handler::index", "calls", "method UserService::list") + .no_edge_after( + "method Handler::index", + "calls", + "method InterceptorBuffer::keyExists", + ) +} + +/// Upstream #2148's relaxation: a receiver whose whole name spells a +/// component (`cbsecurity` -> CBSecurity) takes that component's method once +/// it appears in another file. +pub fn cfml_receiver_named_after_an_added_component() -> Case { + Case::new(Change::AddImportTarget, &[Language::Cfml]) + .file( + "system/Guard.cfc", + "component {\n function guard( cbsecurity ) {\n return cbsecurity.secure( \"x\" );\n }\n}\n", + ) + .write( + "system/security/CBSecurity.cfc", + "component {\n function secure( event ) {\n return true;\n }\n}\n", + ) + .edge_after("method Guard::guard", "calls", "method CBSecurity::secure") +} + +/// Upstream #2148: an inherited method's body changing leaves the unchanged +/// component's bare call where it was. +pub fn cfml_inherited_method_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Cfml]) + .file( + "system/Base.cfc", + "component {\n function getSetting( name ) {\n return 1;\n }\n}\n", + ) + .file( + "system/Handler.cfc", + "component extends=\"app.system.Base\" {\n function index() {\n return getSetting( \"appName\" );\n }\n}\n", + ) + .write( + "system/Base.cfc", + "component {\n function getSetting( name ) {\n return 2;\n }\n}\n", + ) + .edge_throughout("method Handler::index", "calls", "method Base::getSetting") +} + +/// Upstream #1152: a dotted component path names its supertype by +/// directory, so the component it names appearing in another file links the +/// unchanged subclass, as on a fresh index. +pub fn cfml_dotted_extends_of_an_added_component() -> Case { + Case::new(Change::AddImportTarget, &[Language::Cfml]) + .file( + "handlers/Main.cfc", + "component extends=\"coldbox.system.web.Controller\" {\n function index() {\n return handle();\n }\n}\n", + ) + .write( + "system/web/Controller.cfc", + "component {\n function handle() {\n return 1;\n }\n}\n", + ) + .edge_after( + "class handlers/Main.cfc::Main", + "extends", + "class system/web/Controller.cfc::Controller", + ) +} + +/// Upstream #1152: among same-named components the one whose directories +/// corroborate the most of the path wins, so a better-corroborated one +/// appearing in another file takes the unchanged subclass's `extends`. +pub fn cfml_dotted_extends_takes_a_better_corroborated_component() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cfml]) + .file("web/Controller.cfc", "component {\n}\n") + .file( + "handlers/Main.cfc", + "component extends=\"coldbox.system.web.Controller\" {\n}\n", + ) + .write("system/web/Controller.cfc", "component {\n}\n") + .edge_before( + "class handlers/Main.cfc::Main", + "extends", + "class web/Controller.cfc::Controller", + ) + .edge_after( + "class handlers/Main.cfc::Main", + "extends", + "class system/web/Controller.cfc::Controller", + ) + .no_edge_after( + "class handlers/Main.cfc::Main", + "extends", + "class web/Controller.cfc::Controller", + ) +} + +/// Upstream #1152 / #1154: a relative component path (FW/1's `../base`) +/// resolves against the referencing file once the component appears. +pub fn cfml_relative_extends_of_an_added_component() -> Case { + Case::new(Change::AddImportTarget, &[Language::Cfml]) + .file( + "examples/sub/app.cfc", + "component extends=\"../base\" {\n}\n", + ) + .write( + "examples/base.cfc", + "component {\n function shared() {\n return 1;\n }\n}\n", + ) + .edge_after( + "class examples/sub/app.cfc::app", + "extends", + "class examples/base.cfc::base", + ) +} + +/// Upstream #1155: a receiver typed by its declaration reaches the method its +/// component declares, so the method appearing in that component's file +/// links the unchanged call, and never the same-named method of another. +pub fn cfml_typed_receiver_reaches_a_method_its_type_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Cfml]) + .file( + "svc/UserService.cfc", + "component {\n function load(any u) {\n return u;\n }\n}\n", + ) + .file( + "svc/OrderService.cfc", + "component {\n function save(any o) {\n return o;\n }\n}\n", + ) + .file( + "handlers/Main.cfc", + "component {\n function bare() {\n var svc = new UserService();\n return svc.save(1);\n }\n}\n", + ) + .write( + "svc/UserService.cfc", + "component {\n function load(any u) {\n return u;\n }\n function save(any u) {\n return u;\n }\n}\n", + ) + .edge_after("method Main::bare", "calls", "method UserService::save") + .no_edge_after("method Main::bare", "calls", "method OrderService::save") +} + +/// W4-CF-02: a same-named component appearing in another directory leaves +/// the unchanged caller's bare type name ambiguous, so its call loses its +/// edge, while a written path still tells the components apart. +pub fn cfml_typed_receiver_namesake_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cfml]) + .file( + "a/UserService.cfc", + "component {\n function save(any u) {\n return u;\n }\n}\n", + ) + .file( + "handlers/Main.cfc", + "component {\n function bare() {\n var svc = new UserService();\n return svc.save(1);\n }\n function dotted() {\n var svc = new a.UserService();\n return svc.save(2);\n }\n}\n", + ) + .write( + "b/UserService.cfc", + "component {\n function save(any u) {\n return u;\n }\n}\n", + ) + .edge_before( + "method Main::bare", + "calls", + "method UserService::save @a/UserService.cfc", + ) + .no_edge_after( + "method Main::bare", + "calls", + "method UserService::save @a/UserService.cfc", + ) + .no_edge_after( + "method Main::bare", + "calls", + "method UserService::save @b/UserService.cfc", + ) + .edge_after( + "method Main::dotted", + "calls", + "method UserService::save @a/UserService.cfc", + ) +} + +/// W4-CF-02: once the namesake leaves, the unchanged caller's bare type +/// name names one component again, and its call, unresolved while the two +/// stood side by side, reaches it. +pub fn cfml_typed_receiver_namesake_leaves() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Cfml]) + .file( + "a/UserService.cfc", + "component {\n function save(any u) {\n return u;\n }\n}\n", + ) + .file( + "b/UserService.cfc", + "component {\n function save(any u) {\n return u;\n }\n}\n", + ) + .file( + "handlers/Main.cfc", + "component {\n function bare() {\n var svc = new UserService();\n return svc.save(1);\n }\n}\n", + ) + .remove("b/UserService.cfc") + .edge_after( + "method Main::bare", + "calls", + "method UserService::save @a/UserService.cfc", + ) +} + +/// More unresolved `….save` calls than one tail retries in a sync +/// (`NAME_TAIL_RETRY_CEILING`, 500): untyped receivers no `save` is named +/// after. +static CFML_UNTYPED_SAVE_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("component {\n function noise() {\n"); + for index in 0..501 { + text.push_str(&format!(" n{index}.save({index});\n")); + } + text.push_str(" return 0;\n }\n}\n"); + text +}); + +/// W4-CF-02: the namesake leaving settles the unchanged caller's parked +/// typed call even when more parked calls share its tail than the name-tail +/// retry wakes. +pub fn cfml_typed_receiver_namesake_leaves_past_the_tail_ceiling() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Cfml]) + .file( + "a/UserService.cfc", + "component {\n function save(any u) {\n return u;\n }\n}\n", + ) + .file( + "b/UserService.cfc", + "component {\n function save(any u) {\n return u;\n }\n}\n", + ) + .file( + "handlers/Main.cfc", + "component {\n function bare() {\n var svc = new UserService();\n return svc.save(1);\n }\n}\n", + ) + .file("handlers/Noise.cfc", CFML_UNTYPED_SAVE_CALLS.as_str()) + .remove("b/UserService.cfc") + .edge_after( + "method Main::bare", + "calls", + "method UserService::save @a/UserService.cfc", + ) +} + +/// W4-CF-02: a namesake whose directories corroborate more of the written +/// path takes the unchanged caller's typed call. +pub fn cfml_typed_receiver_takes_a_better_corroborated_namesake() -> Case { + Case::new(Change::AddCompetitor, &[Language::Cfml]) + .file( + "a/UserService.cfc", + "component {\n function save(any u) {\n return u;\n }\n}\n", + ) + .file( + "handlers/Main.cfc", + "component {\n function dotted() {\n var svc = new x.a.UserService();\n return svc.save(1);\n }\n}\n", + ) + .write( + "x/a/UserService.cfc", + "component {\n function save(any u) {\n return u;\n }\n}\n", + ) + .edge_before( + "method Main::dotted", + "calls", + "method UserService::save @a/UserService.cfc", + ) + .edge_after( + "method Main::dotted", + "calls", + "method UserService::save @x/a/UserService.cfc", + ) + .no_edge_after( + "method Main::dotted", + "calls", + "method UserService::save @a/UserService.cfc", + ) +} + +/// Upstream #2296: a `` block at component scope declares the +/// component's methods, read with the cfscript grammar in place; editing a +/// method's body moves its call. +pub fn cfml_script_tag_method_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Cfml]) + .file( + "mod/Helpers.cfc", + "component {\n function first() {\n return 1;\n }\n function second() {\n return 2;\n }\n}\n", + ) + .file( + "mod/ModuleConfig.cfc", + "\n\nfunction configure() {\n var helpers = new Helpers();\n return helpers.first();\n}\n\n\n", + ) + .write( + "mod/ModuleConfig.cfc", + "\n\nfunction configure() {\n var helpers = new Helpers();\n return helpers.second();\n}\n\n\n", + ) + .edge_before("method ModuleConfig::configure", "calls", "method Helpers::first") + .edge_after("method ModuleConfig::configure", "calls", "method Helpers::second") +} + +/// Upstream #2296: a `` body's `#hash#` calls and a method wrapped +/// in `` are the method's; an edit to the SQL moves +/// the call. +pub fn cfml_query_body_in_a_wrapped_method_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Cfml]) + .file( + "Application.cfc", + "\n\n\nSELECT #firstColumn()# FROM t\n\n\n\n\n\n", + ) + .write( + "Application.cfc", + "\n\n\nSELECT #secondColumn()# FROM t\n\n\n\n\n\n", + ) + .edge_before( + "method Application::onRequestStart", + "calls", + "method Application::firstColumn", + ) + .edge_after( + "method Application::onRequestStart", + "calls", + "method Application::secondColumn", + ) +} + +/// `(((…1)))` nested 400 deep: past the walker's 256-level limit in a +/// re-parsed `` or `` body, inside a shallow tag tree. +fn cfml_deep_parentheses() -> String { + format!("{}1{}", "(".repeat(400), ")".repeat(400)) +} + +static DEEP_SCRIPT_MODULE_CONFIG: LazyLock = LazyLock::new(|| { + format!( + "\n\nfunction configure() {{\n var helpers = new Helpers();\n var x = {};\n return helpers.first();\n}}\n\n\n", + cfml_deep_parentheses() + ) +}); + +static DEEP_QUERY_MODULE_CONFIG: LazyLock = LazyLock::new(|| { + format!( + "\n\nSELECT #{}# FROM t\n\n\n\n\n", + cfml_deep_parentheses() + ) +}); + +/// A `` body nested past the walker's depth limit fails its whole +/// file: the component's class and methods leave the index, so another +/// component's `extends` of its name takes the namesake elsewhere, as a fresh +/// index has it. +pub fn cfml_script_tag_body_nested_past_the_depth_limit_drops_the_file() -> Case { + Case::new(Change::BodyEdit, &[Language::Cfml]) + .file( + "mod/Helpers.cfc", + "component {\n function first() {\n return 1;\n }\n}\n", + ) + .file( + "mod/ModuleConfig.cfc", + "\n\nfunction configure() {\n var helpers = new Helpers();\n var x = (1);\n return helpers.first();\n}\n\n\n", + ) + .file( + "other/ModuleConfig.cfc", + "component {\n function configure() {\n return 0;\n }\n}\n", + ) + .file("mod/Child.cfc", "component extends=\"ModuleConfig\" {\n}\n") + .write("mod/ModuleConfig.cfc", DEEP_SCRIPT_MODULE_CONFIG.as_str()) + .edge_before( + "method ModuleConfig::configure @mod/ModuleConfig.cfc", + "calls", + "method Helpers::first", + ) + .edge_before( + "class mod/Child.cfc::Child", + "extends", + "class mod/ModuleConfig.cfc::ModuleConfig", + ) + .edge_after( + "class mod/Child.cfc::Child", + "extends", + "class other/ModuleConfig.cfc::ModuleConfig", + ) +} + +/// The same for a `` body whose `#hash#` expression nests past the +/// limit inside a tag function. +pub fn cfml_query_body_nested_past_the_depth_limit_drops_the_file() -> Case { + Case::new(Change::BodyEdit, &[Language::Cfml]) + .file( + "mod/ModuleConfig.cfc", + "\n\nSELECT #(1)# FROM t\n\n\n\n\n", + ) + .file( + "other/ModuleConfig.cfc", + "component {\n function configure() {\n return 0;\n }\n}\n", + ) + .file("mod/Child.cfc", "component extends=\"ModuleConfig\" {\n}\n") + .write("mod/ModuleConfig.cfc", DEEP_QUERY_MODULE_CONFIG.as_str()) + .edge_before( + "method ModuleConfig::configure @mod/ModuleConfig.cfc", + "calls", + "method ModuleConfig::first @mod/ModuleConfig.cfc", + ) + .edge_before( + "class mod/Child.cfc::Child", + "extends", + "class mod/ModuleConfig.cfc::ModuleConfig", + ) + .edge_after( + "class mod/Child.cfc::Child", + "extends", + "class other/ModuleConfig.cfc::ModuleConfig", + ) +} + +/// Upstream #2296: `` is an interface, so a component's +/// `implements` reaches it once the file appears. +pub fn cfml_implements_an_added_interface_tag() -> Case { + Case::new(Change::AddImportTarget, &[Language::Cfml]) + .file( + "model/Catalog.cfc", + "\n\n\n", + ) + .write( + "model/ISearchable.cfc", + "\n\n\n", + ) + .edge_after( + "class model/Catalog.cfc::Catalog", + "implements", + "interface model/ISearchable.cfc::ISearchable", + ) +} + +pub fn cfml_change_a_base_component() -> Case { + Case::new(Change::EditHeritage, &[Language::Cfml]) + .file( + "models/Base.cfc", + "component {\n function describe() {\n return \"base\";\n }\n}\n", + ) + .file( + "models/Person.cfc", + "component {\n function describe() {\n return \"person\";\n }\n}\n", + ) + .file( + "models/User.cfc", + "component extends=\"Base\" {\n function greet() {\n return describe();\n }\n}\n", + ) + .write( + "models/User.cfc", + "component extends=\"Person\" {\n function greet() {\n return describe();\n }\n}\n", + ) + .edge_before( + "class models/User.cfc::User", + "extends", + "class models/Base.cfc::Base", + ) + .edge_after( + "class models/User.cfc::User", + "extends", + "class models/Person.cfc::Person", + ) +} + +// ----- a second declaration of a referenced name appears ------------------- + +pub fn python_add_a_same_named_competitor() -> Case { + Case::new(Change::AddCompetitor, &[Language::Python]) + .file("tools/alpha.py", "def helper():\n return 1\n") + .file("main.py", "def main():\n return helper()\n") + .write("tools/beta.py", "def helper():\n return 2\n") + .edge_before("function main", "calls", "function helper @tools/alpha.py") +} + +pub fn arkts_add_a_same_named_competitor() -> Case { + Case::new(Change::AddCompetitor, &[Language::ArkTs]) + .file( + "entry/Util.ets", + "export function greet(name: string): string {\n return 'hi ' + name;\n}\n", + ) + .file( + "entry/Index.ets", + "import { greet } from './Util';\n\n@Entry\n@Component\nstruct Index {\n build() {\n Text(greet('there'))\n }\n}\n", + ) + .write( + "entry/Other.ets", + "export function greet(name: string): string {\n return 'hello ' + name;\n}\n", + ) + .edge_before("method Index::build", "calls", "function greet @entry/Util.ets") +} + +pub fn kotlin_add_a_same_named_competitor() -> Case { + Case::new(Change::AddCompetitor, &[Language::Kotlin]) + .file( + "src/Util.kt", + "package shop\n\nfun formatPrice(cents: Int): String {\n return \"$\" + cents / 100\n}\n", + ) + .file( + "src/Cart.kt", + "package shop\n\nclass Cart {\n fun label(cents: Int): String {\n return formatPrice(cents)\n }\n}\n", + ) + .write( + "src/Other.kt", + "package other\n\nfun formatPrice(cents: Int): String {\n return cents.toString()\n}\n", + ) + .edge_before("method shop::Cart::label", "calls", "function shop::formatPrice") +} + +pub fn ruby_add_a_same_named_competitor() -> Case { + Case::new(Change::AddCompetitor, &[Language::Ruby]) + .file( + "lib/greeter.rb", + "class Greeter\n def greet(name)\n \"hi #{name}\"\n end\nend\n", + ) + .file( + "lib/app.rb", + "require_relative 'greeter'\n\nclass App\n def run\n Greeter.new.greet('there')\n end\nend\n", + ) + .write( + "lib/robot.rb", + "class Robot\n def greet(name)\n \"beep #{name}\"\n end\nend\n", + ) + .edge_before("method App::run", "calls", "method Greeter::greet") +} + +/// Upstream #2147: a bare Ruby call in a class reaches the modules its +/// ancestors mix in, read from their source, so a superclass gaining an +/// `include` in another file links an unchanged subclass's call. +pub fn ruby_bare_call_follows_a_mixin_added_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Ruby]) + .file( + "lib/helpers.rb", + "module Helpers\n def helper\n 1\n end\nend\n", + ) + .file("lib/base.rb", "class Base\nend\n") + .file( + "lib/foo.rb", + "class Foo < Base\n def run\n helper()\n end\nend\n", + ) + .write("lib/base.rb", "class Base\n include Helpers\nend\n") + .edge_after("method Foo::run", "calls", "method Helpers::helper") +} + +/// Upstream #2147: a bare Ruby call follows the superclass chain, so a +/// middle class changing its superclass in another file moves an unchanged +/// leaf's call. +pub fn ruby_bare_call_follows_a_superclass_change_in_another_file() -> Case { + Case::new(Change::EditHeritage, &[Language::Ruby]) + .file( + "lib/first.rb", + "class First\n def setup\n 1\n end\nend\n", + ) + .file( + "lib/second.rb", + "class Second\n def setup\n 2\n end\nend\n", + ) + .file("lib/middle.rb", "class Middle < First\nend\n") + .file( + "lib/leaf.rb", + "class Leaf < Middle\n def run\n setup()\n end\nend\n", + ) + .write("lib/middle.rb", "class Middle < Second\nend\n") + .edge_before("method Leaf::run", "calls", "method First::setup") + .edge_after("method Leaf::run", "calls", "method Second::setup") +} + +/// Upstream #2147: a mixed-in constant is resolved against the lexical +/// nesting, so a nearer module of that name appearing in another file takes +/// the `include` over, and an unchanged class's call to the farther module's +/// method is gone, as on a fresh index. +pub fn ruby_mixin_resolves_to_a_nearer_module_that_appears() -> Case { + Case::new(Change::AddCompetitor, &[Language::Ruby]) + .file( + "lib/app/range_help.rb", + "module App\n module RangeHelp\n def range_between(a, b)\n [a, b]\n end\n end\nend\n", + ) + .file( + "lib/app/style/foo.rb", + "module App\n module Style\n class Foo\n include RangeHelp\n\n def run\n range_between(1, 2)\n end\n end\n end\nend\n", + ) + .write( + "lib/app/style/range_help.rb", + "module App\n module Style\n module RangeHelp\n end\n end\nend\n", + ) + .edge_before( + "method App::Style::Foo::run", + "calls", + "method App::RangeHelp::range_between", + ) + .no_edge_after( + "method App::Style::Foo::run", + "calls", + "method App::RangeHelp::range_between", + ) +} + +/// Upstream #2147: a bare Ruby call in a class never reaches a method outside +/// its ancestry, so an unrelated class's `format` added in another file +/// leaves rubocop's Kernel `format(…)` unresolved. +pub fn ruby_bare_call_ignores_an_added_unrelated_method() -> Case { + Case::new(Change::AddCompetitor, &[Language::Ruby]) + .file( + "lib/base.rb", + "class Base\n def add_offense(node)\n node\n end\nend\n", + ) + .file( + "lib/foo.rb", + "class Foo < Base\n def run(node)\n add_offense(node)\n format(\"x\")\n end\nend\n", + ) + .write( + "lib/runtime.rb", + "class Runtime\n def format(text)\n text\n end\nend\n", + ) + .edge_throughout("method Foo::run", "calls", "method Base::add_offense") + .no_edge_after("method Foo::run", "calls", "method Runtime::format") +} + +/// Upstream #2147: a Ruby call through an untyped receiver takes the one +/// method of its name only when the receiver is named after its owner, so a +/// lone `loc` added in another file is not `node.loc`. +pub fn ruby_untyped_receiver_ignores_a_lone_added_method() -> Case { + Case::new(Change::AddCompetitor, &[Language::Ruby]) + .file( + "lib/web_push_request.rb", + "class WebPushRequest\n def legacy_encrypt(payload)\n payload\n end\nend\n", + ) + .file( + "lib/sender.rb", + "class Sender\n def run(web_push_request, node)\n web_push_request.legacy_encrypt(1)\n node.loc\n end\nend\n", + ) + .write( + "lib/expression.rb", + "module Expression\n def loc\n @loc\n end\nend\n", + ) + .edge_throughout( + "method Sender::run", + "calls", + "method WebPushRequest::legacy_encrypt", + ) + .no_edge_after("method Sender::run", "calls", "method Expression::loc") +} + +/// Upstream #2147: an ancestor's method body changing leaves the unchanged +/// subclass's bare call where it was. +pub fn ruby_ancestor_method_body_edit() -> Case { + Case::new(Change::BodyEdit, &[Language::Ruby]) + .file( + "lib/base.rb", + "class Base\n def add_offense(node)\n node\n end\nend\n", + ) + .file( + "lib/foo.rb", + "class Foo < Base\n def run(node)\n add_offense(node)\n end\nend\n", + ) + .write( + "lib/base.rb", + "class Base\n def add_offense(node)\n [node]\n end\nend\n", + ) + .edge_throughout("method Foo::run", "calls", "method Base::add_offense") +} + +pub fn php_add_a_same_named_competitor() -> Case { + Case::new(Change::AddCompetitor, &[Language::Php]) + .file( + "src/Mailer.php", + "send($email);\n }\n}\n", + ) + .write( + "src/Queue.php", + " Case { + Case::new(Change::AddCompetitor, &[Language::Php]) + .file( + "src/Field/Base.php", + " Case { + Case::new(Change::AddCompetitor, &[Language::Tsx]) + .file( + "tsconfig.json", + "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"paths\": { \"*\": [\"./typings/*\"] }\n }\n}\n", + ) + .file( + "src/App.tsx", + "import { Typography } from '@mui/material';\n\nexport function App() {\n return ;\n}\n", + ) + .file( + "src/Page.tsx", + "import { Typography } from './Typography';\n\nexport function Page() {\n return ;\n}\n", + ) + .write( + "src/Typography.tsx", + "export function Typography(props: { text: string }) {\n return {props.text};\n}\n", + ) + .edge_after( + "file:src/Page.tsx", + "imports", + "function Typography @src/Typography.tsx", + ) + .no_edge_after("file:src/App.tsx", "imports", "function Typography") +} + +/// The index root's `config.toml` is a watcher control file: the watcher +/// reloads its scope and reconciles the whole project. +pub fn typescript_config_toml_excludes_a_competitor() -> Case { + Case::new( + Change::ControlFile, + &[Language::TypeScript, Language::JavaScript], + ) + .file( + "src/math.ts", + "export function add(a: number, b: number): number {\n return a + b;\n}\n", + ) + .file( + "src/app.ts", + "export function run(): number {\n return add(1, 2);\n}\n", + ) + .file( + "web/bundle.js", + "function add(a, b) {\n return a + b;\n}\n", + ) + .write( + ".codegraph/config.toml", + "[app]\nname = \"matrix\"\n\n[indexing]\nexclude = [\"web/\"]\n", + ) + .edge_throughout("function run", "calls", "function add @src/math.ts") +} + +/// The root `.gitignore` is a watcher control file too. The hidden directory +/// is one the default ignores keep (`vendor/` is never indexed at all), so the +/// call moves from the competitor to the remaining definition. +pub fn python_root_gitignore_hides_a_competitor() -> Case { + Case::new(Change::ControlFile, &[Language::Python]) + .file("tools/alpha.py", "def helper():\n return 1\n") + .file("legacy/beta.py", "def helper():\n return 2\n") + .file("main.py", "def main():\n return helper()\n") + .write(".gitignore", "legacy/\n") + .edge_before("function main", "calls", "function helper @legacy/beta.py") + .edge_after("function main", "calls", "function helper @tools/alpha.py") +} + +/// The repository's `.git/info/exclude` is read with the root `.gitignore` +/// (upstream #1728), so it is a control file as well. +pub fn python_repository_exclude_hides_a_competitor() -> Case { + Case::new(Change::ControlFile, &[Language::Python]) + .file("tools/alpha.py", "def helper():\n return 1\n") + .file("legacy/beta.py", "def helper():\n return 2\n") + .file("main.py", "def main():\n return helper()\n") + .file(".git/info/exclude", "# git ls-files --exclude-standard\n") + .write(".git/info/exclude", "legacy/\n") + .edge_before("function main", "calls", "function helper @legacy/beta.py") + .edge_after("function main", "calls", "function helper @tools/alpha.py") +} + +pub fn lua_extension_override_appears_in_codegraph_json() -> Case { + Case::new(Change::ControlFile, &[Language::Lua]) + .file( + "scripts/util.luax", + "local function shout(text)\n return text .. \"!\"\nend\n\nreturn shout\n", + ) + .file( + "main.lua", + "local shout = require('scripts.util')\nprint(shout('hi'))\n", + ) + .write( + ".codegraph/codegraph.json", + "{ \"extensions\": { \".luax\": \"lua\" } }\n", + ) + .edge_after("file:scripts/util.luax", "contains", "function shout") +} + +/// Found by this matrix: only a fresh index resolves the import through the +/// new `paths` alias; the synced one keeps its bare-name guess. +pub fn typescript_tsconfig_paths_alias_appears() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file( + "src/lib/math.ts", + "export function double(value: number): number {\n return value * 2;\n}\n", + ) + .file( + "src/main.ts", + "import { double } from '@lib/math';\n\nexport function run(): number {\n return double(2);\n}\n", + ) + .write( + "tsconfig.json", + "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"paths\": { \"@lib/*\": [\"src/lib/*\"] }\n }\n}\n", + ) + .edge_after("file:src/main.ts", "imports", "function double") +} + +/// A `tsconfig.json` past 64 MiB, its `paths` sending `@lib/*` to `target`, +/// padded with whitespace to a fixed length. +fn large_tsconfig(target: &str) -> String { + let text = format!( + "{{\"compilerOptions\":{{\"baseUrl\":\".\",\"paths\":{{\"@lib/*\":[\"{target}\"]}}}}}}" + ); + format!("{text}{}\n", " ".repeat(64 * 1024 * 1024 + 1)) +} + +static LARGE_TSCONFIG_ELSEWHERE: LazyLock = LazyLock::new(|| large_tsconfig("src/xyz/*")); +static LARGE_TSCONFIG_LIB: LazyLock = LazyLock::new(|| large_tsconfig("src/lib/*")); + +/// W5 goal review: a control file is read whole whatever its size. A +/// `tsconfig.json` past 64 MiB, replaced with its length and modification +/// time kept, moves the alias, as a fresh index of the new text has it (the +/// digest once stood in the size and mtime for the text past 64 MiB). +pub fn typescript_tsconfig_past_64_mib_replaced_with_its_size_and_mtime_kept() -> Case { + assert_eq!(LARGE_TSCONFIG_ELSEWHERE.len(), LARGE_TSCONFIG_LIB.len()); + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file("tsconfig.json", LARGE_TSCONFIG_ELSEWHERE.as_str()) + .file( + "src/lib/math.ts", + "export function double(value: number): number {\n return value * 2;\n}\n", + ) + .file( + "src/main.ts", + "import { double } from '@lib/math';\n\nexport function run(): number {\n return double(2);\n}\n", + ) + .replace("tsconfig.json", LARGE_TSCONFIG_LIB.as_str()) + .edge_after("file:src/main.ts", "imports", "function double") +} + +/// Found by this matrix: only a fresh index detects React, mints the hook node +/// and resolves the call through it. +pub fn typescript_react_dependency_appears_in_package_json() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file( + "package.json", + "{\"name\":\"web\",\"dependencies\":{\"left-pad\":\"1.3.0\"}}\n", + ) + .file( + "src/hooks.ts", + "export function useCart(): number {\n return 1;\n}\n", + ) + .file( + "src/page.ts", + "export function page(): number {\n return useCart();\n}\n", + ) + .write( + "package.json", + "{\"name\":\"web\",\"dependencies\":{\"left-pad\":\"1.3.0\",\"react\":\"18.2.0\"}}\n", + ) + .edge_before("function page", "calls", "function useCart") + .edge_after("function page", "calls", "function src/hooks.ts::useCart") +} + +/// W5-06: a `jsconfig.json` gaining `paths` makes the alias import resolve. +pub fn javascript_jsconfig_paths_alias_appears() -> Case { + Case::new(Change::ControlFile, &[Language::JavaScript]) + .file( + "src/lib/math.js", + "export function double(value) {\n return value * 2;\n}\n", + ) + .file( + "src/main.js", + "import { double } from '@lib/math';\n\nexport function run() {\n return double(2);\n}\n", + ) + .write( + "jsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@lib/*\":[\"src/lib/*\"]}}}", + ) + .edge_after("file:src/main.js", "imports", "function double") +} + +/// W5-06: the tsconfig deleted, the alias import no longer resolves. +pub fn typescript_tsconfig_paths_alias_disappears() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file( + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@lib/*\":[\"src/lib/*\"]}}}", + ) + .file( + "src/lib/math.ts", + "export function double(value: number): number {\n return value * 2;\n}\n", + ) + .file( + "src/main.ts", + "import { double } from '@lib/math';\n\nexport function run(): number {\n return double(2);\n}\n", + ) + .edge_before("file:src/main.ts", "imports", "function double") + .remove("tsconfig.json") +} + +/// W5-06: the tsconfig `extends` a file that does not exist yet; once it is +/// created with `paths`, the alias import resolves (the missing target is a +/// recorded input). +pub fn typescript_tsconfig_extends_target_appears() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file("tsconfig.json", "{\"extends\":\"./configs/base.json\"}") + .file( + "src/lib/math.ts", + "export function double(value: number): number {\n return value * 2;\n}\n", + ) + .file( + "src/main.ts", + "import { double } from '@lib/math';\n\nexport function run(): number {\n return double(2);\n}\n", + ) + .write( + "configs/base.json", + "{\"compilerOptions\":{\"baseUrl\":\"..\",\"paths\":{\"@lib/*\":[\"src/lib/*\"]}}}", + ) + .edge_after("file:src/main.ts", "imports", "function double") +} + +/// W5-06: `paths` pointed elsewhere moves what a barrel's alias source +/// resolves to, and the import through the barrel follows. +pub fn typescript_paths_retarget_moves_a_barrel_source() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file( + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@lib/*\":[\"src/lib/*\"]}}}", + ) + .file( + "src/lib/math.ts", + "export function double(value: number): number {\n return value * 2;\n}\n", + ) + .file( + "src/alt/math.ts", + "export function double(value: number): number {\n return value + value;\n}\n", + ) + .file("src/index.ts", "export * from '@lib/math';\n") + .file( + "src/main.ts", + "import { double } from './index';\n\nexport function run(): number {\n return double(2);\n}\n", + ) + .write( + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@lib/*\":[\"src/alt/*\"]}}}", + ) + .edge_before("function run", "calls", "function double @src/lib/math.ts") + .edge_after("function run", "calls", "function double @src/alt/math.ts") +} + +/// W5-06: React leaving `package.json` takes the framework's hook node with +/// it, and the call resolves by name again. +pub fn typescript_react_dependency_leaves_package_json() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file( + "package.json", + "{\"name\":\"web\",\"dependencies\":{\"left-pad\":\"1.3.0\",\"react\":\"18.2.0\"}}\n", + ) + .file( + "src/hooks.ts", + "export function useCart(): number {\n return 1;\n}\n", + ) + .file( + "src/page.ts", + "export function page(): number {\n return useCart();\n}\n", + ) + .write( + "package.json", + "{\"name\":\"web\",\"dependencies\":{\"left-pad\":\"1.3.0\"}}\n", + ) + .edge_before("function page", "calls", "function src/hooks.ts::useCart") + .edge_after("function page", "calls", "function useCart") +} + +/// W5-06: a package the manifest comes to declare is outside the project, +/// so the import of its binding no longer reaches a project class by name. +pub fn typescript_declared_dependency_makes_a_bare_import_external() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file("package.json", "{\"name\":\"web\",\"dependencies\":{}}\n") + .file("src/button.ts", "export class Button {}\n") + .file( + "src/app.ts", + "import { Button } from 'ui-kit';\n\nexport const b = new Button();\n", + ) + .write( + "package.json", + "{\"name\":\"web\",\"dependencies\":{\"ui-kit\":\"1.0.0\"}}\n", + ) + .edge_before("constant b", "instantiates", "class Button") + .no_edge_after("constant b", "instantiates", "class Button") +} + +/// W5-06: a workspace member with only its manifest renames its package: +/// the import of the old name stops being the workspace's. +pub fn typescript_workspace_member_manifest_renames_its_package() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file( + "package.json", + "{\"name\":\"root\",\"workspaces\":[\"packages/*\"]}\n", + ) + .file("packages/ui/package.json", "{\"name\":\"@acme/ui\"}\n") + .file("src/button.ts", "export class Button {}\n") + .file( + "src/app.ts", + "import { Button } from '@acme/ui';\n\nexport const b = new Button();\n", + ) + .write("packages/ui/package.json", "{\"name\":\"@acme/kit\"}\n") + .edge_before("file:src/app.ts", "imports", "class Button") +} + +/// W5-06: a workspace member's manifest deleted: the import of its package +/// stops resolving into the member. +pub fn typescript_workspace_member_manifest_is_deleted() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file( + "package.json", + "{\"name\":\"root\",\"workspaces\":[\"packages/*\"]}\n", + ) + .file("packages/ui/package.json", "{\"name\":\"@acme/ui\"}\n") + .file("packages/ui/index.ts", "export class Button {}\n") + .file( + "src/app.ts", + "import { Button } from '@acme/ui';\n\nexport const b = new Button();\n", + ) + .edge_before("file:src/app.ts", "imports", "class Button") + .remove("packages/ui/package.json") +} + +/// W5-06: the workspace glob comes to match a new member once its manifest +/// appears: the import of its package resolves into it. +pub fn typescript_workspace_glob_gains_a_member() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file( + "package.json", + "{\"name\":\"root\",\"workspaces\":[\"packages/*\"]}\n", + ) + .file("packages/kit/index.ts", "export class Button {}\n") + .file( + "src/app.ts", + "import { Button } from '@acme/kit';\n\nexport const b = new Button();\n", + ) + .write("packages/kit/package.json", "{\"name\":\"@acme/kit\"}\n") + .edge_after("file:src/app.ts", "imports", "class Button") +} + +/// W5-06: a Tauri config appearing makes the project a Tauri one: the +/// command reference an `invoke` call site gains at extraction, parked here +/// with no command to bind, and the detected frameworks match a fresh index. +/// A marker is a JS control input, and the JS files re-resolved whole gather +/// their framework references again (Tauri makes no node), so this case +/// converges without the framework lane; the command-attribute case needs it. +pub fn tauri_config_marker_appears() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript, Language::Rust]) + .file( + "src/main.ts", + "import { invoke } from '@tauri-apps/api/core';\n\nexport function run(): Promise {\n return invoke('greet');\n}\n", + ) + .file("src-tauri/src/main.rs", "fn main() {}\n") + .write("src-tauri/tauri.conf.json", "{}\n") + .edge_throughout("file:src/main.ts", "contains", "function run") +} + +/// W5-06: a `#[tauri::command]` appearing in a Rust file makes the project +/// a Tauri one, so every file is re-extracted: the command reference the +/// `invoke` call site gains at extraction binds the command, though no +/// JavaScript file and no control file changed. +pub fn tauri_command_attribute_makes_an_invoke_call_its_edge() -> Case { + Case::new(Change::BodyEdit, &[Language::TypeScript, Language::Rust]) + .file( + "src/main.ts", + "import { invoke } from '@tauri-apps/api/core';\n\nexport function run(): Promise {\n return invoke('greet');\n}\n", + ) + .file( + "src-tauri/src/main.rs", + "pub fn greet() -> String {\n String::new()\n}\n\nfn main() {}\n", + ) + .write( + "src-tauri/src/main.rs", + "#[tauri::command] pub fn greet() -> String {\n String::new()\n}\n\nfn main() {}\n", + ) + .edge_after("file:src/main.ts", "calls", "function greet") +} + +/// W5-06, fail closed: only the manifest's `version` changes; every JS file +/// re-resolves and every edge stays. +pub fn typescript_package_version_bump_keeps_every_edge() -> Case { + Case::new(Change::ControlFile, &[Language::TypeScript]) + .file("package.json", "{\"name\":\"web\",\"version\":\"1.0.0\"}\n") + .file( + "src/lib/math.ts", + "export function double(value: number): number {\n return value * 2;\n}\n", + ) + .file( + "src/main.ts", + "import { double } from './lib/math';\n\nexport function run(): number {\n return double(2);\n}\n", + ) + .write("package.json", "{\"name\":\"web\",\"version\":\"1.0.1\"}\n") + .edge_throughout("function run", "calls", "function double") +} + +// ----- Shopify theme markers (W4-LIQ-01: upstream #2393, #2396) --------- + +/// A JSON template naming the section `main-product`. +const PRODUCT_TEMPLATE: &str = + "{\"sections\":{\"main\":{\"type\":\"main-product\"}},\"order\":[\"main\"]}"; +/// A JSON template naming the section `404`. +const NOT_FOUND_TEMPLATE: &str = + "{\"sections\":{\"main\":{\"type\":\"404\"}},\"order\":[\"main\"]}"; + +/// Upstream #2393: an unchanged JSON template becomes Liquid once its folder +/// gains a theme's settings schema, which is no source file itself. +pub fn liquid_json_template_joins_a_theme_once_its_settings_schema_appears() -> Case { + Case::new(Change::ControlFile, &[Language::Liquid]) + .file( + "sections/main-product.liquid", + "
      {{ product.title }}
      \n", + ) + .file("templates/product.json", PRODUCT_TEMPLATE) + .write("config/settings_schema.json", "[]") + .edge_after( + "file:templates/product.json", + "references", + "file:sections/main-product.liquid", + ) + .file_language_after("templates/product.json", "liquid") +} + +/// Upstream #2393: unchanged JSON templates leave the index once their +/// folder loses the theme's layout. +pub fn liquid_json_templates_leave_once_the_theme_layout_goes() -> Case { + Case::new(Change::ControlFile, &[Language::Liquid]) + .file("layout/theme.liquid", "{{ content_for_layout }}\n") + .file( + "sections/main-product.liquid", + "
      {{ product.title }}
      \n", + ) + .file("templates/product.json", PRODUCT_TEMPLATE) + .file("templates/page.json", PRODUCT_TEMPLATE) + .remove("layout/theme.liquid") + .edge_before( + "file:templates/product.json", + "references", + "file:sections/main-product.liquid", + ) + .edge_before( + "file:templates/page.json", + "references", + "file:sections/main-product.liquid", + ) +} + +/// Upstream #2393: theme folders are named in any case; an unchanged JSON +/// template in a theme's `Templates/` joins once the theme is marked. +pub fn liquid_json_template_in_a_mixed_case_folder_joins_a_marked_theme() -> Case { + Case::new(Change::ControlFile, &[Language::Liquid]) + .file( + "Shop/sections/main-product.liquid", + "
      {{ product.title }}
      \n", + ) + .file("Shop/Templates/product.json", PRODUCT_TEMPLATE) + .write("Shop/config/settings_schema.json", "[]") + .edge_after( + "file:Shop/Templates/product.json", + "references", + "file:Shop/sections/main-product.liquid", + ) + .file_language_after("Shop/Templates/product.json", "liquid") +} + +/// Upstream #2393: a marker counts though ignored, wherever it is; the +/// unchanged JSON template joins once an ignored settings schema appears. +pub fn liquid_json_template_joins_a_theme_through_an_ignored_settings_schema() -> Case { + Case::new(Change::ControlFile, &[Language::Liquid]) + .file(".gitignore", "config/\n") + .file( + "sections/main-product.liquid", + "
      {{ product.title }}
      \n", + ) + .file("templates/product.json", PRODUCT_TEMPLATE) + .write("config/settings_schema.json", "[]") + .edge_after( + "file:templates/product.json", + "references", + "file:sections/main-product.liquid", + ) + .file_language_after("templates/product.json", "liquid") +} + +/// Upstream #2393: the unchanged JSON template leaves once an ignored +/// settings schema, its theme's only marker, is removed. +pub fn liquid_json_template_leaves_once_an_ignored_settings_schema_goes() -> Case { + Case::new(Change::ControlFile, &[Language::Liquid]) + .file(".gitignore", "config/\n") + .file("config/settings_schema.json", "[]") + .file( + "sections/main-product.liquid", + "
      {{ product.title }}
      \n", + ) + .file("templates/product.json", PRODUCT_TEMPLATE) + .remove("config/settings_schema.json") + .edge_before( + "file:templates/product.json", + "references", + "file:sections/main-product.liquid", + ) +} + +/// Upstream #2396: an unchanged section's `render` stops reaching the root +/// theme's snippet once its folder is a theme of its own, which lacks it. +pub fn liquid_render_stays_in_a_folder_once_it_is_marked_a_theme() -> Case { + Case::new(Change::ControlFile, &[Language::Liquid]) + .file("layout/theme.liquid", "{{ content_for_layout }}\n") + .file("snippets/price.liquid", "{{ product.price | money }}\n") + .file("shop/sections/header.liquid", "{% render 'price' %}\n") + .write("shop/config/settings_schema.json", "[]") + .edge_before( + "file:shop/sections/header.liquid", + "references", + "file:snippets/price.liquid", + ) + .no_edge_after( + "file:shop/sections/header.liquid", + "references", + "file:snippets/price.liquid", + ) +} + +/// Upstream #2396: an unchanged section's `render` reaches the root theme's +/// snippet by its path once its folder is no theme any more. +pub fn liquid_render_leaves_a_folder_theme_once_its_marker_goes() -> Case { + Case::new(Change::ControlFile, &[Language::Liquid]) + .file("layout/theme.liquid", "{{ content_for_layout }}\n") + .file("snippets/price.liquid", "{{ product.price | money }}\n") + .file("shop/config/settings_schema.json", "[]") + .file("shop/sections/header.liquid", "{% render 'price' %}\n") + .remove("shop/config/settings_schema.json") + .edge_after( + "file:shop/sections/header.liquid", + "references", + "file:snippets/price.liquid", + ) +} + +/// Upstream #2396: a theme's JSON template whose own section is deleted +/// links no other theme's copy. +pub fn liquid_json_template_loses_its_own_themes_section() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Liquid]) + .file( + "examples/tailwind/layout/theme.liquid", + "{{ content_for_layout }}\n", + ) + .file("examples/tailwind/sections/404.liquid", "

      404

      \n") + .file("examples/tailwind/templates/404.json", NOT_FOUND_TEMPLATE) + .file("straps/dusk/config/settings_schema.json", "[]") + .file("straps/dusk/sections/404.liquid", "

      Not found

      \n") + .remove("examples/tailwind/sections/404.liquid") + .edge_before( + "file:examples/tailwind/templates/404.json", + "references", + "file:examples/tailwind/sections/404.liquid", + ) + .no_edge_after( + "file:examples/tailwind/templates/404.json", + "references", + "file:straps/dusk/sections/404.liquid", + ) +} + +/// Upstream #2396: a theme's JSON template links the section its own theme +/// gains, and no other theme's copy. +pub fn liquid_json_template_gains_its_own_themes_section() -> Case { + Case::new(Change::AddImportTarget, &[Language::Liquid]) + .file( + "examples/tailwind/layout/theme.liquid", + "{{ content_for_layout }}\n", + ) + .file("examples/tailwind/templates/404.json", NOT_FOUND_TEMPLATE) + .file("straps/dusk/config/settings_schema.json", "[]") + .file("straps/dusk/sections/404.liquid", "

      Not found

      \n") + .write("examples/tailwind/sections/404.liquid", "

      404

      \n") + .edge_after( + "file:examples/tailwind/templates/404.json", + "references", + "file:examples/tailwind/sections/404.liquid", + ) + .no_edge_after( + "file:examples/tailwind/templates/404.json", + "references", + "file:straps/dusk/sections/404.liquid", + ) +} + +// ----- a `.h` header changes from C to C++ ---------------------------------- + +/// A unit calling `Helper` (W4-PAS-01). +const PASCAL_VIEW: &str = "unit View;\ninterface\nfunction Render: Integer;\nimplementation\nfunction Render: Integer;\nbegin\n Result := Helper(10);\nend;\nend.\n"; +/// An include declaring `Helper`, of Pascal shapes. +const PASCAL_HELPER_INCLUDE: &str = + "function Helper(Amount: Integer): Integer;\nbegin\n Result := Amount;\nend;\n"; + +/// Upstream #2289: an `.inc` include whose content turns from PHP to Pascal +/// is read as Pascal, and the routine it now declares is called. +pub fn pascal_include_flips_from_php_and_its_routine_is_called() -> Case { + Case::new(Change::HeaderFlip, &[Language::Pascal, Language::Php]) + .file("View.pas", PASCAL_VIEW) + .file("routines.inc", " Case { + Case::new(Change::HeaderFlip, &[Language::Pascal, Language::Php]) + .file("View.pas", PASCAL_VIEW) + .file("routines.inc", PASCAL_HELPER_INCLUDE) + .write("routines.inc", " Case { + Case::new(Change::HeaderFlip, &[Language::C, Language::Cpp]) + .file( + "shape.h", + "struct shape {\n int sides;\n};\n\nint shape_sides(const struct shape *s);\n", + ) + .file( + "shape.c", + "#include \"shape.h\"\n\nint shape_sides(const struct shape *s) {\n return s->sides;\n}\n", + ) + .file( + "main.c", + "#include \"shape.h\"\n\nint main(void) {\n struct shape square = {4};\n return shape_sides(&square);\n}\n", + ) + .write( + "shape.h", + "class Shape {\npublic:\n int sides() const { return 4; }\n};\n\nint shape_sides(const Shape *s);\n", + ) + .edge_throughout("file:main.c", "imports", "file:shape.h") + .edge_throughout("function main", "calls", "function shape_sides @shape.c") + .edge_after("file:shape.h", "contains", "class Shape") + .file_language_after("shape.h", "cpp") +} + +// ----- a `.js` file gains Flow's `@flow` pragma ------------------------------ + +/// Upstream #2161: a `.js` file that gains Flow's `@flow` pragma is read as +/// TSX, so the class its annotations cut short (the JavaScript grammar ends +/// it at `React.Component`) has its methods, as a fresh index of the +/// edit finds. +pub fn javascript_file_gains_the_flow_pragma() -> Case { + Case::new(Change::PragmaFlip, &[Language::JavaScript, Language::Tsx]) + .file( + "js/SegmentedControl.js", + "class SegmentedControl extends React.Component {\n render(): React.Node {\n return this._renderItems();\n }\n\n _renderItems(): React.Node {\n return null;\n }\n}\n\nmodule.exports = SegmentedControl;\n", + ) + .file( + "js/index.js", + "const SegmentedControl = require('./SegmentedControl');\nmodule.exports = SegmentedControl;\n", + ) + .write( + "js/SegmentedControl.js", + "// @flow\nclass SegmentedControl extends React.Component {\n render(): React.Node {\n return this._renderItems();\n }\n\n _renderItems(): React.Node {\n return null;\n }\n}\n\nmodule.exports = SegmentedControl;\n", + ) + .edge_throughout("file:js/index.js", "imports", "file:js/SegmentedControl.js") + .edge_after( + "method SegmentedControl::render", + "calls", + "method SegmentedControl::_renderItems", + ) + .file_language_after("js/SegmentedControl.js", "tsx") +} + +// ----- Dart libraries (W4-DART-02: upstream #2386, #2388, #2391) ---------- + +/// A barrel's `show` gains a name: an unchanged importer's bare call of it +/// reaches the declaration, though no node of that name changed. +pub fn dart_export_show_gains_a_name_an_unchanged_importer_calls() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/src/impl.dart", + "int shown() => 1;\nint later() => 2;\n", + ) + .file("lib/kit.dart", "export 'src/impl.dart' show shown;\n") + .file( + "lib/main.dart", + "import 'kit.dart';\n\nint run() => later();\n", + ) + .write( + "lib/kit.dart", + "export 'src/impl.dart' show shown, later;\n", + ) + .edge_after("function run", "calls", "function later") +} + +/// The reverse: a barrel comes to `hide` a name, so the unchanged +/// importer's call leaves it. +pub fn dart_export_hide_drops_a_name_an_unchanged_importer_calls() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/src/impl.dart", + "int shown() => 1;\nint later() => 2;\n", + ) + .file("lib/kit.dart", "export 'src/impl.dart';\n") + .file( + "lib/main.dart", + "import 'kit.dart';\n\nint run() => later();\n", + ) + .write("lib/kit.dart", "export 'src/impl.dart' hide later;\n") + .edge_before("function run", "calls", "function later") + .no_edge_after("function run", "calls", "function later") +} + +/// A library comes to list a part that declares a name: an unchanged part's +/// bare call moves from the imported namesake to the library's own. +pub fn dart_part_joins_its_library_and_shadows_an_imported_name() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/a_util.dart", "int helper() => 1;\n") + .file( + "lib/feature.dart", + "import 'a_util.dart';\n\npart 'other.dart';\n", + ) + .file( + "lib/other.dart", + "part of 'feature.dart';\n\nint run() => helper();\n", + ) + .file( + "lib/extra.dart", + "part of 'feature.dart';\n\nint helper() => 2;\n", + ) + .write( + "lib/feature.dart", + "import 'a_util.dart';\n\npart 'other.dart';\npart 'extra.dart';\n", + ) + .edge_before("function run", "calls", "function helper @lib/a_util.dart") + .edge_after("function run", "calls", "function helper @lib/extra.dart") +} + +/// A library gains an import: an unchanged part's bare call reaches what it +/// brings in, though no node of that name changed. +pub fn dart_library_import_reaches_its_unchanged_part() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/util.dart", "int helper() => 1;\n") + .file("lib/lib.dart", "part 'piece.dart';\n") + .file( + "lib/piece.dart", + "part of 'lib.dart';\n\nint run() => helper();\n", + ) + .write( + "lib/lib.dart", + "import 'util.dart';\n\npart 'piece.dart';\n", + ) + .edge_after("function run", "calls", "function helper") +} + +/// The reverse: the library drops the import, so the part's call leaves it. +pub fn dart_library_import_leaves_its_unchanged_part() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/util.dart", "int helper() => 1;\n") + .file( + "lib/lib.dart", + "import 'util.dart';\n\npart 'piece.dart';\n", + ) + .file( + "lib/piece.dart", + "part of 'lib.dart';\n\nint run() => helper();\n", + ) + .write("lib/lib.dart", "part 'piece.dart';\n") + .edge_before("function run", "calls", "function helper") + .no_edge_after("function run", "calls", "function helper") +} + +/// A library's two import prefixes swap: an unchanged part's `p.Report` +/// moves to the library the prefix now names. +pub fn dart_import_prefix_swap_moves_an_unchanged_parts_type() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/a.dart", "class Report {}\n") + .file("lib/b.dart", "class Report {}\n") + .file( + "lib/lib.dart", + "import 'a.dart' as p;\nimport 'b.dart' as q;\n\npart 'piece.dart';\n", + ) + .file( + "lib/piece.dart", + "part of 'lib.dart';\n\nvoid run(p.Report r) {}\n", + ) + .write( + "lib/lib.dart", + "import 'a.dart' as q;\nimport 'b.dart' as p;\n\npart 'piece.dart';\n", + ) + .edge_before("function run", "references", "class Report @lib/a.dart") + .edge_after("function run", "references", "class Report @lib/b.dart") +} + +/// A library takes the name an unchanged part's `part of` gives: the part +/// joins it, so its bare call, which saw every file while its library was +/// unknown, reaches only what the library imports. +pub fn dart_library_name_brings_in_an_unchanged_part() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/util.dart", "int helper() => 1;\n") + .file("lib/aaa.dart", "int helper() => 2;\n") + .file( + "lib/models.dart", + "library app.old;\n\nimport 'util.dart';\n\npart 'user.dart';\n", + ) + .file( + "lib/user.dart", + "part of app.models;\n\nint run() => helper();\n", + ) + .write( + "lib/models.dart", + "library app.models;\n\nimport 'util.dart';\n\npart 'user.dart';\n", + ) + .edge_before("function run", "calls", "function helper @lib/aaa.dart") + .edge_after("function run", "calls", "function helper @lib/util.dart") +} + +/// The library an unchanged part names by URI appears: the part takes its +/// imports, so its bare call reaches the one name they bring in. +pub fn dart_library_of_an_unchanged_part_appears() -> Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/util.dart", "int helper() => 1;\n") + .file("lib/other.dart", "int helper() => 2;\n") + .file( + "lib/user.dart", + "part of 'models.dart';\n\nint run() => helper();\n", + ) + .write( + "lib/models.dart", + "import 'util.dart';\n\npart 'user.dart';\n", + ) + .edge_after("function run", "calls", "function helper @lib/util.dart") +} + +/// A second package of a name gains its first Dart file: an unchanged +/// file outside both can no longer tell which `package:example/x.dart` it +/// imports. +pub fn dart_same_named_package_gains_its_first_dart_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("packages/a/pubspec.yaml", "name: example\n") + .file("packages/a/lib/x.dart", "int fromA() => 1;\n") + .file("packages/b/pubspec.yaml", "name: example\n") + .file("tool/pubspec.yaml", "name: tool\n") + .file( + "tool/bin/run.dart", + "import 'package:example/x.dart';\n\nint run() => fromA();\n", + ) + .write("packages/b/lib/y.dart", "int fromB() => 2;\n") + .edge_before( + "file:tool/bin/run.dart", + "imports", + "file:packages/a/lib/x.dart", + ) + .no_edge_after( + "file:tool/bin/run.dart", + "imports", + "file:packages/a/lib/x.dart", + ) +} + +/// A part that declares a name is deleted: an unchanged part's bare call +/// falls back to the imported namesake. +pub fn dart_delete_a_part_that_shadowed_an_imported_name() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/a_util.dart", "int helper() => 1;\n") + .file( + "lib/feature.dart", + "import 'a_util.dart';\n\npart 'other.dart';\npart 'extra.dart';\n", + ) + .file( + "lib/other.dart", + "part of 'feature.dart';\n\nint run() => helper();\n", + ) + .file( + "lib/extra.dart", + "part of 'feature.dart';\n\nint helper() => 2;\n", + ) + .remove("lib/extra.dart") + .edge_before("function run", "calls", "function helper @lib/extra.dart") + .edge_after("function run", "calls", "function helper @lib/a_util.dart") +} + +/// A library comes to list the part an unnamed extension sits in: an +/// unchanged part's bare call reaches the extension's member, which applies +/// in every file of its library and in no other. +pub fn dart_unnamed_extension_reaches_a_part_once_its_file_joins_the_library() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/shapes.dart", "part 'use.dart';\n\nclass Shape {}\n") + .file( + "lib/area_ext.dart", + "part of 'shapes.dart';\n\nextension on Shape {\n int area() => 1;\n}\n", + ) + .file( + "lib/use.dart", + "part of 'shapes.dart';\n\nclass Square extends Shape {\n int twice() => area();\n}\n", + ) + .write( + "lib/shapes.dart", + "part 'use.dart';\npart 'area_ext.dart';\n\nclass Shape {}\n", + ) + .edge_after("method Square::twice", "calls", "method ::area") +} + +/// A package's pubspec.yaml renames it: an unchanged file's +/// `package:kit/…` import no longer names its file. +pub fn dart_pubspec_rename_strands_a_package_import() -> Case { + Case::new(Change::ControlFile, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("packages/kit/pubspec.yaml", "name: kit\n") + .file("packages/kit/lib/kit.dart", "int shown() => 1;\n") + .file( + "lib/main.dart", + "import 'package:kit/kit.dart';\n\nint run() => shown();\n", + ) + .write("packages/kit/pubspec.yaml", "name: kit2\n") + .edge_before( + "file:lib/main.dart", + "imports", + "file:packages/kit/lib/kit.dart", + ) + .no_edge_after( + "file:lib/main.dart", + "imports", + "file:packages/kit/lib/kit.dart", + ) +} + +/// An app's pubspec.yaml comes to depend on one of two same-named packages +/// by `path:`: its unchanged import now names that package's file. +pub fn dart_pubspec_path_dependency_picks_a_same_named_package() -> Case { + Case::new(Change::ControlFile, &[Language::Dart]) + .file("examples/login/pubspec.yaml", "name: login\n") + .file("examples/login/packages/auth/pubspec.yaml", "name: auth\n") + .file( + "examples/login/packages/auth/lib/auth.dart", + "class Auth {}\n", + ) + .file("examples/other/pubspec.yaml", "name: other\n") + .file("examples/other/packages/auth/pubspec.yaml", "name: auth\n") + .file( + "examples/other/packages/auth/lib/auth.dart", + "class Auth {}\n", + ) + .file( + "examples/login/lib/app.dart", + "import 'package:auth/auth.dart';\n\nclass App {}\n", + ) + .write( + "examples/login/pubspec.yaml", + "name: login\ndependencies:\n auth:\n path: packages/auth\n", + ) + .edge_after( + "file:examples/login/lib/app.dart", + "imports", + "file:examples/login/packages/auth/lib/auth.dart", + ) +} + +/// W5-06: an app's `pubspec_overrides.yaml` comes to name one of two +/// same-named packages by `path:`: its unchanged import now names that +/// package's file. +pub fn dart_pubspec_overrides_path_dependency_picks_a_same_named_package() -> Case { + Case::new(Change::ControlFile, &[Language::Dart]) + .file("examples/login/pubspec.yaml", "name: login\n") + .file("examples/login/packages/auth/pubspec.yaml", "name: auth\n") + .file( + "examples/login/packages/auth/lib/auth.dart", + "class Auth {}\n", + ) + .file("examples/other/pubspec.yaml", "name: other\n") + .file("examples/other/packages/auth/pubspec.yaml", "name: auth\n") + .file( + "examples/other/packages/auth/lib/auth.dart", + "class Auth {}\n", + ) + .file( + "examples/login/lib/app.dart", + "import 'package:auth/auth.dart';\n\nclass App {}\n", + ) + .write( + "examples/login/pubspec_overrides.yaml", + "dependency_overrides:\n auth:\n path: packages/auth\n", + ) + .edge_after( + "file:examples/login/lib/app.dart", + "imports", + "file:examples/login/packages/auth/lib/auth.dart", + ) +} + +/// W5-06: the same through a `pubspec_overrides.yaml` the repository +/// ignores, as melos writes it: the manifest is no indexed file, so only the +/// pubspec lane hears of it, and its `path:` still picks the package. +pub fn dart_ignored_pubspec_overrides_path_dependency_picks_a_same_named_package() -> Case { + Case::new(Change::ControlFile, &[Language::Dart]) + .file(".gitignore", "pubspec_overrides.yaml\n") + .file("examples/login/pubspec.yaml", "name: login\n") + .file("examples/login/packages/auth/pubspec.yaml", "name: auth\n") + .file( + "examples/login/packages/auth/lib/auth.dart", + "class Auth {}\n", + ) + .file("examples/other/pubspec.yaml", "name: other\n") + .file("examples/other/packages/auth/pubspec.yaml", "name: auth\n") + .file( + "examples/other/packages/auth/lib/auth.dart", + "class Auth {}\n", + ) + .file( + "examples/login/lib/app.dart", + "import 'package:auth/auth.dart';\n\nclass App {}\n", + ) + .write( + "examples/login/pubspec_overrides.yaml", + "dependency_overrides:\n auth:\n path: packages/auth\n", + ) + .edge_after( + "file:examples/login/lib/app.dart", + "imports", + "file:examples/login/packages/auth/lib/auth.dart", + ) +} + +/// W5-06: a `pubspec.yaml` appearing makes its folder the package it names: +/// an unchanged file's `package:kit/…` import comes to name its file. The +/// manifest is an indexed YAML file in the folder `kit`, so the parked +/// import is also retried under that key (W5-01) and this case converges +/// without the per-state manifests; the rename and deletion cases need them. +pub fn dart_pubspec_appears_and_its_folder_becomes_a_package() -> Case { + Case::new(Change::ControlFile, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("packages/kit/lib/kit.dart", "int shown() => 1;\n") + .file( + "lib/main.dart", + "import 'package:kit/kit.dart';\n\nint run() => shown();\n", + ) + .write("packages/kit/pubspec.yaml", "name: kit\n") + .edge_after( + "file:lib/main.dart", + "imports", + "file:packages/kit/lib/kit.dart", + ) +} + +/// W5-06: a package's `pubspec.yaml` deleted: its folder is no package, so +/// an unchanged file's `package:kit/…` import no longer names its file. +pub fn dart_pubspec_deleted_strands_a_package_import() -> Case { + Case::new(Change::ControlFile, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("packages/kit/pubspec.yaml", "name: kit\n") + .file("packages/kit/lib/kit.dart", "int shown() => 1;\n") + .file( + "lib/main.dart", + "import 'package:kit/kit.dart';\n\nint run() => shown();\n", + ) + .remove("packages/kit/pubspec.yaml") + .edge_before( + "file:lib/main.dart", + "imports", + "file:packages/kit/lib/kit.dart", + ) +} + +/// 501 parked imports of `main.dart` files the project lacks: more than the +/// shared import-key lookup wakes for one key. +static MAIN_IMPORTS: LazyLock = LazyLock::new(|| { + let mut text = String::new(); + for index in 0..501 { + text.push_str(&format!("import 'gen{index}/main.dart';\n")); + } + text.push_str("\nvoid noise() {}\n"); + text +}); + +/// A Dart import links the file it names once it appears, however many +/// parked imports share the file's name. No pubspec.yaml: the files' scopes +/// are unknown, so the library models of the sync's two states see the same +/// for them, and only the uncapped retry of the parked imports links them. +pub fn dart_import_of_an_added_library_past_the_import_key_ceiling() -> Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("lib/noise.dart", MAIN_IMPORTS.as_str()) + .file( + "lib/app.dart", + "import 'gen7/main.dart';\n\nvoid run() {}\n", + ) + .write("lib/gen7/main.dart", "class Main {}\n") + .edge_after("file:lib/app.dart", "imports", "file:lib/gen7/main.dart") + .edge_after("file:lib/noise.dart", "imports", "file:lib/gen7/main.dart") +} + +/// Two barrels trade a name in one edit: one stops exporting it as the +/// other starts, so its importers' calls move both ways at once (each +/// library's namespace is compared on its own). +pub fn dart_two_barrels_trade_an_exported_name() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/src/x.dart", "int foo() => 1;\nint bar() => 2;\n") + .file("lib/a.dart", "export 'src/x.dart' show foo;\n") + .file("lib/b.dart", "export 'src/x.dart' hide foo;\n") + .file( + "lib/use_a.dart", + "import 'a.dart';\n\nint runA() => foo();\n", + ) + .file( + "lib/use_b.dart", + "import 'b.dart';\n\nint runB() => foo();\n", + ) + .write("lib/a.dart", "export 'src/x.dart' hide foo;\n") + .write("lib/b.dart", "export 'src/x.dart' show foo;\n") + .edge_before("function runA", "calls", "function foo") + .no_edge_after("function runA", "calls", "function foo") + .edge_after("function runB", "calls", "function foo") +} + +/// A library's directives move down a line: their import nodes move, and +/// nothing another file resolves changes (no resolution reads a Dart import +/// node, so a moved one touches no name). +pub fn dart_library_directives_move_down_a_line() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/util.dart", "int helper() => 1;\n") + .file( + "lib/lib.dart", + "import 'util.dart';\n\npart 'piece.dart';\n", + ) + .file( + "lib/piece.dart", + "part of 'lib.dart';\n\nint run() => helper();\n", + ) + .file( + "lib/main.dart", + "import 'lib.dart';\nimport 'util.dart';\n\nint go() => helper();\n", + ) + .write( + "lib/lib.dart", + "// The library.\nimport 'util.dart';\n\npart 'piece.dart';\n", + ) + .edge_throughout("function run", "calls", "function helper") + .edge_throughout("file:lib/main.dart", "imports", "file:lib/lib.dart") +} + +/// A barrel comes to hide a base type: a subclass in another library loses +/// it, so a call on the subclass in a third file no longer reaches the +/// base's method through it (bloc's `export 'src/cubit.dart' hide Cubit` +/// moved `cubit.close()` in its tests). +pub fn dart_hidden_base_type_leaves_a_call_on_its_subclass() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/src/base.dart", "class Base {\n void close() {}\n}\n") + .file( + "lib/src/cubit.dart", + "import 'base.dart';\n\nabstract class Cubit extends Base {}\n", + ) + .file("lib/kit.dart", "export 'src/base.dart';\nexport 'src/cubit.dart';\n") + .file( + "test/seeded.dart", + "import 'package:app/kit.dart';\n\nclass Seeded extends Cubit {}\n", + ) + .file("test/other.dart", "class Other {\n void close() {}\n}\n") + .file( + "test/use.dart", + "import 'seeded.dart';\n\nvoid run() {\n final cubit = Seeded();\n cubit.close();\n}\n", + ) + .write( + "lib/kit.dart", + "export 'src/base.dart';\nexport 'src/cubit.dart' hide Cubit;\n", + ) + .edge_before("function run", "calls", "method Base::close") +} + +/// 501 parked `x.close()` calls: more than the shared tail retry wakes for +/// one tail. +static CLOSE_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("void noise() {\n"); + for index in 0..501 { + text.push_str(&format!(" missing{index}.close();\n")); + } + text.push_str("}\n"); + text +}); + +/// The reverse, past the tail ceiling: the barrel stops hiding the base +/// type, so the call on the subclass reaches the base's method through it, +/// however many parked calls share the method's name. +pub fn dart_shown_base_type_brings_its_method_to_a_call_on_its_subclass_past_the_tail_ceiling() +-> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/src/base.dart", "class Base {\n void close() {}\n}\n") + .file( + "lib/src/cubit.dart", + "import 'base.dart';\n\nabstract class Cubit extends Base {}\n", + ) + .file( + "lib/kit.dart", + "export 'src/base.dart';\nexport 'src/cubit.dart' hide Cubit;\n", + ) + .file( + "test/seeded.dart", + "import 'package:app/kit.dart';\n\nclass Seeded extends Cubit {}\n", + ) + .file( + "test/use.dart", + "import 'seeded.dart';\n\nvoid run() {\n final cubit = Seeded();\n cubit.close();\n}\n", + ) + .file("test/noise.dart", CLOSE_CALLS.as_str()) + .write("lib/kit.dart", "export 'src/base.dart';\nexport 'src/cubit.dart';\n") + .edge_after("function run", "calls", "method Base::close") +} + +/// The library a subclass sits in comes to import its base type: the +/// subclass gains the base's members, so a call on it in another file +/// reaches the base's method (the part re-resolves whole; the call's file is +/// unchanged). +pub fn dart_library_import_brings_a_base_method_to_a_call_on_its_parts_subclass() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/base.dart", "class Base {\n void close() {}\n}\n") + .file("lib/models.dart", "part 'seeded.dart';\n") + .file( + "lib/seeded.dart", + "part of 'models.dart';\n\nclass Seeded extends Base {}\n", + ) + .file( + "lib/use.dart", + "import 'models.dart';\n\nvoid run() {\n final cubit = Seeded();\n cubit.close();\n}\n", + ) + .write("lib/models.dart", "import 'base.dart';\n\npart 'seeded.dart';\n") + .edge_after("function run", "calls", "method Base::close") +} + +// ----- W4-DART-03: getter reads, extension members, static constants ------- + +/// 501 parked reads of `area` through receivers nothing types, so the shared +/// tail retry skips that tail. +static AREA_READS: LazyLock = LazyLock::new(|| { + let mut text = String::from("int noise() {\n var total = 0;\n"); + for index in 0..501 { + text.push_str(&format!(" total += missing{index}.area;\n")); + } + text.push_str(" return total;\n}\n"); + text +}); + +/// Upstream #2338 (W4-DART-03): a getter read links the getter its +/// receiver's type gains in another file; a field read before, it linked +/// nothing. +pub fn dart_getter_read_reaches_a_getter_its_type_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/box.dart", "class Box {\n final int size = 1;\n}\n") + .file( + "lib/use.dart", + "import 'box.dart';\n\nint read(Box x) => x.area;\n", + ) + .write( + "lib/box.dart", + "class Box {\n final int size = 1;\n int get area => size * size;\n}\n", + ) + .edge_after("function read", "calls", "method Box::area") +} + +/// The same past the tail ceiling: 501 parked reads share the tail `area`. +pub fn dart_getter_read_reaches_a_getter_its_type_gains_past_the_tail_ceiling() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/box.dart", "class Box {\n final int size = 1;\n}\n") + .file( + "lib/use.dart", + "import 'box.dart';\n\nint read(Box x) => x.area;\n", + ) + .file("noise/noise.dart", AREA_READS.as_str()) + .write( + "lib/box.dart", + "class Box {\n final int size = 1;\n int get area => size * size;\n}\n", + ) + .edge_after("function read", "calls", "method Box::area") +} + +/// The reverse: the getter moves to another type, and the read, through a +/// receiver of the first, links nothing: never a getter found by its name. +pub fn dart_getter_read_leaves_a_getter_its_type_drops() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/box.dart", + "class Box {\n final int size = 1;\n int get area => size * size;\n}\n\nclass Other {}\n", + ) + .file("lib/use.dart", "import 'box.dart';\n\nint read(Box x) => x.area;\n") + .write( + "lib/box.dart", + "class Box {\n final int size = 1;\n}\n\nclass Other {\n int get area => 1;\n}\n", + ) + .edge_before("function read", "calls", "method Box::area") + .no_edge_after("function read", "calls", "method Other::area") +} + +/// A field of the base class, declared in another file, types the read in +/// the subclass (upstream #2171's walk through the hierarchy): retyped there, +/// the read moves, though nothing in the subclass's file changed. +pub fn dart_getter_read_through_an_inherited_field_follows_its_retyping() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/shapes.dart", + "class Box {\n int get area => 1;\n}\n\nclass Panel {\n int get area => 2;\n}\n", + ) + .file( + "lib/base.dart", + "import 'shapes.dart';\n\nclass Base {\n Base(this.shape);\n final Box shape;\n}\n", + ) + .file( + "lib/sub.dart", + "import 'base.dart';\n\nclass Sub extends Base {\n Sub(super.shape);\n\n int measure() => shape.area;\n}\n", + ) + .write( + "lib/base.dart", + "import 'shapes.dart';\n\nclass Base {\n Base(this.shape);\n final Panel shape;\n}\n", + ) + .edge_before("method Sub::measure", "calls", "method Box::area") + .edge_after("method Sub::measure", "calls", "method Panel::area") +} + +/// The receiver's type, declared in another file, comes to extend another +/// base: the read moves to that base's getter. +pub fn dart_getter_read_follows_its_receiver_types_new_base() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/shapes.dart", + "class Box {\n int get area => 1;\n}\n\nclass Panel {\n int get area => 2;\n}\n", + ) + .file( + "lib/square.dart", + "import 'shapes.dart';\n\nclass Square extends Box {}\n", + ) + .file( + "lib/use.dart", + "import 'square.dart';\n\nint read(Square s) => s.area;\n", + ) + .write( + "lib/square.dart", + "import 'shapes.dart';\n\nclass Square extends Panel {}\n", + ) + .edge_before("function read", "calls", "method Box::area") + .edge_after("function read", "calls", "method Panel::area") +} + +/// 501 parked calls of `shout` through receivers nothing types. +static SHOUT_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("void noise() {\n"); + for index in 0..501 { + text.push_str(&format!(" missing{index}.shout();\n")); + } + text.push_str("}\n"); + text +}); + +/// Upstream #2338: an extension on an enum, in a file added later, brings +/// the member a call on a receiver of the enum's type makes. +pub fn dart_call_on_an_enum_reaches_a_member_an_added_extension_brings() -> Case { + Case::new(Change::AddCompetitor, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/shape.dart", "enum Shape { circle }\n") + .file( + "lib/use.dart", + "import 'shape.dart';\n\nString loud(Shape s) => s.shout();\n", + ) + .write( + "lib/info.dart", + "import 'shape.dart';\n\nextension ShapeInfo on Shape {\n String shout() => name;\n}\n", + ) + .edge_after("function loud", "calls", "method ShapeInfo::shout") +} + +/// A class declares a named constructor `Foo.value`; an extension in a file +/// added later brings an instance `value()`. A call on a parameter of the +/// type and the last link of a chain typed to it reach the extension's member, +/// never the constructor (KEEP-RUST, W4d end). +pub fn dart_value_call_named_like_a_constructor_reaches_an_added_extension_member() -> Case { + Case::new(Change::AddCompetitor, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/foo.dart", + "class Foo {\n const Foo.value(this.v);\n\n final int v;\n}\n\nFoo build() => const Foo.value(1);\n", + ) + .file( + "lib/use.dart", + "import 'foo.dart';\n\nint viaParameter(Foo x) => x.value();\n\nint viaChain() => build().value();\n", + ) + .write( + "lib/ext.dart", + "import 'foo.dart';\n\nextension FooX on Foo {\n int value() => v;\n}\n", + ) + .edge_after("function viaParameter", "calls", "method FooX::value") + .edge_after("function viaChain", "calls", "method FooX::value") +} + +/// The extension that brought `value()` to a class with a named constructor +/// `Foo.value` goes away: calls through values of the type, receivers named +/// `foo` and `fooValue` included, link nothing rather than fall back to the +/// constructor (KEEP-RUST, W4d end). +pub fn dart_value_call_named_like_a_constructor_leaves_a_removed_extension_member() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/foo.dart", + "class Foo {\n const Foo.value(this.v);\n\n final int v;\n}\n", + ) + .file( + "lib/ext.dart", + "import 'foo.dart';\n\nextension FooX on Foo {\n int value() => v;\n}\n", + ) + .file( + "lib/use.dart", + "import 'foo.dart';\nimport 'ext.dart';\n\nint viaNamed(Foo foo) => foo.value();\n\nint viaWord(Foo fooValue) => fooValue.value();\n", + ) + .remove("lib/ext.dart") + .edge_before("function viaNamed", "calls", "method FooX::value") + .edge_before("function viaWord", "calls", "method FooX::value") + .no_edge_after("function viaNamed", "calls", "method Foo::value") + .no_edge_after("function viaWord", "calls", "method Foo::value") +} + +/// The same past the tail ceiling: 501 parked calls share the tail `shout`. +pub fn dart_call_on_an_enum_reaches_a_member_an_added_extension_brings_past_the_tail_ceiling() +-> Case { + Case::new(Change::AddCompetitor, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/shape.dart", "enum Shape { circle }\n") + .file( + "lib/use.dart", + "import 'shape.dart';\n\nString loud(Shape s) => s.shout();\n", + ) + .file("noise/noise.dart", SHOUT_CALLS.as_str()) + .write( + "lib/info.dart", + "import 'shape.dart';\n\nextension ShapeInfo on Shape {\n String shout() => name;\n}\n", + ) + .edge_after("function loud", "calls", "method ShapeInfo::shout") +} + +/// A getter an extension in a file added later brings to the receiver's +/// type, past the tail ceiling. +pub fn dart_getter_read_reaches_a_getter_an_added_extension_brings_past_the_tail_ceiling() -> Case { + Case::new(Change::AddCompetitor, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/box.dart", "class Box {\n final int size = 1;\n}\n") + .file( + "lib/use.dart", + "import 'box.dart';\n\nint read(Box x) => x.area;\n", + ) + .file("noise/noise.dart", AREA_READS.as_str()) + .write( + "lib/box_x.dart", + "import 'box.dart';\n\nextension BoxX on Box {\n int get area => size * size;\n}\n", + ) + .edge_after("function read", "calls", "method BoxX::area") +} + +/// Upstream #2367: a call through a type's name links the static constant the +/// type gains, not another type's same-named method. +pub fn dart_call_through_a_type_reaches_the_static_constant_it_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/builders.dart", + "class Builder {\n const Builder();\n Object call(Object create) => create;\n}\n\nclass Other {\n Object autoDispose(Object create) => create;\n}\n", + ) + .file("lib/provider.dart", "import 'builders.dart';\n\nclass Provider {}\n") + .file( + "lib/use.dart", + "import 'provider.dart';\n\nfinal p = Provider.autoDispose(1);\n", + ) + .write( + "lib/provider.dart", + "import 'builders.dart';\n\nclass Provider {\n static const autoDispose = Builder();\n}\n", + ) + .edge_after("constant p", "calls", "constant Provider::autoDispose") +} + +/// A namesake appears for a call a parameter covers (upstream #2379): the +/// parked call stays unlinked, while the same call outside the parameter's +/// function reaches it. +pub fn dart_parameter_call_links_nothing_when_a_namesake_appears() -> Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/run.dart", + "import 'create.dart';\n\nvoid run(void Function() create) {\n create();\n}\n\nvoid later() {\n create();\n}\n", + ) + .write("lib/create.dart", "void create() {}\n") + .edge_after("function later", "calls", "function create") + .no_edge_after("function run", "calls", "function create") +} + +/// The same for a bloc handler's `emit` parameter, the method it would +/// otherwise reach arriving in its base class's file. +pub fn dart_handler_parameter_call_links_nothing_when_its_base_gains_the_method() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/bloc.dart", "class Bloc {\n void on(void Function(Object event, Object emit) handler) {}\n}\n") + .file( + "lib/counter.dart", + "import 'bloc.dart';\n\nclass CounterBloc extends Bloc {\n void onReset(String event, Object emit) {\n emit(0);\n }\n\n void bump() => emit(2);\n}\n", + ) + .write( + "lib/bloc.dart", + "class Bloc {\n void on(void Function(Object event, Object emit) handler) {}\n\n void emit(S state) {}\n}\n", + ) + .edge_after("method CounterBloc::bump", "calls", "method Bloc::emit") + .no_edge_after("method CounterBloc::onReset", "calls", "method Bloc::emit") +} + +/// A local function is nearer than a namesake an imported file comes to +/// declare, and the parameter it shadows outside its block covers the call +/// there (upstream #2379; W4-DART-03c, a W4-DART-01 regression). +pub fn dart_local_function_call_keeps_its_local_when_a_namesake_appears() -> Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/wire.dart", + "import 'make.dart';\n\nint outer(int Function() make) {\n int helper() {\n int make() => 2;\n return make();\n }\n\n return helper() + make();\n}\n", + ) + .write("lib/make.dart", "int make() => 1;\n") + .edge_throughout("function outer::helper", "calls", "function outer::make") + .edge_throughout("function outer", "calls", "function outer::helper") + .no_edge_after("function outer", "calls", "function make") +} + +/// A type gains the named `const` constructor a call through it names +/// (upstream #2380): the call reaches it, never another type's member of +/// that name. +pub fn dart_call_through_a_type_reaches_a_const_constructor_it_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/provider.dart", "class BlocProvider {\n const BlocProvider();\n}\n") + .file("lib/other.dart", "class RepositoryProvider {\n RepositoryProvider.value();\n}\n") + .file( + "lib/use.dart", + "import 'provider.dart';\nimport 'other.dart';\n\nfinal p = BlocProvider.value();\nfinal q = BlocProvider.value();\n", + ) + .write( + "lib/provider.dart", + "class BlocProvider {\n const BlocProvider();\n\n const BlocProvider.value();\n}\n", + ) + .edge_after("constant p", "calls", "method BlocProvider::value") + .edge_after("constant q", "calls", "method BlocProvider::value") + .no_edge_after("constant p", "calls", "method RepositoryProvider::value") +} + +/// A static method turns into a named constructor on the same line, so its +/// node keeps its id: a bare call in a subclass leaves it, as a constructor +/// is reached only through its class (upstream #2380). +pub fn dart_bare_call_leaves_a_method_that_becomes_a_constructor() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/base.dart", + "class Base {\n static Base value() => Base();\n}\n", + ) + .file( + "lib/sub.dart", + "import 'base.dart';\n\nclass Sub extends Base {\n Object m() => value();\n}\n", + ) + .write("lib/base.dart", "class Base {\n const Base.value();\n}\n") + .edge_before("method Sub::m", "calls", "method Base::value") + .no_edge_after("method Sub::m", "calls", "method Base::value") +} + +/// The reverse: a named constructor turns into a static method on its line, +/// and the bare call in the subclass reaches it. +pub fn dart_bare_call_reaches_a_constructor_that_becomes_a_method() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/base.dart", "class Base {\n const Base.value();\n}\n") + .file( + "lib/sub.dart", + "import 'base.dart';\n\nclass Sub extends Base {\n Object m() => value();\n}\n", + ) + .write( + "lib/base.dart", + "class Base {\n static Base value() => Base();\n}\n", + ) + .edge_after("method Sub::m", "calls", "method Base::value") +} + +/// A factory comes to sit below an instance getter of its name: a call +/// through the type reaches the factory, not the getter (upstream #2380). +pub fn dart_call_through_a_type_reaches_a_factory_added_below_a_getter_of_its_name() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/async_value.dart", + "class AsyncValue {\n const AsyncValue._();\n\n Object? get error => null;\n}\n", + ) + .file( + "lib/use.dart", + "import 'async_value.dart';\n\nObject fail(Object e) => AsyncValue.error(e);\n", + ) + .write( + "lib/async_value.dart", + "class AsyncValue {\n const AsyncValue._();\n\n Object? get error => null;\n\n const factory AsyncValue.error(Object error) = _Error;\n}\n\nclass _Error extends AsyncValue {\n const _Error(this.error) : super._();\n\n @override\n final Object error;\n}\n", + ) + .edge_after( + "function fail", + "calls", + "method AsyncValue::error @lib/async_value.dart:6", + ) + .no_edge_after( + "function fail", + "calls", + "method AsyncValue::error @lib/async_value.dart:4", + ) +} + +/// A call through an import prefix reaches a name the prefixed package's +/// barrel starts to show (upstream #2389): the caller's file is unchanged. +pub fn dart_prefixed_call_reaches_a_name_its_barrel_starts_to_show() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("packages/kit/pubspec.yaml", "name: kit\n") + .file( + "packages/kit/lib/kit.dart", + "export 'src/impl.dart' show shown;\n", + ) + .file( + "packages/kit/lib/src/impl.dart", + "int shown() => 1;\nint notShown() => 2;\n", + ) + .file( + "lib/app.dart", + "import 'package:kit/kit.dart' as kit;\n\nint run() => kit.notShown() + kit.shown();\n", + ) + .write( + "packages/kit/lib/kit.dart", + "export 'src/impl.dart' show shown, notShown;\n", + ) + .edge_throughout("function run", "calls", "function shown") + .edge_after("function run", "calls", "function notShown") +} + +/// The reverse: the barrel stops showing the name, and the call links +/// nothing. +pub fn dart_prefixed_call_leaves_a_name_its_barrel_stops_showing() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("packages/kit/pubspec.yaml", "name: kit\n") + .file( + "packages/kit/lib/kit.dart", + "export 'src/impl.dart' show shown, notShown;\n", + ) + .file( + "packages/kit/lib/src/impl.dart", + "int shown() => 1;\nint notShown() => 2;\n", + ) + .file( + "lib/app.dart", + "import 'package:kit/kit.dart' as kit;\n\nint run() => kit.notShown() + kit.shown();\n", + ) + .write( + "packages/kit/lib/kit.dart", + "export 'src/impl.dart' show shown;\n", + ) + .edge_before("function run", "calls", "function notShown") + .no_edge_after("function run", "calls", "function notShown") +} + +/// A part takes its library's import prefixes: the library gains the import, +/// and the unchanged part's prefixed call reaches what it brings in. +pub fn dart_prefixed_call_in_a_part_follows_a_prefix_its_library_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/format.dart", "String describe(Object value) => '';\n") + .file("lib/app.dart", "part 'app_part.dart';\n") + .file( + "lib/app_part.dart", + "part of 'app.dart';\n\nString again(Object value) => fmt.describe(value);\n", + ) + .write( + "lib/app.dart", + "import 'format.dart' as fmt;\n\npart 'app_part.dart';\n", + ) + .edge_after("function again", "calls", "function describe") +} + +/// A chain through an import prefix is on the type the prefix brings in: it +/// reaches the static member that type gains, never a namesake's (upstream +/// #2389). +pub fn dart_chain_through_a_prefix_reaches_the_member_its_type_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("packages/kit/pubspec.yaml", "name: kit\n") + .file("packages/kit/lib/kit.dart", "export 'src/impl.dart';\n") + .file( + "packages/kit/lib/src/impl.dart", + "class Box {\n const Box();\n}\n", + ) + .file( + "lib/legacy.dart", + "class Box {\n static Box create() => Box();\n}\n", + ) + .file( + "lib/app.dart", + "import 'package:kit/kit.dart' as kit;\nimport 'legacy.dart';\n\nObject run() => kit.Box.create();\n", + ) + .write( + "packages/kit/lib/src/impl.dart", + "class Box {\n const Box();\n\n static Box create() => const Box();\n}\n", + ) + .edge_after( + "function run", + "calls", + "method Box::create @packages/kit/lib/src/impl.dart", + ) + .no_edge_after("function run", "calls", "method Box::create @lib/legacy.dart") +} + +/// An annotation is a constant: it reaches the one its imported library +/// gains, never a getter of the name (upstream #2390). +pub fn dart_annotation_reaches_a_constant_its_imported_library_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/annotation.dart", + "class Riverpod {\n const Riverpod();\n}\n", + ) + .file( + "lib/nodes.dart", + "extension Annotated on Object {\n Object? get riverpod => null;\n}\n", + ) + .file( + "lib/app.dart", + "import 'annotation.dart';\nimport 'nodes.dart';\n\n@riverpod\nint counter() => 0;\n", + ) + .write( + "lib/annotation.dart", + "class Riverpod {\n const Riverpod();\n}\n\nconst riverpod = Riverpod();\n", + ) + .edge_after("function counter", "decorates", "constant riverpod") + .no_edge_after( + "function counter", + "decorates", + "method Annotated::riverpod", + ) +} + +/// 501 parked calls of `describe` through types the project lacks. +static DESCRIBE_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::new(); + for index in 0..501 { + text.push_str(&format!( + "final noise{index} = Missing{index}.describe(1);\n" + )); + } + text +}); + +/// A call through an import prefix reaches the function its prefixed library +/// gains, past the tail ceiling: 501 parked calls share the tail `describe` +/// (upstream #2389). +pub fn dart_prefixed_call_reaches_a_function_its_library_gains_past_the_tail_ceiling() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/format.dart", "int unrelated() => 0;\n") + .file( + "lib/app.dart", + "import 'format.dart' as fmt;\n\nString run(Object value) => fmt.describe(value);\n", + ) + .file("noise/noise.dart", DESCRIBE_CALLS.as_str()) + .write( + "lib/format.dart", + "int unrelated() => 0;\n\nString describe(Object value) => '';\n", + ) + .edge_after("function run", "calls", "function describe") +} + +/// 501 parked calls of `notShown` through types the project lacks. +static NOT_SHOWN_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::new(); + for index in 0..501 { + text.push_str(&format!( + "final noise{index} = Missing{index}.notShown();\n" + )); + } + text +}); + +/// The same for a name the prefixed package's barrel starts to show, past +/// the tail ceiling: no declaration of the name comes or goes. +pub fn dart_prefixed_call_reaches_a_name_its_barrel_starts_to_show_past_the_tail_ceiling() -> Case { + Case::new(Change::ReexportEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("packages/kit/pubspec.yaml", "name: kit\n") + .file( + "packages/kit/lib/kit.dart", + "export 'src/impl.dart' show shown;\n", + ) + .file( + "packages/kit/lib/src/impl.dart", + "int shown() => 1;\nint notShown() => 2;\n", + ) + .file( + "lib/app.dart", + "import 'package:kit/kit.dart' as kit;\n\nint run() => kit.notShown();\n", + ) + .file("noise/noise.dart", NOT_SHOWN_CALLS.as_str()) + .write( + "packages/kit/lib/kit.dart", + "export 'src/impl.dart' show shown, notShown;\n", + ) + .edge_after("function run", "calls", "function notShown") +} + +/// 501 parked calls of `autoDispose` through types the project lacks. +static AUTO_DISPOSE_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::new(); + for index in 0..501 { + text.push_str(&format!( + "final noise{index} = Missing{index}.autoDispose(1);\n" + )); + } + text +}); + +/// The same past the tail ceiling, no method of the name anywhere: 501 +/// parked calls share the tail `autoDispose`. +pub fn dart_call_through_a_type_reaches_the_static_constant_it_gains_past_the_tail_ceiling() -> Case +{ + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/builders.dart", + "class Builder {\n const Builder();\n Object call(Object create) => create;\n}\n", + ) + .file("lib/provider.dart", "import 'builders.dart';\n\nclass Provider {}\n") + .file( + "lib/use.dart", + "import 'provider.dart';\n\nfinal p = Provider.autoDispose(1);\n", + ) + .file("noise/noise.dart", AUTO_DISPOSE_CALLS.as_str()) + .write( + "lib/provider.dart", + "import 'builders.dart';\n\nclass Provider {\n static const autoDispose = Builder();\n}\n", + ) + .edge_after("constant p", "calls", "constant Provider::autoDispose") +} + +/// Upstream #2367: an initializer's calls are its constant's, a field's its +/// class's; the callee, added in another file later, links. +pub fn dart_initializer_calls_reach_a_function_added_in_another_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/use.dart", + "import 'data.dart';\n\nfinal dioProvider = Provider((ref) => createDio());\n\nclass Service {\n final dio = createDio();\n}\n\nclass Provider {\n Provider(Object create);\n}\n", + ) + .write("lib/data.dart", "Object createDio() => Object();\n") + .edge_after("constant dioProvider", "calls", "function createDio") + .edge_after("class Service", "calls", "function createDio") +} + +/// Upstream #2327: a field's type is its class's reference, and a type an +/// extension is `on` the extension's; the type, added later, links. +pub fn dart_field_and_extension_types_reach_a_type_added_in_another_file() -> Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/holder.dart", + "import 'report.dart';\n\nclass Holder {\n const Holder(this.report);\n final Report report;\n}\n\nextension ReportX on Report {\n bool get high => true;\n}\n", + ) + .write("lib/report.dart", "class Report {}\n") + .edge_after("class Holder", "references", "class Report") + .edge_after("class ReportX", "references", "class Report") +} + +/// The receiver's type, declared in another file, goes: the read through it +/// links nothing, though no name of the read's file changed. +pub fn dart_getter_read_through_a_deleted_type_links_nothing() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/shapes.dart", "class Box {\n int get area => 1;\n}\n") + .file( + "lib/square.dart", + "import 'shapes.dart';\n\nclass Square extends Box {}\n", + ) + .file( + "lib/use.dart", + "import 'square.dart';\n\nint read(Square s) => s.area;\n", + ) + .remove("lib/square.dart") + .edge_before("function read", "calls", "method Box::area") + .no_edge_after("function read", "calls", "method Box::area") +} + +/// A type the read's file already names comes to be declared, extending a +/// type with the getter: the read links it. The read's file imports nothing +/// (a receiver's type is read from its declaration, whatever the file +/// imports), so no directive of it moves. +pub fn dart_getter_read_through_a_type_that_appears_reaches_its_base() -> Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/shapes.dart", "class Box {\n int get area => 1;\n}\n") + .file("lib/use.dart", "int read(Square s) => s.area;\n") + .write( + "lib/square.dart", + "import 'shapes.dart';\n\nclass Square extends Box {}\n", + ) + .edge_after("function read", "calls", "method Box::area") +} + +/// 501 parked calls of `make` through types the project lacks. +static MAKE_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::new(); + for index in 0..501 { + text.push_str(&format!("final noise{index} = Missing{index}.make();\n")); + } + text +}); + +/// A type that appears with a static member a constant calls through it, +/// past the tail ceiling: the call names the type only as its receiver (the +/// calling file imports nothing, so no directive of it moves). +pub fn dart_call_through_a_type_that_appears_reaches_its_member_past_the_tail_ceiling() -> Case { + Case::new(Change::AddImportTarget, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/use.dart", "final made = Square.make();\n") + .file("noise/noise.dart", MAKE_CALLS.as_str()) + .write( + "lib/square.dart", + "class Square {\n static Square make() => Square();\n}\n", + ) + .edge_after("constant made", "calls", "method Square::make") +} + +/// A field of a base class types a read in a subclass by a type that comes +/// to extend another base: the subclass's file names neither type, yet its +/// read moves. +pub fn dart_getter_read_through_an_inherited_field_follows_its_types_new_base() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/shapes.dart", + "class Box {\n int get area => 1;\n}\n\nclass Panel {\n int get area => 2;\n}\n", + ) + .file( + "lib/square.dart", + "import 'shapes.dart';\n\nclass Square extends Box {}\n", + ) + .file( + "lib/base.dart", + "import 'square.dart';\n\nclass Base {\n Base(this.shape);\n final Square shape;\n}\n", + ) + .file( + "lib/sub.dart", + "import 'base.dart';\n\nclass Sub extends Base {\n Sub(super.shape);\n\n int measure() => shape.area;\n}\n", + ) + .write( + "lib/square.dart", + "import 'shapes.dart';\n\nclass Square extends Panel {}\n", + ) + .edge_before("method Sub::measure", "calls", "method Box::area") + .edge_after("method Sub::measure", "calls", "method Panel::area") +} + +/// The same, the read's file declaring the base it leaves: a same-file edge +/// moves too. +pub fn dart_getter_read_leaves_a_base_of_its_own_file_its_receivers_type_stops_extending() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/shapes.dart", "class Box {\n int get area => 1;\n}\n") + .file( + "lib/square.dart", + "import 'use.dart';\n\nclass Square extends Panel {}\n", + ) + .file( + "lib/use.dart", + "import 'square.dart';\n\nclass Panel {\n int get area => 2;\n}\n\nint read(Square s) => s.area;\n", + ) + .write( + "lib/square.dart", + "import 'shapes.dart';\n\nclass Square extends Box {}\n", + ) + .edge_before("function read", "calls", "method Panel::area") + .edge_after("function read", "calls", "method Box::area") +} + +/// A class comes to extend another base, which declares the field a +/// subclass reads through with another type: the read moves, though no type +/// the subclass's file names changed (the member lane, the head rule). +pub fn dart_getter_read_through_a_field_its_class_inherits_from_a_new_base() -> Case { + Case::new(Change::EditHeritage, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/shapes.dart", + "class Box {\n int get area => 1;\n}\n\nclass Panel {\n int get area => 2;\n}\n", + ) + .file( + "lib/holders.dart", + "import 'shapes.dart';\n\nclass BoxHolder {\n BoxHolder(this.shape);\n final Box shape;\n}\n\nclass PanelHolder {\n PanelHolder(this.shape);\n final Panel shape;\n}\n", + ) + .file( + "lib/base.dart", + "import 'holders.dart';\n\nclass Base extends BoxHolder {\n Base(super.shape);\n}\n", + ) + .file( + "lib/sub.dart", + "import 'base.dart';\n\nclass Sub extends Base {\n Sub(super.shape);\n\n int measure() => shape.area;\n}\n", + ) + .write( + "lib/base.dart", + "import 'holders.dart';\n\nclass Base extends PanelHolder {\n Base(super.shape);\n}\n", + ) + .edge_before("method Sub::measure", "calls", "method Box::area") + .edge_after("method Sub::measure", "calls", "method Panel::area") +} + +/// An extension that already sits `on` the receiver's type gains the getter, +/// past the tail ceiling: no head moved, so only the new member's name says +/// which parked reads to retry. +pub fn dart_getter_read_reaches_a_getter_an_existing_extension_gains_past_the_tail_ceiling() -> Case +{ + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/box.dart", "class Box {\n final int size = 1;\n}\n") + .file( + "lib/box_x.dart", + "import 'box.dart';\n\nextension BoxX on Box {\n int twice() => size * 2;\n}\n", + ) + .file("lib/use.dart", "import 'box.dart';\n\nint read(Box x) => x.area;\n") + .file("noise/noise.dart", AREA_READS.as_str()) + .write( + "lib/box_x.dart", + "import 'box.dart';\n\nextension BoxX on Box {\n int twice() => size * 2;\n int get area => size * size;\n}\n", + ) + .edge_after("function read", "calls", "method BoxX::area") +} + +static WRAP_CALLS: LazyLock = LazyLock::new(|| { + let mut text = String::from("void noise() {\n"); + for index in 0..501 { + text.push_str(&format!(" missing{index}.wrap();\n")); + } + text.push_str("}\n"); + text +}); + +/// Upstream #2376 (W4-DART-03b): a later link of a chain calls a member of +/// what the chain before it evaluates to, typed by each link's declared +/// return type: retyped in another file, the link moves, though its own +/// name's declarations did not change. +pub fn dart_chain_link_follows_a_return_type_retyped_in_another_file() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/repo.dart", + "import 'items.dart';\n\nclass Repo {\n Item load() => Item();\n}\n", + ) + .file( + "lib/use.dart", + "import 'repo.dart';\n\nvoid run(Repo r) {\n r.load().save();\n}\n", + ) + .write( + "lib/repo.dart", + "import 'items.dart';\n\nclass Repo {\n Other load() => Other();\n}\n", + ) + .edge_before("function run", "calls", "method Item::save") + .edge_after("function run", "calls", "method Other::save") +} + +/// A chain's head call types it by the function's declared return type. +pub fn dart_chain_link_follows_a_top_level_functions_return_type() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/make.dart", + "import 'items.dart';\n\nItem make() => Item();\n", + ) + .file( + "lib/use.dart", + "import 'make.dart';\n\nvoid run() {\n make().touch().save();\n}\n", + ) + .write( + "lib/make.dart", + "import 'items.dart';\n\nOther make() => Other();\n", + ) + .edge_before("function run", "calls", "method Item::save") + .edge_after("function run", "calls", "method Other::save") +} + +/// `FutureProvider.autoDispose.family()` is the `family` of the builder the +/// type's static constant holds: another builder there, and the link and the +/// one after it move. +pub fn dart_chain_link_follows_a_static_constants_initializer() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/builders.dart", + "import 'items.dart';\n\nclass FBuilder {\n const FBuilder();\n Item family() => Item();\n}\n\nclass SBuilder {\n const SBuilder();\n Other family() => Other();\n}\n", + ) + .file( + "lib/providers.dart", + "import 'builders.dart';\n\nclass FutureProvider {\n static const autoDispose = FBuilder();\n}\n", + ) + .file( + "lib/use.dart", + "import 'providers.dart';\n\nvoid run() {\n FutureProvider.autoDispose.family().save();\n}\n", + ) + .write( + "lib/providers.dart", + "import 'builders.dart';\n\nclass FutureProvider {\n static const autoDispose = SBuilder();\n}\n", + ) + .edge_before("function run", "calls", "method FBuilder::family") + .edge_after("function run", "calls", "method SBuilder::family") + .edge_before("function run", "calls", "method Item::save") + .edge_after("function run", "calls", "method Other::save") +} + +/// A top-level constant heading a chain is typed by its initializer, read +/// back from where it ends: an edit past what its node records of it (the +/// first hundred characters) still moves the chain (W4-DART-03b's top-level +/// lines). +pub fn dart_chain_link_follows_the_end_of_a_long_top_level_initializer() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/maker.dart", + "import 'items.dart';\n\nclass Maker {\n static Maker make(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j, int k, int l, int m, int n, int o, int p, int q, int r, int s, int t, int u, int v, int w, int x, int y, int z, int aa, int bb, int cc, int dd, int ee, int ff, int gg) => Maker();\n Item item() => Item();\n Other other() => Other();\n}\n", + ) + .file( + "lib/consts.dart", + "import 'maker.dart';\n\nfinal thing = Maker.make(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33).item();\n", + ) + .file( + "lib/use.dart", + "import 'consts.dart';\n\nvoid run() {\n thing.touch().save();\n}\n", + ) + .write( + "lib/consts.dart", + "import 'maker.dart';\n\nfinal thing = Maker.make(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33).other();\n", + ) + .edge_before("function run", "calls", "method Item::save") + .edge_after("function run", "calls", "method Other::save") +} + +/// An extension on a type parameter of its own (`extension X on T`) +/// reaches every project type: added in another file, its member and the +/// link after it come into reach. +pub fn dart_chain_link_after_a_universal_extension_it_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/ext.dart", + "import 'items.dart';\n", + ) + .file( + "lib/use.dart", + "import 'items.dart';\nimport 'ext.dart';\n\nvoid run(Item i) {\n i.touch().wrap().save();\n}\n", + ) + .write( + "lib/ext.dart", + "import 'items.dart';\n\nextension Wrap on T {\n Other wrap() => Other();\n}\n", + ) + .edge_after("function run", "calls", "method Wrap::wrap") + .edge_after("function run", "calls", "method Other::save") +} + +/// No call runs a setter: a method turned into one, its signature unchanged, +/// leaves the chain's link (W4-DART-03b's member statements). +pub fn dart_chain_link_leaves_a_method_that_becomes_a_setter() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/holder.dart", + "class Holder {\n void value(int v) {}\n}\n\nclass Box {\n Holder wrap() => Holder();\n}\n", + ) + .file( + "lib/use.dart", + "import 'holder.dart';\n\nvoid run(Box b) {\n b.wrap().value(1);\n}\n", + ) + .write( + "lib/holder.dart", + "class Holder {\n set value(int v) {}\n}\n\nclass Box {\n Holder wrap() => Holder();\n}\n", + ) + .edge_before("function run", "calls", "method Holder::value") +} + +/// `T read()` called as `read()` is a Repo: the method's own type +/// parameters, renamed, no longer give it the call's type argument. +pub fn dart_chain_link_follows_a_methods_own_type_parameters() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/ref.dart", + "class Ref {\n T read(Object p) => throw 0;\n}\n", + ) + .file( + "lib/repo.dart", + "import 'items.dart';\n\nclass Repo {\n Item load() => Item();\n}\n", + ) + .file( + "lib/use.dart", + "import 'ref.dart';\nimport 'repo.dart';\n\nvoid run(Ref ref) {\n ref.read(1).load().save();\n}\n", + ) + .write( + "lib/ref.dart", + "class Ref {\n T read(Object p) => throw 0;\n}\n", + ) + .edge_before("function run", "calls", "method Repo::load") + .edge_before("function run", "calls", "method Item::save") +} + +/// A chain through fields of what a call returns (`make().box.item.save()`) +/// reads each field's declared type from its class: retyped there, the +/// links after it move, though the calling file writes no class. +pub fn dart_chain_link_through_fields_of_a_call_result_follows_a_retyping() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/boxes.dart", + "import 'items.dart';\n\nclass Box {\n final Item item = Item();\n}\n\nclass Shelf {\n final Other item = Other();\n}\n", + ) + .file( + "lib/crate.dart", + "import 'boxes.dart';\n\nclass Crate {\n final Box box = Box();\n}\n\nCrate make() => Crate();\n", + ) + .file( + "lib/use.dart", + "import 'crate.dart';\n\nvoid run() {\n make().box.item.save();\n}\n", + ) + .write( + "lib/crate.dart", + "import 'boxes.dart';\n\nclass Crate {\n final Shelf box = Shelf();\n}\n\nCrate make() => Crate();\n", + ) + .edge_before("function run", "calls", "method Item::save") + .edge_after("function run", "calls", "method Other::save") +} + +/// A local heading a chain is typed by its initializer (`final item = +/// r.load();`): the initializer's call retyped in another file moves the +/// chain written after it. +pub fn dart_chain_link_through_a_local_follows_its_initializers_return_type() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/repo.dart", + "import 'items.dart';\n\nclass Repo {\n Item load() => Item();\n}\n", + ) + .file( + "lib/use.dart", + "import 'repo.dart';\n\nvoid run(Repo r) {\n final item = r.load();\n item.touch().save();\n}\n", + ) + .write( + "lib/repo.dart", + "import 'items.dart';\n\nclass Repo {\n Other load() => Other();\n}\n", + ) + .edge_before("function run", "calls", "method Item::save") + .edge_after("function run", "calls", "method Other::save") +} + +/// A getter's value called (`b.family().save()`) is what its type's `call` +/// returns: retyped, the link after it moves, though the chain never writes +/// `call`. +pub fn dart_chain_link_follows_a_retyped_call_method() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/family.dart", + "import 'items.dart';\n\nclass Family {\n Item call() => Item();\n}\n", + ) + .file( + "lib/builder.dart", + "import 'family.dart';\n\nclass Builder {\n Family get family => Family();\n}\n", + ) + .file( + "lib/use.dart", + "import 'builder.dart';\n\nvoid run(Builder b) {\n b.family().save();\n}\n", + ) + .write( + "lib/family.dart", + "import 'items.dart';\n\nclass Family {\n Other call() => Other();\n}\n", + ) + .edge_before("function run", "calls", "method Item::save") + .edge_after("function run", "calls", "method Other::save") +} + +/// A top-level constant heading a chain is typed by what its initializer's +/// function returns: retyped in a third file, the chain moves, though its +/// file names neither the function nor its type. +pub fn dart_chain_link_through_a_constant_follows_the_function_its_initializer_calls() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/repos.dart", + "import 'items.dart';\n\nclass Repo {\n Item load() => Item();\n}\n\nclass OtherRepo {\n Other load() => Other();\n}\n", + ) + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/makers.dart", + "import 'repos.dart';\n\nRepo makeRepo() => Repo();\n", + ) + .file( + "lib/consts.dart", + "import 'makers.dart';\n\nfinal repo = makeRepo();\n", + ) + .file( + "lib/use.dart", + "import 'consts.dart';\n\nvoid run() {\n repo.load().touch().save();\n}\n", + ) + .write( + "lib/makers.dart", + "import 'repos.dart';\n\nOtherRepo makeRepo() => OtherRepo();\n", + ) + .edge_before("function run", "calls", "method Item::save") + .edge_after("function run", "calls", "method Other::save") +} + +/// A field declared without a type is typed by its initializer: the function +/// it calls retyped in another file moves a chain through the field. +pub fn dart_chain_link_through_a_field_initializer_follows_the_function_it_calls() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/caches.dart", + "import 'items.dart';\n\nclass Cache {\n Item get() => Item();\n}\n\nclass OtherCache {\n Other get() => Other();\n}\n", + ) + .file( + "lib/makers.dart", + "import 'caches.dart';\n\nCache makeCache() => Cache();\n", + ) + .file( + "lib/box.dart", + "import 'makers.dart';\n\nclass Box {\n final cache = makeCache();\n}\n", + ) + .file( + "lib/use.dart", + "import 'box.dart';\n\nvoid run(Box b) {\n b.cache.get().save();\n}\n", + ) + .write( + "lib/makers.dart", + "import 'caches.dart';\n\nOtherCache makeCache() => OtherCache();\n", + ) + .edge_before("function run", "calls", "method Cache::get") + .edge_after("function run", "calls", "method OtherCache::get") + .edge_before("function run", "calls", "method Item::save") + .edge_after("function run", "calls", "method Other::save") +} + +/// A getter an extension adds is a link of the chain (`c.manager.read()`): +/// added in another file, the call through it comes into reach. +pub fn dart_chain_link_after_an_extension_getter_its_receiver_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/manager.dart", + "class Manager {\n void read() {}\n}\n\nclass OtherManager {\n void read() {}\n}\n\nclass Container {\n final Manager inner = Manager();\n}\n", + ) + .file( + "lib/ext.dart", + "import 'manager.dart';\n", + ) + .file( + "lib/use.dart", + "import 'manager.dart';\nimport 'ext.dart';\n\nvoid run(Container c) {\n c.manager.read();\n}\n", + ) + .write( + "lib/ext.dart", + "import 'manager.dart';\n\nextension ContainerX on Container {\n Manager get manager => inner;\n}\n", + ) + .edge_after("function run", "calls", "method ContainerX::manager") + .edge_after("function run", "calls", "method Manager::read") +} + +/// A link reaches a member through the lineage of the type the link before +/// it returns: that type stops extending the base declaring it, and the +/// link and the one after it link nothing. +pub fn dart_chain_link_leaves_a_member_a_base_of_an_inner_type_drops() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/base.dart", + "import 'items.dart';\n\nclass Base {\n Other wrap() => Other();\n}\n", + ) + .file( + "lib/inner.dart", + "import 'base.dart';\n\nclass Inner extends Base {}\n", + ) + .file( + "lib/repo.dart", + "import 'inner.dart';\n\nclass Repo {\n Inner load() => Inner();\n}\n", + ) + .file( + "lib/use.dart", + "import 'repo.dart';\n\nvoid run(Repo r) {\n r.load().wrap().save();\n}\n", + ) + .write( + "lib/inner.dart", + "import 'base.dart';\n\nclass Inner {}\n", + ) + .edge_before("function run", "calls", "method Base::wrap") + .edge_before("function run", "calls", "method Other::save") +} + +/// The same past the tail ceiling: 501 parked calls share the tail `wrap`. +pub fn dart_chain_link_after_a_universal_extension_it_gains_past_the_tail_ceiling() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/items.dart", + "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n", + ) + .file( + "lib/ext.dart", + "import 'items.dart';\n", + ) + .file( + "lib/use.dart", + "import 'items.dart';\nimport 'ext.dart';\n\nvoid run(Item i) {\n i.touch().wrap().save();\n}\n", + ) + .file("noise/noise.dart", WRAP_CALLS.as_str()) + .write( + "lib/ext.dart", + "import 'items.dart';\n\nextension Wrap on T {\n Other wrap() => Other();\n}\n", + ) + .edge_after("function run", "calls", "method Wrap::wrap") + .edge_after("function run", "calls", "method Other::save") +} + +/// A getter typed by a type parameter of its class (`Item get held` in +/// `Holder`) says nothing; once the class stops declaring that +/// parameter, `Item` is the type, though no member of the class changed +/// (W4-DART-03b: a class head that changed moves every member's name). +pub fn dart_chain_link_follows_a_class_head_that_stops_declaring_a_type_parameter() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file( + "lib/holder.dart", + "import 'items.dart';\n\nclass Holder {\n Item get held => throw 0;\n}\n", + ) + .file( + "lib/use.dart", + "import 'holder.dart';\n\nvoid run(Holder h) {\n h.held.save();\n}\n", + ) + .write( + "lib/holder.dart", + "import 'items.dart';\n\nclass Holder {\n Item get held => throw 0;\n}\n", + ) + .edge_after("function run", "calls", "method Item::save") +} + +/// The type a chain's local is initialized through goes with its file: the +/// links after the local link nothing, though their own targets stayed. +pub fn dart_chain_link_through_a_local_of_a_deleted_type_links_nothing() -> Case { + Case::new(Change::DeleteImportedModule, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file( + "lib/repo.dart", + "import 'items.dart';\n\nclass Repo {\n Item load() => Item();\n}\n", + ) + .file( + "lib/use.dart", + "import 'repo.dart';\n\nvoid run(Repo r) {\n final item = r.load();\n item.touch().save();\n}\n", + ) + .remove("lib/repo.dart") + .edge_before("function run", "calls", "method Item::save") +} + +/// An extension's member answers a lookup on the type it is `on`, whatever +/// the chain names: the extension moves to another type, and the link to it +/// and the link after it link nothing, though no member changed +/// (W4-DART-03b). +pub fn dart_chain_link_leaves_an_extension_that_moves_to_another_type() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file( + "lib/ext.dart", + "import 'items.dart';\n\nextension Wrap on Item {\n Other wrap() => Other();\n}\n", + ) + .file( + "lib/use.dart", + "import 'items.dart';\nimport 'ext.dart';\n\nvoid run(Item i) {\n i.touch().wrap().save();\n}\n", + ) + .write( + "lib/ext.dart", + "import 'items.dart';\n\nextension Wrap on Other {\n Other wrap() => Other();\n}\n", + ) + .edge_before("function run", "calls", "method Wrap::wrap") + .edge_before("function run", "calls", "method Other::save") +} + +/// A constant an initializer types through an extension's member is what +/// the member returns: the extension moves to another type, and a chain +/// headed by the constant in another file links nothing, though neither +/// file names the extension (W4-DART-03b). +pub fn dart_chain_link_through_a_constant_an_extension_types_leaves_with_it() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file( + "lib/ext.dart", + "import 'items.dart';\n\nextension Wrap on Item {\n Other wrap() => Other();\n}\n", + ) + .file( + "lib/w.dart", + "import 'items.dart';\nimport 'ext.dart';\n\nfinal w = Item().wrap();\n", + ) + .file( + "lib/use.dart", + "import 'items.dart';\nimport 'w.dart';\n\nvoid run() {\n w.touch().save();\n}\n", + ) + .write( + "lib/ext.dart", + "import 'items.dart';\n\nextension Wrap on Other {\n Other wrap() => Other();\n}\n", + ) + .edge_before("function run", "calls", "method Other::save") +} + +/// A class head that changes moves the lookups on the class, reached +/// through a function typed by it in another file: once the head stops +/// declaring the type parameter its getter returns, the getter's type is +/// the class it names (W4-DART-03b). +pub fn dart_chain_link_through_a_function_typed_by_a_class_whose_head_changes() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file( + "lib/holder.dart", + "import 'items.dart';\n\nclass Holder {\n Item get held => throw 0;\n}\n", + ) + .file( + "lib/make.dart", + "import 'holder.dart';\n\nHolder makeHolder() => Holder();\n", + ) + .file( + "lib/use.dart", + "import 'make.dart';\n\nvoid run() {\n makeHolder().held.save();\n}\n", + ) + .write( + "lib/holder.dart", + "import 'items.dart';\n\nclass Holder {\n Item get held => throw 0;\n}\n", + ) + .edge_after("function run", "calls", "method Item::save") +} + +/// A static constant's initializer calls the function its own library +/// holds: a file declaring `pick` becomes a part of that library, and a +/// chain through the constant in a file that imports neither moves with it, +/// though no declaration changed (W4-DART-03b; the names a library's +/// visibility moved). +pub fn dart_chain_link_through_a_static_constant_follows_a_part_joining_its_library() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file("lib/b.dart", "import 'items.dart';\n\nOther pick() => Other();\n") + .file("lib/pa.dart", "import 'items.dart';\n\nItem pick() => Item();\n") + .file("lib/holder.dart", "class Holder {\n static final p = pick();\n}\n") + .file( + "lib/use.dart", + "void run() {\n Holder.p.touch().save();\n}\n", + ) + .write( + "lib/holder.dart", + "import 'items.dart';\n\npart 'pa.dart';\n\nclass Holder {\n static final p = pick();\n}\n", + ) + .write("lib/pa.dart", "part of 'holder.dart';\n\nItem pick() => Item();\n") + .edge_after("function run", "calls", "method Item::save") +} + +/// A chain's head naming a local is the local's declaration, unless a type +/// of its name exists: once one comes, the head is that type, and a link it +/// declares nothing for links nothing, though the chain names nothing the +/// new type declares (W4-DART-03b; a local's name is read only as a +/// type's). +pub fn dart_chain_link_headed_by_a_local_follows_a_type_of_its_name() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file("lib/box.dart", "import 'items.dart';\n") + .file( + "lib/use.dart", + "import 'items.dart';\nimport 'box.dart';\n\nvoid run() {\n final box = Item();\n box.touch().save();\n}\n", + ) + .write("lib/box.dart", "import 'items.dart';\n\nclass box {}\n") + .edge_before("function run", "calls", "method Item::save") +} + +/// A field a type gains through the base its base comes to extend is read +/// through a chain on the type, typed in another file: the field has no node, +/// so the names a lookup on the type reaches are read from the declarations +/// of its ancestry (W4-DART-03b). +pub fn dart_chain_link_reads_a_field_its_type_gains_through_a_base() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file( + "lib/base.dart", + "import 'items.dart';\n\nclass Base {}\n\nclass Holder {\n final Item item = Item();\n}\n", + ) + .file( + "lib/make.dart", + "import 'base.dart';\n\nclass Child extends Base {}\n\nChild makeChild() => Child();\n", + ) + .file( + "lib/use.dart", + "import 'make.dart';\n\nvoid run() {\n makeChild().item.save();\n}\n", + ) + .write( + "lib/base.dart", + "import 'items.dart';\n\nclass Base extends Holder {}\n\nclass Holder {\n final Item item = Item();\n}\n", + ) + .edge_after("function run", "calls", "method Item::save") +} + +/// An extension `on` the base a type's base comes to extend answers a link +/// on the type: the names a lookup reaches include the extensions on its +/// ancestry (W4-DART-03b). +pub fn dart_chain_link_reaches_an_extension_on_a_base_its_type_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file( + "lib/base.dart", + "import 'items.dart';\n\nclass Base {}\n\nclass Holder {}\n\nextension HolderX on Holder {\n Other wrap() => Other();\n}\n", + ) + .file( + "lib/make.dart", + "import 'base.dart';\n\nclass Child extends Base {}\n\nChild makeChild() => Child();\n", + ) + .file( + "lib/use.dart", + "import 'items.dart';\nimport 'base.dart';\nimport 'make.dart';\n\nvoid run() {\n makeChild().wrap().save();\n}\n", + ) + .write( + "lib/base.dart", + "import 'items.dart';\n\nclass Base extends Holder {}\n\nclass Holder {}\n\nextension HolderX on Holder {\n Other wrap() => Other();\n}\n", + ) + .edge_after("function run", "calls", "method Other::save") +} + +/// An extension `on` any type applies to a project type only: once the type +/// a function returns comes, a chain on the function's result reaches the +/// extension's member, though no name the chain writes changed +/// (W4-DART-03b). +pub fn dart_chain_link_reaches_an_extension_on_any_type_once_its_type_comes() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file( + "lib/also.dart", + "extension Also on T {\n T also() => this;\n}\n", + ) + .file( + "lib/make.dart", + "import 'fresh.dart';\n\nFresh makeFresh() => Fresh();\n", + ) + .file( + "lib/use.dart", + "import 'also.dart';\nimport 'make.dart';\n\nvoid run() {\n makeFresh().also();\n}\n", + ) + .write("lib/fresh.dart", "class Fresh {\n void go() {}\n}\n") + .edge_after("function run", "calls", "method Also::also") +} + +/// A chain written in a type reads the type: `this` and a receiver-less +/// head are typed by it, so its links move with the type's lineage, though +/// the chain names nothing that changed (W4-DART-03b). +pub fn dart_chain_link_in_a_type_follows_a_base_its_base_gains() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n Item touch() => this;\n}\n\nclass Other {\n void save() {}\n Other touch() => this;\n}\n") + .file( + "lib/base.dart", + "import 'items.dart';\n\nclass Base {}\n\nclass Mixer {\n Other wrap() => Other();\n}\n", + ) + .file( + "lib/child.dart", + "import 'items.dart';\nimport 'base.dart';\n\nclass Child extends Base {\n void run() {\n this.wrap().save();\n wrap().touch();\n }\n}\n", + ) + .write( + "lib/base.dart", + "import 'items.dart';\n\nclass Base extends Mixer {}\n\nclass Mixer {\n Other wrap() => Other();\n}\n", + ) + .edge_after("method Child::run", "calls", "method Other::save") + .edge_after("method Child::run", "calls", "method Other::touch") +} + +/// An unnamed extension applies only in its own library: once its file +/// becomes a part of the chain's library, its member and the link after it +/// come into reach, though neither file of the chain changed. +pub fn dart_chain_link_reaches_an_unnamed_extension_once_its_file_joins_the_library() -> Case { + Case::new(Change::BodyEdit, &[Language::Dart]) + .file("pubspec.yaml", "name: app\n") + .file("lib/items.dart", "class Item {\n void save() {}\n}\n") + .file( + "lib/shapes.dart", + "class Shape {}\n\nclass Wrap {\n Shape get shape => Shape();\n}\n", + ) + .file( + "lib/use.dart", + "import 'items.dart';\nimport 'shapes.dart';\n\npart 'ext.dart';\n\nvoid run(Wrap w) {\n w.shape.area().save();\n}\n", + ) + .file( + "lib/ext.dart", + "import 'items.dart';\nimport 'shapes.dart';\n\nextension on Shape {\n Item area() => Item();\n}\n", + ) + .write( + "lib/ext.dart", + "part of 'use.dart';\n\nextension on Shape {\n Item area() => Item();\n}\n", + ) + .edge_after("function run", "calls", "method ::area") + .edge_after("function run", "calls", "method Item::save") +} diff --git a/crates/codegraph-cli/tests/sync_convergence/main.rs b/crates/codegraph-cli/tests/sync_convergence/main.rs new file mode 100644 index 00000000..aeb3f4a9 --- /dev/null +++ b/crates/codegraph-cli/tests/sync_convergence/main.rs @@ -0,0 +1,1521 @@ +//! Cross-language sync-versus-fresh convergence matrix. +//! +//! Every case writes a `before` tree, indexes it with the built +//! `codegraph init`, applies one edit, and brings the index up to date through +//! two drivers: +//! +//! - `cli`: `codegraph sync`, the full-scan path a user or a git hook runs; +//! - `watch`: the file watcher's path, `codegraph_watch::sync_changed_paths` +//! with exactly the changed paths. When an edit touches a watcher control +//! file (the index root's `config.toml` or `codegraph.json`, or the root +//! `.gitignore`), the real watcher reloads its scope and runs one full +//! reconcile instead (`watcher.rs`, the control-file branch of the event +//! loop), so the driver does the same. +//! +//! The oracle writes the final tree into a separate directory and runs a fresh +//! `init` from an empty index. Each driver's database must equal the oracle's +//! under `canonicalize_db` and `diff_canonical`: every node, file, edge and +//! unresolved reference by its full natural key, plus the schema text. Nothing +//! is compared by count, and no existing database is ever re-indexed. +//! +//! Each case also names the edges its `before` and fresh indexes must hold, so +//! a case cannot pass by exercising nothing. A known gap is an `#[ignore]`d case +//! whose reason names the plan item that fixes it, or says `unscheduled` when no +//! item has taken it yet; run with `-- --ignored`, it fails. +//! `sync_matrix_covers_every_extracted_language` fails when a language with an +//! extractor has no case that runs, and `sync_matrix_covers_every_change_kind` +//! when a [`Change`] has none. + +#[path = "../probes/support.rs"] +mod support; + +mod cases; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use codegraph_bench::oracle::{canonicalize_db, diff_canonical}; +use codegraph_core::types::Language; + +use support::{Graph, Indexed, Project}; + +/// The kind of change a case applies. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Change { + /// A file appears that satisfies an import or include an unchanged file + /// already has. + AddImportTarget, + /// Only a body changes; every declaration keeps its identity. + BodyEdit, + /// A declaration is renamed in its own file, under unchanged references. + RenameSymbol, + /// A module an unchanged file imports is deleted. + DeleteImportedModule, + /// An `extends`/`implements`-style clause changes. + EditHeritage, + /// A second declaration of an already-referenced name appears elsewhere. + AddCompetitor, + /// A project control file changes: one resolution reads (`package.json`, + /// `tsconfig.json`, `jsconfig.json` or a config it extends, a workspace + /// member's manifest, a `.csproj` or `.props`, a `Cargo.toml`, a Tauri + /// config), the root `.gitignore`, `.git/info/exclude`, the index root's + /// `config.toml` or `codegraph.json`, or a Shopify theme marker + /// (`layout/theme.liquid`, `config/settings_schema.json`). + ControlFile, + /// A `.h` header changes from C to C++ content, or an `.inc` include + /// between PHP and Pascal content. + HeaderFlip, + /// A `.js` file gains Flow's `@flow` pragma, so it is read as TSX. + PragmaFlip, + /// A module's re-exports change: a name an unchanged file imports + /// through it now means another definition, though no node of that + /// name changed. + ReexportEdit, +} + +impl Change { + const ALL: [Self; 10] = [ + Self::AddImportTarget, + Self::BodyEdit, + Self::RenameSymbol, + Self::DeleteImportedModule, + Self::EditHeritage, + Self::AddCompetitor, + Self::ControlFile, + Self::HeaderFlip, + Self::PragmaFlip, + Self::ReexportEdit, + ]; +} + +/// One file operation of a case's edit. +pub enum Op { + Write(&'static str, &'static str), + /// Rewrite a file keeping its modification time: with contents of the + /// same length, nothing a stat sees moves. + Replace(&'static str, &'static str), + Remove(&'static str), + Rename(&'static str, &'static str), +} + +impl Op { + /// The paths a watcher event batch for this operation names. + fn paths(&self) -> Vec<&'static str> { + match *self { + Self::Write(path, _) | Self::Replace(path, _) | Self::Remove(path) => vec![path], + Self::Rename(from, to) => vec![from, to], + } + } +} + +type EdgeSpec = (&'static str, &'static str, &'static str); + +/// A `before` tree, one edit, and the edges that prove the case is live. +pub struct Case { + change: Change, + languages: Vec, + before: Vec<(&'static str, &'static str)>, + edit: Vec, + edges_before: Vec, + edges_after: Vec, + absent_after: Vec, + languages_after: Vec<(&'static str, &'static str)>, +} + +impl Case { + /// A case of `change` that exercises `languages`; every one of them must + /// be the language of an indexed file or node. + pub fn new(change: Change, languages: &[Language]) -> Self { + Self { + change, + languages: languages.to_vec(), + before: Vec::new(), + edit: Vec::new(), + edges_before: Vec::new(), + edges_after: Vec::new(), + absent_after: Vec::new(), + languages_after: Vec::new(), + } + } + + /// A file of the `before` tree. + pub fn file(mut self, path: &'static str, contents: &'static str) -> Self { + self.before.push((path, contents)); + self + } + + /// Edit: add or rewrite a file. + pub fn write(mut self, path: &'static str, contents: &'static str) -> Self { + self.edit.push(Op::Write(path, contents)); + self + } + + /// Edit: delete a file. + /// Rewrite `path` keeping its modification time (see [`Op::Replace`]). + pub fn replace(mut self, path: &'static str, contents: &'static str) -> Self { + self.edit.push(Op::Replace(path, contents)); + self + } + + pub fn remove(mut self, path: &'static str) -> Self { + self.edit.push(Op::Remove(path)); + self + } + + /// Edit: move a file. + pub fn rename(mut self, from: &'static str, to: &'static str) -> Self { + self.edit.push(Op::Rename(from, to)); + self + } + + /// An edge the `before` index must hold, in the probe selector notation. + pub fn edge_before( + mut self, + source: &'static str, + kind: &'static str, + target: &'static str, + ) -> Self { + self.edges_before.push((source, kind, target)); + self + } + + /// An edge a fresh index of the final tree must hold. + pub fn edge_after( + mut self, + source: &'static str, + kind: &'static str, + target: &'static str, + ) -> Self { + self.edges_after.push((source, kind, target)); + self + } + + /// An edge a fresh index of the final tree must NOT hold, between nodes it + /// does hold. + pub fn no_edge_after( + mut self, + source: &'static str, + kind: &'static str, + target: &'static str, + ) -> Self { + self.absent_after.push((source, kind, target)); + self + } + + /// The language a fresh index of the final tree records for file `path`. + pub fn file_language_after(mut self, path: &'static str, language: &'static str) -> Self { + self.languages_after.push((path, language)); + self + } + + /// An edge both the `before` index and a fresh index of the final tree + /// must hold. + pub fn edge_throughout( + self, + source: &'static str, + kind: &'static str, + target: &'static str, + ) -> Self { + self.edge_before(source, kind, target) + .edge_after(source, kind, target) + } + + /// The final tree: `before` with the edit applied. + #[track_caller] + fn final_tree(&self) -> BTreeMap { + let mut tree = BTreeMap::new(); + for (path, contents) in &self.before { + assert!( + tree.insert(path.to_string(), contents.to_string()) + .is_none(), + "`before` lists {path} twice" + ); + } + assert!(!self.edit.is_empty(), "a case needs an edit"); + for op in &self.edit { + match *op { + Op::Write(path, contents) | Op::Replace(path, contents) => { + tree.insert(path.to_string(), contents.to_string()); + } + Op::Remove(path) => { + assert!( + tree.remove(path).is_some(), + "removes {path}, which does not exist" + ); + } + Op::Rename(from, to) => { + let contents = tree + .remove(from) + .unwrap_or_else(|| panic!("renames {from}, which does not exist")); + assert!( + tree.insert(to.to_string(), contents).is_none(), + "renames {from} over the existing {to}" + ); + } + } + } + tree + } + + fn index_before(&self, label: &str) -> Indexed { + self.before + .iter() + .fold(Project::named(label), |project, (path, contents)| { + project.file(path, contents) + }) + .index() + } + + /// Applies the edit on disk and returns the paths a watcher would report. + fn apply(&self, project: &mut Indexed) -> Vec<&'static str> { + let mut changed = Vec::new(); + for op in &self.edit { + match *op { + Op::Write(path, contents) => { + project.write(path, contents); + } + Op::Replace(path, contents) => { + let mtime = fs::metadata(project.root().join(path)) + .and_then(|metadata| metadata.modified()) + .unwrap_or_else(|err| panic!("stat {path}: {err}")); + support::write_file(project.root(), path, contents, mtime); + } + Op::Remove(path) => { + project.remove(path); + } + Op::Rename(from, to) => { + project.rename(from, to); + } + } + for path in op.paths() { + if !changed.contains(&path) { + changed.push(path); + } + } + } + changed + } +} + +/// Runs one case through both drivers and compares each with a fresh index. +fn run_case(name: &str, case: &Case) { + let final_tree = case.final_tree(); + + let mut cli = case.index_before("cli"); + expect_edges(name, "before", cli.graph(), &case.edges_before); + let mut languages = indexed_languages(cli.graph()); + case.apply(&mut cli); + cli.sync(); + + let mut watch = case.index_before("watch"); + let changed = case.apply(&mut watch); + watcher_sync(&watch, &changed); + watch.reload(); + + let fresh = final_tree + .iter() + .fold(Project::named("fresh"), |project, (path, contents)| { + project.file(path, contents) + }) + .index(); + expect_edges(name, "fresh", fresh.graph(), &case.edges_after); + expect_no_edges(name, "fresh", fresh.graph(), &case.absent_after); + for &(path, language) in &case.languages_after { + let recorded = fresh + .graph() + .files + .iter() + .find(|file| file.path == path) + .map(|file| file.language.as_str()); + assert_eq!( + recorded, + Some(language), + "[{name}] the fresh index records {path} as {recorded:?}" + ); + } + languages.extend(indexed_languages(fresh.graph())); + for language in &case.languages { + assert!( + languages.contains(language.as_str()), + "[{name}] declares {language}, but neither the before nor the fresh index has a \ + {language} file or node; indexed: {languages:?}" + ); + } + for project in [&cli, &watch] { + assert_tree(name, project.root(), &final_tree); + } + + let expected = canonicalize_db(&fresh.db()).expect("canonicalize the fresh index"); + let mut divergent = Vec::new(); + for (driver, project) in [("cli", &cli), ("watch", &watch)] { + let actual = canonicalize_db(&project.db()).expect("canonicalize the synced index"); + if let Err(diff) = diff_canonical(&expected, &actual, None) { + divergent.push(format!( + "=== {driver} driver: {} canonical difference(s) from a fresh index\n{}{diff}", + diff.entries().len(), + readable_difference(fresh.graph(), project.graph()), + )); + } + } + // The module-surface rows and their version marker (W4a-F3) are no + // canonical surface, but the next sync reads them: a sync leaves them as a + // fresh index writes them. + let surface_rows = support::project_metadata_with_prefix(&fresh.db(), "js_module_surface"); + // So is the C/C++ dependents marker (W4-CPP-04): every sync that + // succeeded leaves it as a fresh index writes it, and the namespace rows + // (W4-CPP-07) with it. + let cpp_marker = support::project_metadata_with_prefix(&fresh.db(), "cpp_type_surface"); + let namespace_rows = + support::project_metadata_with_prefix(&fresh.db(), "cpp_namespace_surface"); + // The Java head rows and their marker (W4-JVM-01), one prefix for both. + let head_rows = support::project_metadata_with_prefix(&fresh.db(), "java_type_heads"); + // And the Kotlin surface rows and their marker (W4-JVM-03). + let kotlin_rows = support::project_metadata_with_prefix(&fresh.db(), "kotlin_surface"); + // And the Scala surface rows and their marker (W4-SC-01). + let scala_rows = support::project_metadata_with_prefix(&fresh.db(), "scala_surface"); + // And the C# surface rows and their marker (W4-CS-01). + let csharp_rows = support::project_metadata_with_prefix(&fresh.db(), "csharp_surface"); + // And the member surface rows and their marker (W4-CS-03). + let member_rows = support::project_metadata_with_prefix(&fresh.db(), "member_surface"); + // And the Ruby head rows and their marker (W4-RB-01). + let ruby_rows = support::project_metadata_with_prefix(&fresh.db(), "ruby_heads"); + // And the CFML component rows and their marker (W4-CF-01). + let cfml_rows = support::project_metadata_with_prefix(&fresh.db(), "cfml_heads"); + // And the Solidity head rows and their marker (W4-SOL-01). + let solidity_rows = support::project_metadata_with_prefix(&fresh.db(), "solidity_heads"); + // And the Swift head rows and their marker (W4-SW-01). + let swift_rows = support::project_metadata_with_prefix(&fresh.db(), "swift_heads"); + // And the Objective-C head rows and their marker (W4-SW-04). + let objc_rows = support::project_metadata_with_prefix(&fresh.db(), "objc_heads"); + // And the Go head rows and their marker (W4-GO-01). + let go_rows = support::project_metadata_with_prefix(&fresh.db(), "go_heads"); + // And the PHP head rows and their marker (W4-PHP-01). + let php_rows = support::project_metadata_with_prefix(&fresh.db(), "php_heads"); + let lua_rows = support::project_metadata_with_prefix(&fresh.db(), "lua_module"); + let dart_rows = support::project_metadata_with_prefix(&fresh.db(), "dart_heads"); + // And the Dart library rows (W4-DART-02). + let dart_library_rows = support::project_metadata_with_prefix(&fresh.db(), "dart_library"); + // And the Shopify theme row and its marker (W4-LIQ-01). + let shopify_rows = support::project_metadata_with_prefix(&fresh.db(), "shopify_themes"); + // And the class-head rows (W5-07), the control-input rows, the + // detected frameworks and the Dart pubspec rows (W5-06). An input + // outside the project is keyed by + // the absolute path its reader used, which differs between the trees, so + // only project paths are compared. + for prefix in [ + "class_heads", + "control_inputs", + "frameworks_", + "dart_pubspec", + ] { + let fresh_rows = comparable_rows(&fresh.db(), prefix); + for (driver, project) in [("cli", &cli), ("watch", &watch)] { + let rows = comparable_rows(&project.db(), prefix); + if rows != fresh_rows { + divergent.push(format!( + "=== {driver} driver: the {prefix} rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {fresh_rows:#?}" + )); + } + } + } + for (driver, project) in [("cli", &cli), ("watch", &watch)] { + let rows = support::project_metadata_with_prefix(&project.db(), "js_module_surface"); + if rows != surface_rows { + divergent.push(format!( + "=== {driver} driver: module-surface rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {surface_rows:#?}" + )); + } + let marker = support::project_metadata_with_prefix(&project.db(), "cpp_type_surface"); + if marker != cpp_marker { + divergent.push(format!( + "=== {driver} driver: the C/C++ dependents marker differs from a fresh index\n\ + synced: {marker:#?}\nfresh: {cpp_marker:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "cpp_namespace_surface"); + if rows != namespace_rows { + divergent.push(format!( + "=== {driver} driver: the C/C++ namespace rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {namespace_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "java_type_heads"); + if rows != head_rows { + divergent.push(format!( + "=== {driver} driver: the Java head rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {head_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "kotlin_surface"); + if rows != kotlin_rows { + divergent.push(format!( + "=== {driver} driver: the Kotlin surface rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {kotlin_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "scala_surface"); + if rows != scala_rows { + divergent.push(format!( + "=== {driver} driver: the Scala surface rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {scala_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "csharp_surface"); + if rows != csharp_rows { + divergent.push(format!( + "=== {driver} driver: the C# surface rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {csharp_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "member_surface"); + if rows != member_rows { + divergent.push(format!( + "=== {driver} driver: the member surface rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {member_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "ruby_heads"); + if rows != ruby_rows { + divergent.push(format!( + "=== {driver} driver: the Ruby head rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {ruby_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "cfml_heads"); + if rows != cfml_rows { + divergent.push(format!( + "=== {driver} driver: the CFML component rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {cfml_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "solidity_heads"); + if rows != solidity_rows { + divergent.push(format!( + "=== {driver} driver: the Solidity head rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {solidity_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "swift_heads"); + if rows != swift_rows { + divergent.push(format!( + "=== {driver} driver: the Swift head rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {swift_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "objc_heads"); + if rows != objc_rows { + divergent.push(format!( + "=== {driver} driver: the Objective-C head rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {objc_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "go_heads"); + if rows != go_rows { + divergent.push(format!( + "=== {driver} driver: the Go head rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {go_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "php_heads"); + if rows != php_rows { + divergent.push(format!( + "=== {driver} driver: the PHP head rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {php_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "lua_module"); + if rows != lua_rows { + divergent.push(format!( + "=== {driver} driver: the Lua module rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {lua_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "dart_heads"); + if rows != dart_rows { + divergent.push(format!( + "=== {driver} driver: the Dart head rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {dart_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "dart_library"); + if rows != dart_library_rows { + divergent.push(format!( + "=== {driver} driver: the Dart library rows differ from a fresh index\n\ + synced: {rows:#?}\nfresh: {dart_library_rows:#?}" + )); + } + let rows = support::project_metadata_with_prefix(&project.db(), "shopify_themes"); + if rows != shopify_rows { + divergent.push(format!( + "=== {driver} driver: the Shopify theme row differs from a fresh index\n\ + synced: {rows:#?}\nfresh: {shopify_rows:#?}" + )); + } + } + assert!( + divergent.is_empty(), + "[{name}] incremental sync after a {:?} edit diverges from a fresh index\n\ + edit: {}\n{}\n=== fresh index\n{}", + case.change, + describe_edit(&case.edit), + divergent.join("\n"), + fresh.graph().report(), + ); +} + +/// The `prefix` rows of `db`, a control-input row's keys outside the project +/// (absolute paths) dropped. +fn comparable_rows(db: &Path, prefix: &str) -> Vec<(String, String)> { + support::project_metadata_with_prefix(db, prefix) + .into_iter() + .map(|(key, value)| { + if !key.starts_with("control_inputs:") { + return (key, value); + } + let Ok(serde_json::Value::Object(map)) = serde_json::from_str(&value) else { + return (key, value); + }; + let kept: serde_json::Map = map + .into_iter() + .filter(|(input, _)| { + !std::path::Path::new(input.trim_start_matches("dir:")).is_absolute() + }) + .collect(); + (key, serde_json::Value::Object(kept).to_string()) + }) + .collect() +} + +/// The watcher-path driver: what the real watcher runs for one event batch. +fn watcher_sync(project: &Indexed, changed: &[&str]) { + let root = project.root(); + let paths = support::index_paths(root); + let controls = [ + paths.config_toml(), + paths.extension_config(), + paths.project().join(".gitignore"), + paths + .project() + .join(codegraph_extract::engine::REPOSITORY_EXCLUDE), + ] + .map(|path| relative_slash(paths.project(), &path)); + let outcome = if changed + .iter() + .any(|path| controls.iter().any(|control| control == path)) + { + codegraph_watch::sync_project_once(root) + } else { + codegraph_watch::sync_changed_paths( + root, + paths.current_db(), + changed + .iter() + .map(|path| root.join(support::checked_relative(path))), + ) + }; + outcome.unwrap_or_else(|err| panic!("watcher-path sync of {}: {err:#}", root.display())); +} + +fn relative_slash(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or_else(|_| panic!("{} is outside {}", path.display(), root.display())) + .to_string_lossy() + .replace('\\', "/") +} + +#[track_caller] +fn expect_edges(name: &str, label: &str, graph: &Graph, edges: &[EdgeSpec]) { + for &(source, kind, target) in edges { + assert!( + !graph.edges_matching(source, kind, target).is_empty(), + "[{name}] the {label} index lacks {source} -[{kind}]-> {target}, which this case \ + exists to exercise\n{}", + graph.report() + ); + } +} + +#[track_caller] +fn expect_no_edges(name: &str, label: &str, graph: &Graph, edges: &[EdgeSpec]) { + for &(source, kind, target) in edges { + for selector in [source, target] { + assert!( + !graph.nodes_matching(selector).is_empty(), + "[{name}] the {label} index has no `{selector}`, so the absence of \ + {source} -[{kind}]-> {target} would hold vacuously\n{}", + graph.report() + ); + } + assert!( + graph.edges_matching(source, kind, target).is_empty(), + "[{name}] the {label} index holds {source} -[{kind}]-> {target}, which this case \ + exists to rule out\n{}", + graph.report() + ); + } +} + +/// The languages of a graph's files and nodes. Both count: a `.h` file keeps +/// the `c` file language while its nodes may be `cpp`. +fn indexed_languages(graph: &Graph) -> BTreeSet { + graph + .files + .iter() + .map(|file| file.language.clone()) + .chain(graph.nodes.iter().map(|node| node.language.clone())) + .collect() +} + +/// The project tree on disk, the index's own files aside, must be exactly the +/// tree the oracle indexed. +#[track_caller] +fn assert_tree(name: &str, root: &Path, expected: &BTreeMap) { + let paths = support::index_paths(root); + let index_root = relative_slash(paths.project(), paths.current_root()); + let controls = [paths.config_toml(), paths.extension_config()] + .map(|path| relative_slash(paths.project(), &path)); + let mut actual = BTreeMap::new(); + let mut pending = vec![paths.project().to_path_buf()]; + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(&dir).expect("read a project directory") { + let path: PathBuf = entry.expect("read a directory entry").path(); + let relative = relative_slash(paths.project(), &path); + let in_index = + relative == index_root || relative.starts_with(&format!("{index_root}/")); + if path.is_dir() { + if !in_index || relative == index_root { + pending.push(path); + } + } else if !in_index || controls.contains(&relative) { + let contents = fs::read_to_string(&path).expect("read a project file"); + actual.insert(relative, contents); + } + } + } + assert!( + actual == *expected, + "[{name}] the tree under {} is not the tree the fresh index was built from\n\ + on disk: {:?}\nexpected: {:?}", + root.display(), + actual.keys().collect::>(), + expected.keys().collect::>() + ); +} + +/// The rows each graph holds that the other lacks, in selector notation. +fn readable_difference(fresh: &Graph, synced: &Graph) -> String { + fn rows(graph: &Graph) -> Vec { + let mut rows = graph + .nodes + .iter() + .map(|node| format!("node {}", graph.describe(&node.id))) + .chain( + graph + .edges + .iter() + .map(|edge| format!("edge {}", graph.describe_edge(edge))), + ) + .chain( + graph + .unresolved + .iter() + .map(|reference| format!("ref {}", graph.describe_ref(reference))), + ) + .chain( + graph + .files + .iter() + .map(|file| format!("file {} ({})", file.path, file.language)), + ) + .collect::>(); + rows.sort(); + rows + } + let fresh_rows = rows(fresh); + let synced_rows = rows(synced); + let mut text = String::new(); + for (title, from, other) in [ + ("only in the fresh index", &fresh_rows, &synced_rows), + ("only in the synced index", &synced_rows, &fresh_rows), + ] { + let mut remaining = other.clone(); + let mut only = Vec::new(); + for row in from { + match remaining.iter().position(|candidate| candidate == row) { + Some(index) => { + remaining.remove(index); + } + None => only.push(row.as_str()), + } + } + text.push_str(&format!("{title} ({}):\n", only.len())); + for row in only { + text.push_str(&format!(" {row}\n")); + } + } + text +} + +fn describe_edit(edit: &[Op]) -> String { + edit.iter() + .map(|op| match *op { + Op::Write(path, _) => format!("write {path}"), + Op::Replace(path, _) => format!("replace {path}, its mtime kept"), + Op::Remove(path) => format!("remove {path}"), + Op::Rename(from, to) => format!("rename {from} -> {to}"), + }) + .collect::>() + .join(", ") +} + +/// Every language a file can be extracted in: a tree-sitter spec or an +/// embedded extractor. YAML, Twig, Properties and the three Godot formats have +/// no extractor (they get a file node; the Godot resolver reads the latter), so +/// the gate does not require them; a GDScript case still indexes a Godot +/// project and scene. +fn extracted_languages() -> Vec { + Language::ALL + .into_iter() + .filter(|&language| { + codegraph_extract::lang::spec_for_language(language).is_some() + || codegraph_extract::embedded::extract_embedded("coverage-probe", "", language) + .is_some() + }) + .collect() +} + +macro_rules! gap { + () => { + None + }; + ($gap:literal) => { + Some($gap) + }; +} + +/// Registers each case function of [`cases`] as one test, `#[ignore]`d with +/// its known-gap reason where one is given, and lists them all for the +/// coverage gates. +macro_rules! matrix { + ($($(#[ignore = $gap:literal])? $name:ident,)*) => { + $( + #[test] + $(#[ignore = $gap])? + fn $name() { + run_case(stringify!($name), &cases::$name()); + } + )* + + fn registered() -> Vec<(&'static str, Option<&'static str>, Case)> { + vec![$((stringify!($name), gap!($($gap)?), cases::$name()),)*] + } + }; +} + +// Every `#[ignore]` below fails when run. +matrix! { + typescript_named_import_of_an_added_file, + typescript_default_import_of_an_added_file, + typescript_aliased_named_import_of_an_added_file, + typescript_aliased_named_import_gains_its_export, + tsx_aliased_component_import_gains_its_export, + typescript_aliased_named_import_leaves_a_namesake_when_its_module_gains_the_export, + typescript_aliased_named_import_leaves_an_in_module_namesake_when_its_module_gains_the_export, + typescript_aliased_named_import_loses_its_export, + tsx_component_import_of_an_added_file, + python_from_import_of_an_added_module, + php_include_of_an_added_file, + dart_import_of_an_added_library, + csharp_using_of_an_added_namespace, + java_import_of_an_added_class, + lua_require_of_an_added_module, + typescript_out_of_repo_call_ignores_an_added_namesake, + svelte_lib_alias_call_reaches_an_added_module, + vue_bare_call_ignores_an_added_interface_property, + javascript_unexported_namesake_appears, + svelte_instance_script_namesake_appears, + javascript_parameter_shadows_an_added_function, + typescript_destructured_composable_call_reaches_an_added_module, + typescript_module_destructuring_call_body_edit, + vue_own_function_shadows_a_nuxt_auto_import, + typescript_value_reference_survives_an_added_namesake, + javascript_global_namespace_member_appears, + javascript_global_namespace_member_renamed, + javascript_object_literal_this_call_body_edit, + tsx_tag_becomes_a_parameter_body_edit, + typescript_interface_moves_into_a_declare_module_body, + vue_options_api_this_call_body_edit, + vue_template_call_reaches_an_added_composable, + javascript_fetch_body_call_ignores_an_added_method, + python_bare_call_ignores_an_added_method, + python_outside_import_ignores_an_added_function, + python_call_becomes_a_local_binding_body_edit, + python_star_reexport_of_an_added_module, + python_package_gains_a_star_reexport, + python_package_drops_a_star_reexport, + python_reexporting_package_is_deleted, + python_named_reexport_moves_to_another_module, + python_module_member_reexport_appears, + python_dotted_module_class_of_an_added_module, + python_self_call_ignores_an_added_import_target, + python_fixture_returns_another_class, + python_conftest_plugs_a_fixture_module, + python_plugged_fixture_returns_another_class, + python_aliased_plugged_fixture_returns_another_class, + python_closer_conftest_overrides_a_fixture, + python_django_manager_result_body_edit, + python_module_global_retyped_in_its_module, + python_module_global_gains_an_external_write, + python_module_global_loses_an_unknown_write, + python_module_global_writer_is_deleted, + javascript_require_imports_an_added_module, + javascript_require_member_reaches_an_added_module, + typescript_require_default_of_an_added_es_module, + typescript_namespace_reexport_member_reaches_an_added_module, + typescript_namespace_reexport_names_another_module, + typescript_named_reexport_names_another_module, + typescript_nested_wildcard_reexport_names_another_module, + typescript_local_export_alias_names_another_declaration, + javascript_commonjs_forward_names_another_module, + typescript_barrel_gains_a_named_reexport, + javascript_export_list_drops_a_binding, + javascript_export_list_gains_a_binding, + javascript_sealed_module_gains_a_commonjs_export, + svelte_instance_script_becomes_a_module_script, + typescript_alias_forwarded_barrel_is_deleted, + typescript_workspace_entry_barrel_is_renamed, + javascript_barrel_chain_follows_the_importer_language, + cpp_attribute_macro_class_body_edit, + cpp_broken_tree_class_body_edit, + cpp_declared_base_class_heritage_edit, + cpp_alias_retarget_moves_an_unchanged_receiver, + cpp_template_alias_retarget_moves_an_unchanged_receiver, + cpp_alias_chain_retarget_moves_an_unchanged_receiver, + cpp_header_member_type_moves_an_unchanged_receiver, + cpp_inner_class_added_hides_an_outer_alias, + cpp_alias_in_an_unincluded_header_makes_it_ambiguous, + cpp_base_change_moves_an_inherited_alias, + cpp_base_change_reaches_an_out_of_line_method_of_a_derived_class, + cpp_batch_rewrites_a_base_before_dropping_a_derivation, + cpp_source_file_namesake_does_not_take_a_base, + cpp_using_declaration_names_the_base, + cpp_header_member_becomes_a_library_type, + cpp_project_declares_a_lowercase_outside_type, + cpp_nested_template_member_type_moves_an_unchanged_receiver, + cpp_cross_file_namespace_alias_retarget, + cpp_macro_opener_opens_another_namespace, + cpp_macro_alias_moves_an_opener_it_feeds, + cpp_macro_closer_moves_a_declaration_out_of_the_namespace, + cpp_free_operator_moves_out_of_the_macro_namespace, + cpp_opener_definition_stops_opening_a_namespace, + c_call_ignores_an_added_cpp_method, + objc_c_call_ignores_an_added_method, + cpp_macro_visibility_through_a_deep_include_graph, + cpp_lowercase_macro_class_body_edit, + c_auto_local_initializer_call_body_edit, + java_subclass_gains_a_base_its_caller_reaches, + typescript_dollar_import_reaches_an_added_module, + typescript_dollar_default_export_names_another_declaration, + java_super_call_in_an_override_body_edit, + java_annotation_ignores_a_case_variant_method_body_edit, + typescript_outside_type_call_ignores_an_added_method, + cpp_class_template_receiver_body_edit, + csharp_extension_method_call_body_edit, + csharp_method_becomes_an_extension_method, + csharp_extension_overload_of_a_renamed_type_parameter_appears, + csharp_extension_overload_under_another_constraint_appears, + csharp_extension_overload_spelled_with_other_spacing_appears, + csharp_extension_overload_with_a_raw_string_default_appears, + python_inherited_classmethod_body_edit, + python_class_method_of_an_added_class, + python_inherited_classmethod_of_an_added_base, + typescript_test_suite_namesake_appears, + java_overload_delegation_body_edit, + python_call_ignores_a_react_hook_body_edit, + typescript_type_parameter_added_body_edit, + python_function_value_bound_by_an_added_parameter, + javascript_minified_script_becomes_readable, + javascript_readable_script_becomes_minified, + typescript_side_effect_import_of_an_added_file, + go_import_of_an_added_package, + solidity_import_of_an_added_file, + svelte_import_of_an_added_component, + vue_import_of_an_added_component, + go_call_into_an_added_file_of_the_same_package, + typescript_collapsed_parse_is_fixed, + go_composite_literal_of_an_added_struct, + go_parameter_type_of_an_added_struct, + java_parameter_type_of_an_added_class, + csharp_parameter_type_of_an_added_class, + kotlin_property_type_of_an_added_class, + php_property_type_of_an_added_class, + c_include_of_an_added_header, + liquid_render_of_an_added_snippet, + liquid_json_template_section_of_an_added_section, + razor_reference_to_an_added_class, + r_source_of_an_added_file, + erlang_remote_call_into_an_added_module, + mybatis_include_of_an_added_fragment, + erlang_remote_call_into_an_added_module_past_the_tail_ceiling, + erlang_remote_call_follows_a_module_renamed_in_place_past_the_tail_ceiling, + mybatis_include_follows_a_namespace_renamed_in_place_past_the_tail_ceiling, + erlang_remote_call_into_a_deleted_module, + mybatis_include_of_a_deleted_fragment, + typescript_pr2288_same_named_methods_keep_typed_callers, + java_overloads_keep_their_callers, + jsx_component_body_edit, + swift_method_body_edit, + swift_extension_becomes_a_declaration, + swift_subclass_scope_follows_a_superclass_change_in_another_file, + swift_conformer_head_moves_an_extension_method_call, + swift_declaration_of_an_extended_type_appears, + swift_extension_beside_a_reference_keeps_the_declaration, + swift_type_path_call_follows_an_extension_path_change, + swift_type_path_call_reaches_an_extension_that_now_writes_its_path, + swift_type_path_call_reaches_a_member_its_type_gains, + swift_type_path_call_reaches_a_member_an_extension_gains_past_the_tail_ceiling, + swift_type_path_call_follows_a_second_namesake_an_extension_gains, + swift_type_path_call_drops_a_namesake_an_extension_loses, + swift_bare_call_follows_a_superclass_change_in_another_file, + swift_typed_property_follows_an_inherited_declaration, + go_embedded_method_follows_an_embedding_change_in_another_file, + go_field_chain_follows_a_field_type_change_in_another_file, + go_field_chain_reaches_a_retyped_field_past_the_tail_ceiling, + go_field_chain_follows_an_import_retarget_in_the_declaring_file, + go_embedded_method_follows_an_import_retarget_in_the_declaring_file, + go_unexported_receiver_reaches_a_method_its_package_gains, + go_qualified_type_reference_reaches_a_package_type_that_appears, + go_alias_method_follows_an_alias_target_change_in_another_file, + go_alias_chain_follows_a_middle_alias_change_in_another_file, + go_alias_method_reaches_the_aliased_type_once_the_alias_appears, + go_defined_type_reaches_a_type_its_package_gains, + go_external_qualified_reference_never_reaches_a_namesake_that_appears, + go_asserted_call_follows_an_embedding_change_in_another_file, + go_asserted_call_reaches_a_method_the_asserted_type_gains, + go_type_position_reaches_a_type_its_package_gains, + objc_self_send_follows_a_superclass_change_in_another_file, + objc_super_send_follows_the_header_of_its_class, + objc_typed_property_receiver_follows_a_declared_type_change, + objc_typed_property_receiver_reaches_an_owner_its_type_comes_to_inherit, + objc_typed_local_reaches_an_inherited_method_past_the_tail_ceiling, + objc_category_send_reaches_a_system_superclass_category_that_appears, + objc_namesake_protocol_keeps_its_own_header_when_a_copy_appears, + php_this_call_follows_an_ancestor_extends_change, + typescript_line_shift_above_a_function_keeps_namesake_edges_elsewhere, + php_line_shift_in_a_base_class_keeps_its_callers, + c_static_function_becomes_visible_with_a_line_added_above, + php_base_this_call_leaves_a_deeper_subclass_method_once_a_middle_class_is_rebased, + php_this_call_reaches_a_method_once_a_middle_class_extends_its_class, + php_this_call_follows_a_trait_use_change_in_an_ancestor, + php_parent_call_follows_an_import_retarget_in_an_ancestor_file, + php_this_call_leaves_a_repository_trait_once_the_outside_base_appears, + php_this_call_leaves_a_method_once_its_trait_becomes_a_class, + php_this_call_reaches_a_method_once_its_class_becomes_a_trait, + php_this_call_reaches_a_repository_trait_once_its_imported_base_is_deleted, + php_untyped_receiver_leaves_a_method_its_class_stops_inheriting, + php_untyped_receiver_reaches_an_inherited_method_past_the_tail_ceiling, + php_untyped_receiver_reaches_a_method_its_ancestor_gains_past_the_tail_ceiling, + php_untyped_receiver_leaves_a_method_a_seed_pushes_past_the_walk_limit, + php_bare_call_reaches_a_function_that_appears, + php_bare_class_name_reaches_a_class_added_to_its_namespace, + php_namespace_alias_instantiation_reaches_a_class_that_appears, + php_qualified_static_call_leaves_a_class_moved_to_another_namespace, + php_qualified_static_call_reaches_a_class_that_appears_past_the_tail_ceiling, + php_qualified_static_call_reaches_a_method_its_class_gains_past_the_tail_ceiling, + php_function_called_from_the_global_namespace_reaches_it_once_it_appears, + php_call_in_another_case_reaches_a_function_that_appears, + pascal_call_in_another_case_reaches_a_function_that_appears, + lua_function_made_local_leaves_its_callers_in_another_file, + lua_local_function_made_global_reaches_its_callers_in_another_file, + lua_alias_reaches_a_member_its_module_starts_exporting, + lua_alias_reaches_a_member_re_exported_from_a_module_two_away, + lua_alias_reaches_a_member_of_a_module_that_appears, + lua_alias_of_a_library_function_reaches_the_patch_that_appears, + lua_alias_of_a_library_function_leaves_the_patch_once_a_second_appears, + lua_alias_through_a_module_reaches_the_library_patch_that_appears, + lua_alias_through_a_changed_module_reaches_the_library_patch_that_appears, + lua_alias_of_a_host_table_member_reaches_the_method_that_appears, + lua_alias_through_a_commented_spread_or_multi_target_require_reaches_a_new_export, + lua_alias_reaches_a_new_export_through_a_middle_module_edited_in_the_same_sync, + lua_the_first_of_two_local_namesakes_made_global_reaches_its_callers_in_another_file, + lua_alias_follows_the_first_of_two_same_named_aliases_after_they_swap, + r_test_file_variable_stays_out_of_another_test_that_calls_its_name, + r_test_variable_moved_to_a_helper_reaches_the_other_tests, + r_base_named_binding_whose_line_gains_a_function_reaches_its_callers, + r_base_named_binding_whose_line_loses_its_function_leaves_its_callers, + r_bare_call_stays_off_an_objects_method_that_appears, + dart_bare_call_reaches_a_method_of_a_base_its_superclass_starts_to_extend, + dart_bare_call_leaves_a_method_of_a_base_its_superclass_stops_extending, + dart_bare_call_reaches_an_extension_member_once_it_is_on_the_callers_base, + dart_value_call_named_like_a_constructor_reaches_an_added_extension_member, + dart_value_call_named_like_a_constructor_leaves_a_removed_extension_member, + dart_bare_call_reaches_an_extension_member_once_its_superclass_extends_the_on_type, + dart_bare_call_reaches_a_member_whose_base_its_superclass_brings_nearer, + dart_bare_call_leaves_a_member_pushed_past_the_walk_limit, + dart_core_method_call_leaves_an_extension_moved_to_another_type, + dart_bare_call_leaves_an_extension_member_once_the_extension_is_unnamed, + dart_core_method_call_reaches_an_extension_moved_onto_its_type_past_the_tail_ceiling, + dart_export_show_gains_a_name_an_unchanged_importer_calls, + dart_export_hide_drops_a_name_an_unchanged_importer_calls, + dart_part_joins_its_library_and_shadows_an_imported_name, + dart_library_import_reaches_its_unchanged_part, + dart_library_import_leaves_its_unchanged_part, + dart_import_prefix_swap_moves_an_unchanged_parts_type, + dart_library_name_brings_in_an_unchanged_part, + dart_library_of_an_unchanged_part_appears, + dart_same_named_package_gains_its_first_dart_file, + dart_delete_a_part_that_shadowed_an_imported_name, + dart_unnamed_extension_reaches_a_part_once_its_file_joins_the_library, + dart_import_of_an_added_library_past_the_import_key_ceiling, + dart_two_barrels_trade_an_exported_name, + dart_library_directives_move_down_a_line, + dart_hidden_base_type_leaves_a_call_on_its_subclass, + dart_shown_base_type_brings_its_method_to_a_call_on_its_subclass_past_the_tail_ceiling, + dart_library_import_brings_a_base_method_to_a_call_on_its_parts_subclass, + dart_getter_read_reaches_a_getter_its_type_gains, + dart_getter_read_reaches_a_getter_its_type_gains_past_the_tail_ceiling, + dart_getter_read_leaves_a_getter_its_type_drops, + dart_getter_read_through_an_inherited_field_follows_its_retyping, + dart_getter_read_follows_its_receiver_types_new_base, + dart_call_on_an_enum_reaches_a_member_an_added_extension_brings, + dart_call_on_an_enum_reaches_a_member_an_added_extension_brings_past_the_tail_ceiling, + dart_getter_read_reaches_a_getter_an_added_extension_brings_past_the_tail_ceiling, + dart_call_through_a_type_reaches_the_static_constant_it_gains, + dart_call_through_a_type_reaches_the_static_constant_it_gains_past_the_tail_ceiling, + dart_initializer_calls_reach_a_function_added_in_another_file, + dart_field_and_extension_types_reach_a_type_added_in_another_file, + dart_getter_read_through_a_deleted_type_links_nothing, + dart_getter_read_through_a_type_that_appears_reaches_its_base, + dart_call_through_a_type_that_appears_reaches_its_member_past_the_tail_ceiling, + dart_getter_read_through_an_inherited_field_follows_its_types_new_base, + dart_getter_read_leaves_a_base_of_its_own_file_its_receivers_type_stops_extending, + dart_getter_read_through_a_field_its_class_inherits_from_a_new_base, + dart_getter_read_reaches_a_getter_an_existing_extension_gains_past_the_tail_ceiling, + dart_chain_link_follows_a_return_type_retyped_in_another_file, + dart_chain_link_follows_a_top_level_functions_return_type, + dart_chain_link_follows_a_static_constants_initializer, + dart_chain_link_follows_the_end_of_a_long_top_level_initializer, + dart_chain_link_after_a_universal_extension_it_gains, + dart_chain_link_leaves_a_method_that_becomes_a_setter, + dart_chain_link_follows_a_methods_own_type_parameters, + dart_chain_link_through_fields_of_a_call_result_follows_a_retyping, + dart_chain_link_through_a_local_follows_its_initializers_return_type, + dart_chain_link_follows_a_retyped_call_method, + dart_chain_link_through_a_constant_follows_the_function_its_initializer_calls, + dart_chain_link_through_a_field_initializer_follows_the_function_it_calls, + dart_chain_link_after_an_extension_getter_its_receiver_gains, + dart_chain_link_leaves_a_member_a_base_of_an_inner_type_drops, + dart_chain_link_after_a_universal_extension_it_gains_past_the_tail_ceiling, + dart_chain_link_follows_a_class_head_that_stops_declaring_a_type_parameter, + dart_chain_link_through_a_local_of_a_deleted_type_links_nothing, + dart_chain_link_reaches_an_unnamed_extension_once_its_file_joins_the_library, + dart_chain_link_in_a_type_follows_a_base_its_base_gains, + dart_chain_link_leaves_an_extension_that_moves_to_another_type, + dart_chain_link_through_a_constant_an_extension_types_leaves_with_it, + dart_chain_link_through_a_function_typed_by_a_class_whose_head_changes, + dart_chain_link_through_a_static_constant_follows_a_part_joining_its_library, + dart_chain_link_headed_by_a_local_follows_a_type_of_its_name, + dart_chain_link_reads_a_field_its_type_gains_through_a_base, + dart_chain_link_reaches_an_extension_on_a_base_its_type_gains, + dart_chain_link_reaches_an_extension_on_any_type_once_its_type_comes, + dart_parameter_call_links_nothing_when_a_namesake_appears, + dart_handler_parameter_call_links_nothing_when_its_base_gains_the_method, + dart_local_function_call_keeps_its_local_when_a_namesake_appears, + dart_call_through_a_type_reaches_a_const_constructor_it_gains, + dart_bare_call_leaves_a_method_that_becomes_a_constructor, + dart_bare_call_reaches_a_constructor_that_becomes_a_method, + dart_call_through_a_type_reaches_a_factory_added_below_a_getter_of_its_name, + dart_prefixed_call_reaches_a_name_its_barrel_starts_to_show, + dart_prefixed_call_leaves_a_name_its_barrel_stops_showing, + dart_prefixed_call_in_a_part_follows_a_prefix_its_library_gains, + dart_chain_through_a_prefix_reaches_the_member_its_type_gains, + dart_annotation_reaches_a_constant_its_imported_library_gains, + dart_prefixed_call_reaches_a_function_its_library_gains_past_the_tail_ceiling, + dart_prefixed_call_reaches_a_name_its_barrel_starts_to_show_past_the_tail_ceiling, + dart_pubspec_rename_strands_a_package_import, + dart_pubspec_path_dependency_picks_a_same_named_package, + dart_pubspec_overrides_path_dependency_picks_a_same_named_package, + dart_ignored_pubspec_overrides_path_dependency_picks_a_same_named_package, + dart_pubspec_appears_and_its_folder_becomes_a_package, + dart_pubspec_deleted_strands_a_package_import, + php_call_in_another_case_leaves_a_function_once_a_namesake_in_another_case_appears, + php_function_called_in_another_case_from_the_global_namespace_reaches_it_once_it_appears, + php_inherited_static_call_follows_a_class_head_spread_past_its_third_line, + python_inherited_classmethod_follows_a_base_change_in_another_file, + python_inherited_classmethod_follows_a_base_change_beside_its_old_target, + java_inherited_static_call_follows_a_base_change_in_another_file, + csharp_inherited_static_call_follows_a_base_change_in_another_file, + swift_inherited_class_method_follows_a_base_change_in_another_file, + ruby_class_method_follows_a_base_change_in_another_file, + kotlin_class_name_call_follows_a_base_change_in_another_file, + dart_class_name_call_reaches_no_inherited_static_member, + scala_object_call_follows_a_base_change_in_another_file, + objc_class_message_follows_a_base_change_in_another_file, + pascal_class_function_call_follows_a_base_change_in_another_file, + typescript_static_call_through_an_imported_subclass_reads_no_head, + python_inherited_classmethod_leaves_a_deleted_base, + python_inherited_classmethod_follows_a_middle_class_base_change, + python_inherited_classmethod_leaves_a_base_pushed_past_five_levels, + python_parked_class_method_reaches_a_base_its_middle_class_gains, + python_inherited_classmethod_follows_a_base_two_levels_above_the_changed_head, + typescript_value_reader_body_edit, + astro_frontmatter_call_body_edit, + go_value_initializer_body_edit, + cpp_scoped_read_body_edit, + java_static_field_read_body_edit, + ruby_class_constant_reader_body_edit, + kotlin_object_constant_reader_body_edit, + cpp_qualified_call_body_edit, + vue_component_template_edit, + liquid_snippet_body_edit, + luau_module_function_body_edit, + vue_script_function_body_edit, + svelte_instance_script_call_edit, + ruby_receiver_call_body_edit, + objc_implementation_body_edit, + gdscript_autoload_method_body_edit, + typescript_rename_an_imported_function, + javascript_rename_a_function_used_by_import_and_require, + cpp_rename_a_member_function, + csharp_rename_a_called_method, + svelte_rename_an_imported_function, + pascal_rename_a_unit_function, + scala_rename_a_called_method, + terraform_rename_a_variable, + erlang_rename_a_remotely_called_function, + erlang_imported_call_reaches_a_function_its_module_gains, + erlang_bare_call_reaches_a_function_its_header_gains, + erlang_imported_call_reaches_a_function_its_module_gains_past_the_tail_ceiling, + mybatis_rename_an_included_fragment, + typescript_delete_an_imported_module, + rust_delete_a_used_module, + rust_glob_module_gains_an_item, + rust_out_of_scope_namesake_appears, + rust_prelude_name_ignores_a_project_namesake_that_appears, + rust_glob_variant_leaves_when_its_enum_is_renamed, + rust_variant_path_reaches_the_enum_once_it_declares_the_variant, + rust_variant_path_leaves_the_enum_when_the_variant_is_renamed, + rust_outside_type_receiver_reaches_a_type_the_project_gains, + rust_outside_type_receiver_leaves_when_the_project_type_is_renamed, + rust_alias_receiver_reaches_a_method_once_the_alias_appears, + rust_alias_receiver_leaves_the_method_when_the_alias_goes, + rust_cargo_dependency_appears, + rust_cargo_dependency_leaves, + rust_workspace_member_becomes_a_project_crate, + rust_first_source_under_a_nested_manifest_makes_its_crate_the_projects, + rust_last_source_under_a_nested_manifest_leaves_its_crate_outside, + dart_delete_an_imported_library, + astro_delete_an_imported_module, + lua_delete_a_required_module, + nix_delete_an_imported_file, + python_move_an_imported_module_away, + typescript_change_a_superclass, + java_change_a_superclass_and_interface, + java_bare_call_follows_a_base_change_in_another_file, + java_enum_constant_body_edit, + java_lombok_data_appears_on_a_class, + java_lombok_hand_written_getter_replaces_the_generated_one, + java_lombok_field_removal_drops_its_accessor, + kotlin_enum_entry_body_edit, + kotlin_bare_call_follows_a_base_change_in_another_file, + kotlin_receiver_type_appears_in_another_file, + kotlin_lambda_receiver_changes_in_another_file, + kotlin_import_package_appears_in_the_project, + kotlin_gradle_script_owner_becomes_a_project_class, + kotlin_platform_ancestry_chain_follows_a_project_head_change, + kotlin_base_body_edit_keeps_a_bare_call, + kotlin_batch_rewrites_a_base_and_a_middle_type, + kotlin_lambda_receiver_changes_through_a_typealias, + kotlin_gradle_script_follows_a_project_head_change, + kotlin_infix_function_appears_in_another_file, + kotlin_number_bitwise_extension_appears_in_another_file, + kotlin_when_guard_keeps_the_class_around_it, + java_nested_type_follows_a_base_change_in_another_file, + java_nested_type_ignores_an_added_namesake, + java_import_package_appears_in_the_project, + java_import_package_leaves_the_project, + java_import_package_is_renamed_away, + java_bare_call_follows_a_same_named_head_in_another_file, + java_anonymous_class_follows_an_interface_head_change, + java_head_window_text_moves_a_subclass_bare_call, + java_batch_rewrites_a_base_and_a_middle_type, + java_same_named_heads_trade_places_at_the_walk_limit, + csharp_base_list_gains_an_interface, + objc_change_a_superclass, + python_change_a_base_class, + php_change_a_parent_class, + go_change_an_embedded_struct, + dart_change_a_mixin, + scala_change_a_parent_trait, + scala_bare_call_follows_a_base_change_in_another_file, + scala_imported_package_object_changes_its_supertype, + scala_package_object_is_renamed_under_its_members, + scala_chain_link_type_changes_its_package, + scala_member_val_becomes_block_local, + scala_function_becomes_an_extension_method, + scala_type_reference_follows_a_namesake_head_in_another_file, + scala_package_object_takes_an_imported_name, + scala_chain_link_type_moves_into_the_named_package, + scala_block_local_val_becomes_a_member, + scala_base_body_edit_keeps_a_bare_call, + scala_batch_rewrites_a_base_and_a_middle_type, + scala_same_named_vals_trade_block_and_member_states, + scala_holder_change_reaches_past_the_tail_ceiling, + scala_package_object_supertype_head_changes_in_another_file, + csharp_global_using_appears_for_its_project, + csharp_global_using_leaves_its_project, + csharp_csproj_using_appears, + csharp_partial_part_gains_a_base, + csharp_middle_type_drops_its_base, + csharp_nested_owner_becomes_a_namespace, + csharp_type_moves_into_an_imported_namespace, + csharp_type_position_ignores_an_added_property, + csharp_bare_call_follows_a_base_change_in_another_file, + csharp_bare_call_follows_a_long_head_in_another_file, + csharp_global_using_static_appears, + csharp_global_using_static_leaves, + csharp_csproj_static_using_appears, + csharp_props_using_appears, + csharp_implicit_usings_turn_on, + csharp_new_csproj_hides_another_projects_global_using, + csharp_deleted_csproj_lets_another_projects_global_using_in, + csharp_renamed_project_file_keeps_its_using, + csharp_upper_case_files_read_their_project_file, + csharp_bare_name_ignores_an_added_property, + csharp_inherited_member_type_changes_in_another_file, + csharp_inherited_member_body_edit, + java_inherited_field_type_changes_in_another_file, + kotlin_inherited_property_type_changes_in_another_file, + csharp_middle_type_argument_changes_in_another_file, + java_type_parameter_bound_changes_in_another_file, + csharp_accessor_body_call_of_an_added_class, + csharp_field_initializer_body_edit, + csharp_target_typed_new_of_an_added_class, + csharp_type_position_ignores_an_added_constant, + ruby_bare_call_follows_a_mixin_added_in_another_file, + ruby_bare_call_follows_a_superclass_change_in_another_file, + ruby_mixin_resolves_to_a_nearer_module_that_appears, + ruby_bare_call_ignores_an_added_unrelated_method, + ruby_untyped_receiver_ignores_a_lone_added_method, + ruby_ancestor_method_body_edit, + solidity_change_a_base_contract, + solidity_bare_call_reaches_a_function_its_chain_comes_to_inherit, + solidity_bare_call_leaves_a_function_its_chain_stops_inheriting, + solidity_bare_call_follows_a_namesake_base_that_appears, + cfml_change_a_base_component, + cfml_dotted_extends_of_an_added_component, + cfml_dotted_extends_takes_a_better_corroborated_component, + cfml_relative_extends_of_an_added_component, + cfml_typed_receiver_reaches_a_method_its_type_gains, + cfml_typed_receiver_namesake_appears, + cfml_typed_receiver_namesake_leaves, + cfml_typed_receiver_namesake_leaves_past_the_tail_ceiling, + cfml_typed_receiver_takes_a_better_corroborated_namesake, + cfml_script_tag_method_body_edit, + cfml_query_body_in_a_wrapped_method_edit, + cfml_script_tag_body_nested_past_the_depth_limit_drops_the_file, + cfml_query_body_nested_past_the_depth_limit_drops_the_file, + cfml_implements_an_added_interface_tag, + cfml_bare_call_follows_an_extends_change_in_another_file, + cfml_bare_extends_takes_a_component_that_appears_beside_it, + cfml_bare_call_ignores_an_added_built_in_namesake, + cfml_untyped_receiver_ignores_a_lone_added_method, + cfml_receiver_named_after_an_added_component, + cfml_inherited_method_body_edit, + python_add_a_same_named_competitor, + arkts_add_a_same_named_competitor, + kotlin_add_a_same_named_competitor, + ruby_add_a_same_named_competitor, + php_add_a_same_named_competitor, + php_use_of_a_namespace_gains_a_same_named_import_elsewhere, + tsx_catch_all_alias_ignores_a_same_named_project_component, + typescript_config_toml_excludes_a_competitor, + python_root_gitignore_hides_a_competitor, + python_repository_exclude_hides_a_competitor, + lua_extension_override_appears_in_codegraph_json, + typescript_tsconfig_paths_alias_appears, + typescript_tsconfig_past_64_mib_replaced_with_its_size_and_mtime_kept, + typescript_react_dependency_appears_in_package_json, + javascript_jsconfig_paths_alias_appears, + typescript_tsconfig_paths_alias_disappears, + typescript_tsconfig_extends_target_appears, + typescript_paths_retarget_moves_a_barrel_source, + typescript_react_dependency_leaves_package_json, + typescript_declared_dependency_makes_a_bare_import_external, + typescript_workspace_member_manifest_renames_its_package, + typescript_workspace_member_manifest_is_deleted, + typescript_workspace_glob_gains_a_member, + tauri_config_marker_appears, + tauri_command_attribute_makes_an_invoke_call_its_edge, + typescript_package_version_bump_keeps_every_edge, + liquid_json_template_joins_a_theme_once_its_settings_schema_appears, + liquid_json_templates_leave_once_the_theme_layout_goes, + liquid_json_template_in_a_mixed_case_folder_joins_a_marked_theme, + liquid_json_template_joins_a_theme_through_an_ignored_settings_schema, + liquid_json_template_leaves_once_an_ignored_settings_schema_goes, + liquid_render_stays_in_a_folder_once_it_is_marked_a_theme, + liquid_render_leaves_a_folder_theme_once_its_marker_goes, + liquid_json_template_loses_its_own_themes_section, + liquid_json_template_gains_its_own_themes_section, + c_header_flips_to_cpp, + pascal_include_flips_from_php_and_its_routine_is_called, + pascal_include_flips_to_php_and_its_routine_leaves, + javascript_file_gains_the_flow_pragma, +} + +/// An index built before the module-surface rows existed (W4a-F3) can hold +/// an edge an older sync left stale: such a sync re-extracted a barrel that +/// came to forward another module and never refreshed the barrel's importers. +/// The test fakes that index (the barrel's file row says its new text, its +/// importer's edge still goes the old way, no marker and no rows), then an +/// unrelated edit is synced: both drivers must reconcile every +/// JavaScript-family file, match a fresh index, and leave the rows and marker +/// a fresh index writes. +#[test] +fn surface_bootstrap_reconciles_an_index_built_before_the_rows() { + const A: &str = "export function safeParse(value: unknown) {\n return value;\n}\n"; + const B: &str = "export function safeParse(value: unknown) {\n return [value];\n}\n"; + const USE: &str = "import { safeParse } from './index';\n\nexport function check() {\n return safeParse(1);\n}\n"; + const TO_B: &str = "export * from './b';\n"; + const OTHER: &str = "export const other = 2;\n"; + let before = [ + ("src/a.ts", A), + ("src/b.ts", B), + ("src/index.ts", "export * from './a';\n"), + ("src/use.ts", USE), + ("src/other.ts", "export const other = 1;\n"), + ]; + let index_stale = |label: &str| { + let mut project = before + .iter() + .fold( + support::Project::named(label), + |project, (path, contents)| project.file(path, contents), + ) + .index(); + project.write("src/index.ts", TO_B); + let db = project.db(); + let disk = fs::metadata(project.root().join("src/index.ts")).unwrap(); + let modified = disk + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let conn = rusqlite::Connection::open(&db).unwrap(); + let updated = conn + .execute( + "UPDATE files SET content_hash = ?1, size = ?2, modified_at = ?3 WHERE path = 'src/index.ts'", + rusqlite::params![ + codegraph_core::node_id::hash_content(TO_B), + TO_B.len() as i64, + modified + ], + ) + .unwrap(); + assert_eq!(updated, 1); + conn.execute( + "DELETE FROM project_metadata WHERE substr(key, 1, 17) = 'js_module_surface'", + [], + ) + .unwrap(); + conn.close().unwrap(); + project.reload(); + assert!( + !project + .graph() + .edges_matching("function check", "calls", "function safeParse @src/a.ts") + .is_empty(), + "the faked index keeps the stale edge\n{}", + project.graph().report() + ); + project.write("src/other.ts", OTHER); + project + }; + + let mut cli = index_stale("cli"); + cli.sync(); + let mut watch = index_stale("watch"); + watcher_sync(&watch, &["src/other.ts"]); + watch.reload(); + + let fresh = [ + ("src/a.ts", A), + ("src/b.ts", B), + ("src/index.ts", TO_B), + ("src/use.ts", USE), + ("src/other.ts", OTHER), + ] + .iter() + .fold( + support::Project::named("fresh"), + |project, (path, contents)| project.file(path, contents), + ) + .index(); + let expected = canonicalize_db(&fresh.db()).expect("canonicalize the fresh index"); + for (driver, project) in [("cli", &cli), ("watch", &watch)] { + let actual = canonicalize_db(&project.db()).expect("canonicalize the synced index"); + if let Err(diff) = diff_canonical(&expected, &actual, None) { + panic!( + "[{driver}] the reconciled index differs from a fresh one\n{}{diff}", + readable_difference(fresh.graph(), project.graph()), + ); + } + } + let surface_rows = support::project_metadata_with_prefix(&fresh.db(), "js_module_surface"); + assert!( + surface_rows + .iter() + .any(|(key, _)| key == "js_module_surface_version"), + "{surface_rows:?}" + ); + for (driver, project) in [("cli", &cli), ("watch", &watch)] { + assert_eq!( + support::project_metadata_with_prefix(&project.db(), "js_module_surface"), + surface_rows, + "[{driver}] module-surface rows" + ); + } +} + +#[test] +fn sync_matrix_covers_every_extracted_language() { + let mut running = BTreeMap::<&str, Vec<&str>>::new(); + for (name, gap, case) in registered() { + if gap.is_none() { + for language in case.languages { + running.entry(language.as_str()).or_default().push(name); + } + } + } + let missing = extracted_languages() + .into_iter() + .map(Language::as_str) + .filter(|language| !running.contains_key(language)) + .collect::>(); + assert!( + missing.is_empty(), + "languages with an extractor but no running sync convergence case: {missing:?}\n\ + add a case to tests/sync_convergence/cases.rs; covered so far: {running:#?}" + ); +} + +#[test] +fn sync_matrix_covers_every_change_kind() { + let running = registered() + .into_iter() + .filter(|(_, gap, _)| gap.is_none()) + .map(|(_, _, case)| case.change) + .collect::>(); + let missing = Change::ALL + .into_iter() + .filter(|change| !running.contains(change)) + .collect::>(); + assert!( + missing.is_empty(), + "change kinds with no running sync convergence case: {missing:?}" + ); +} diff --git a/crates/codegraph-cli/tests/upgrade_from_schema_9.rs b/crates/codegraph-cli/tests/upgrade_from_schema_9.rs new file mode 100644 index 00000000..12705b00 --- /dev/null +++ b/crates/codegraph-cli/tests/upgrade_from_schema_9.rs @@ -0,0 +1,328 @@ +//! Migrating a current index one schema behind (W5-09e). +//! +//! `tests/fixtures/upgrade_schema_9/codegraph.db` is the schema-9 database a +//! release build of 3878d52 (extraction version 22, the current one) wrote for +//! the mini corpus. Each test plants it in a fresh project namespace as a +//! `Current` index, edits a file other files depend on, and syncs it through +//! one of the two incremental paths: `codegraph sync` from the shell, and the +//! watcher's sync of the changed paths. Both must migrate the schema to the +//! current one in place, without a rebuild, leave the stored-references marker +//! unwritten (the migration creates `resolved_refs` empty, so the dependents +//! are extracted), and leave a canonical graph equal to a fresh `init` of the +//! edited tree. A full `index` afterwards writes the marker. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use codegraph_bench::oracle::{canonicalize_db, diff_canonical}; +use codegraph_core::IndexPaths; +use codegraph_store::migrations::CURRENT_SCHEMA_VERSION; +use codegraph_store::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store, checksum_hex, +}; + +/// The edit: one line above everything `src/app.ts` imports from it. +const MATH_EDITED: &str = "// one line down\nexport function add(left: number, right: number): number {\n return left + right;\n}\n\nexport class Counter {\n private value = 0;\n\n increment(step: number = 1): number {\n this.value = add(this.value, step);\n return this.value;\n }\n}\n"; + +#[test] +fn cli_sync_migrates_a_schema_9_index_in_place() { + let dir = TestDir::new("cli"); + let project = plant_schema_9_index(&dir); + fs::write(project.join("src/math.ts"), MATH_EDITED).unwrap(); + + let sync = cli(&["sync", path_str(&project)]); + assert!( + sync.ok, + "sync over a schema-9 index failed:\nstdout:\n{}\nstderr:\n{}", + sync.stdout, sync.stderr + ); + let report = format!("{}{}", sync.stdout, sync.stderr); + assert!( + report.contains("1 reindexed, 2 skipped (unchanged)"), + "an incremental sync, not a rebuild: {report}" + ); + + assert_migrated_like_a_fresh_init(&project, "cli"); + assert_full_index_writes_the_marker(&project); +} + +#[test] +fn watcher_sync_migrates_a_schema_9_index_in_place() { + let dir = TestDir::new("watcher"); + let project = plant_schema_9_index(&dir); + fs::write(project.join("src/math.ts"), MATH_EDITED).unwrap(); + + let db = index_paths(&project).current_db(); + let outcome = codegraph_watch::sync_changed_paths(&project, &db, ["src/math.ts"]) + .expect("watcher sync over a schema-9 index"); + assert_eq!(outcome.files_reindexed, 1, "an incremental sync"); + assert_eq!(outcome.changed_paths, ["src/math.ts"]); + + assert_migrated_like_a_fresh_init(&project, "watcher"); + assert_full_index_writes_the_marker(&project); +} + +/// Copy the mini corpus into `dir`, create a namespace with the current binary, +/// then replace its database with the schema-9 one and publish it as a current +/// index of this extraction version. +fn plant_schema_9_index(dir: &TestDir) -> PathBuf { + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + + let paths = index_paths(&project); + let db = paths.current_db(); + for sidecar in ["-wal", "-shm"] { + let _ = fs::remove_file(PathBuf::from(format!("{}{sidecar}", db.display()))); + } + fs::copy(schema_9_database(), &db).expect("plant the schema-9 database"); + stage_state_slot( + &paths, + 2, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION, + "current", + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "the planted namespace must classify as current" + ); + let conn = open_immutable(&db); + assert_eq!(schema_version(&conn), 9, "the fixture is a schema-9 index"); + assert!(!has_table(&conn, "resolved_refs")); + project +} + +fn assert_migrated_like_a_fresh_init(project: &Path, label: &str) { + let paths = index_paths(project); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "the migrated namespace must stay current" + ); + let conn = open_immutable(&paths.current_db()); + assert_eq!( + schema_version(&conn), + CURRENT_SCHEMA_VERSION, + "schema must be current" + ); + assert!(has_table(&conn, "resolved_refs"), "migration 10 ran"); + assert_eq!( + marker(&conn), + None, + "a migrated index is not trusted for stored references" + ); + drop(conn); + + let peer_dir = TestDir::new(&format!("{label}-fresh")); + let peer = peer_dir.path().join("project"); + copy_tree(&mini_fixture(), &peer); + fs::write(peer.join("src/math.ts"), MATH_EDITED).unwrap(); + let init = cli(&["init", path_str(&peer)]); + assert!(init.ok, "fresh init failed: {}", init.stderr); + + let mut migrated = canonicalize_db(&paths.current_db()).expect("canonicalize migrated index"); + let fresh = + canonicalize_db(&index_paths(&peer).current_db()).expect("canonicalize fresh index"); + // A migration creates its objects after the ones already there, so only + // the order of the schema dump differs from a fresh index's. + let statements = |schema: &str| { + let mut statements = schema.split(";\n").map(str::to_string).collect::>(); + statements.sort(); + statements + }; + assert_eq!( + statements(&migrated.schema), + statements(&fresh.schema), + "the migrated schema holds a fresh index's objects" + ); + migrated.schema.clone_from(&fresh.schema); + if let Err(report) = diff_canonical(&fresh, &migrated, None) { + panic!("the migrated index differs from a fresh init:\n{report}"); + } +} + +fn assert_full_index_writes_the_marker(project: &Path) { + let index = cli(&["index", path_str(project)]); + assert!(index.ok, "index failed: {}", index.stderr); + let conn = open_immutable(&index_paths(project).current_db()); + assert!( + marker(&conn).is_some_and(|marker| marker.starts_with("1:")), + "a full index writes the marker" + ); +} + +/// The stored-references marker (`codegraph_watch::resolved_refs::VERSION_KEY`). +fn marker(conn: &rusqlite::Connection) -> Option { + use rusqlite::OptionalExtension; + conn.query_row( + "SELECT value FROM project_metadata WHERE key = ?1", + [codegraph_watch::resolved_refs::VERSION_KEY], + |row| row.get(0), + ) + .optional() + .expect("read the marker") +} + +fn schema_version(conn: &rusqlite::Connection) -> i64 { + conn.query_row("SELECT MAX(version) FROM schema_versions", [], |row| { + row.get(0) + }) + .expect("schema version") +} + +fn has_table(conn: &rusqlite::Connection, name: &str) -> bool { + conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + [name], + |row| row.get::<_, i64>(0), + ) + .expect("read sqlite_master") + > 0 +} + +/// `immutable=1` takes no lock and leaves no `-wal`/`-shm` beside the index. +fn open_immutable(db: &Path) -> rusqlite::Connection { + rusqlite::Connection::open_with_flags( + format!("{}?immutable=1", sqlite_file_uri(db)), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("open index") +} + +/// `path` as an absolute SQLite `file:` URI: a Windows verbatim prefix (`\\?\`) +/// is dropped and every byte outside the unreserved set is percent-encoded. +fn sqlite_file_uri(path: &Path) -> String { + let text = path.to_string_lossy().replace('\\', "/"); + let text = text.strip_prefix("//?/").unwrap_or(&text); + let mut uri = String::from("file://"); + if !text.starts_with('/') { + uri.push('/'); + } + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || b"/-._~:".contains(&byte) { + uri.push(char::from(byte)); + } else { + uri.push_str(&format!("%{byte:02X}")); + } + } + uri +} + +fn mini_fixture() -> PathBuf { + workspace_root().join("crates/codegraph-bench/fixtures/mini") +} + +fn schema_9_database() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/upgrade_schema_9/codegraph.db") +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn index_paths(project: &Path) -> IndexPaths { + IndexPaths::resolve(project, None).expect("resolve index paths") +} + +fn path_str(path: &Path) -> &str { + path.to_str().expect("utf-8 test path") +} + +/// Author one authoritative state slot and remove its companion, the same way +/// `upgrade_from_v054.rs` stages protocol records. +fn stage_state_slot( + paths: &IndexPaths, + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, +) { + let identity = paths.project_identity(); + let checksum = checksum_hex( + sequence, + storage_protocol, + extraction_version, + phase, + identity, + ); + let body = format!( + "{{\"sequence\":{sequence},\"storageProtocol\":{storage_protocol},\ + \"extractionVersion\":{extraction_version},\"phase\":\"{phase}\",\ + \"projectIdentity\":\"{identity}\",\"checksum\":\"{checksum}\"}}\n" + ); + let [slot0, slot1] = paths.state_slots(); + fs::write(&slot0, body).expect("stage authoritative state slot"); + let _ = fs::remove_file(&slot1); +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +fn cli(args: &[&str]) -> Run { + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(args) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env_remove("CODEGRAPH_DIR") + .output() + .expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +fn copy_tree(src: &Path, dst: &Path) { + fs::create_dir_all(dst).unwrap(); + for entry in fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + fs::copy(&from, &to).unwrap(); + } + } +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-upgrade-schema-9-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} diff --git a/crates/codegraph-cli/tests/upgrade_from_v054.rs b/crates/codegraph-cli/tests/upgrade_from_v054.rs new file mode 100644 index 00000000..d41d832d --- /dev/null +++ b/crates/codegraph-cli/tests/upgrade_from_v054.rs @@ -0,0 +1,272 @@ +//! Upgrading an index the previous release wrote. +//! +//! `tests/fixtures/upgrade_v0_54/codegraph.db` is the schema-8 database the +//! released v0.54.0 binary (extraction version 21) wrote for the mini corpus. +//! Each test plants it in a fresh project namespace, stages the state slot that +//! release publishes (`phase=current`, extraction version 21), and upgrades it +//! through one of the two paths a user hits: `codegraph sync` from the shell, and +//! the catch-up sync a daemon or MCP session runs when it first opens the +//! project. Both must classify the namespace as outdated, rebuild it from source +//! exactly once, and leave a current schema-9 index whose canonical graph equals +//! a fresh `init` of the same tree. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use codegraph_bench::oracle::{canonicalize_db, diff_canonical}; +use codegraph_core::IndexPaths; +use codegraph_store::migrations::CURRENT_SCHEMA_VERSION; +use codegraph_store::{ + CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, ExtractionStatus, Store, checksum_hex, +}; + +/// Extraction version the v0.54.0 release published. +const V0_54_EXTRACTION_VERSION: u64 = 21; + +#[test] +fn cli_sync_upgrades_a_v0_54_index_to_the_current_schema_and_extraction() { + let dir = TestDir::new("cli"); + let project = plant_v0_54_index(&dir); + + let status = cli(&["status", path_str(&project), "--json"]); + assert!( + status.ok, + "status on a v0.54.0 index failed: {}", + status.stderr + ); + let json: serde_json::Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!( + json["extractionStatus"], "outdated", + "a v0.54.0 index must read as outdated: {}", + status.stdout + ); + + let sync = cli(&["sync", path_str(&project)]); + assert!( + sync.ok, + "sync over a v0.54.0 index failed:\nstdout:\n{}\nstderr:\n{}", + sync.stdout, sync.stderr + ); + + assert_upgraded_like_a_fresh_init(&project, "cli"); +} + +#[test] +fn catch_up_sync_upgrades_a_v0_54_index_to_the_current_schema_and_extraction() { + let dir = TestDir::new("catch-up"); + let project = plant_v0_54_index(&dir); + + // The catch-up a daemon or MCP session runs when it first opens a project. + codegraph_watch::sync_project_once(&project).expect("catch-up sync over a v0.54.0 index"); + + assert_upgraded_like_a_fresh_init(&project, "catch-up"); +} + +/// Copy the mini corpus into `dir`, create a namespace with the current binary, +/// then replace its database and state slot with what v0.54.0 left behind. +fn plant_v0_54_index(dir: &TestDir) -> PathBuf { + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + + let paths = index_paths(&project); + let db = paths.current_db(); + for sidecar in ["-wal", "-shm"] { + let _ = fs::remove_file(PathBuf::from(format!("{}{sidecar}", db.display()))); + } + fs::copy(v0_54_database(), &db).expect("plant the v0.54.0 database"); + stage_state_slot( + &paths, + 2, + CURRENT_STORAGE_PROTOCOL, + V0_54_EXTRACTION_VERSION, + "current", + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Outdated { + built: V0_54_EXTRACTION_VERSION + }, + "the planted namespace must classify as outdated before the upgrade" + ); + project +} + +fn assert_upgraded_like_a_fresh_init(project: &Path, label: &str) { + let paths = index_paths(project); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Current, + "the upgraded namespace must be current" + ); + // `immutable=1` takes no lock and leaves no `-wal`/`-shm` beside the index. + let conn = rusqlite::Connection::open_with_flags( + format!("{}?immutable=1", sqlite_file_uri(&paths.current_db())), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("open upgraded database"); + let schema: i64 = conn + .query_row("SELECT MAX(version) FROM schema_versions", [], |row| { + row.get(0) + }) + .expect("schema version"); + assert_eq!(schema, CURRENT_SCHEMA_VERSION, "schema must be current"); + let stamp: String = conn + .query_row( + "SELECT value FROM project_metadata WHERE key = 'indexed_with_extraction_version'", + [], + |row| row.get(0), + ) + .expect("extraction stamp"); + assert_eq!(stamp, CURRENT_EXTRACTION_VERSION.to_string()); + drop(conn); + + let peer_dir = TestDir::new(&format!("{label}-fresh")); + let peer = peer_dir.path().join("project"); + copy_tree(&mini_fixture(), &peer); + let init = cli(&["init", path_str(&peer)]); + assert!(init.ok, "fresh init failed: {}", init.stderr); + + let upgraded = canonicalize_db(&paths.current_db()).expect("canonicalize upgraded index"); + let fresh = + canonicalize_db(&index_paths(&peer).current_db()).expect("canonicalize fresh index"); + if let Err(report) = diff_canonical(&fresh, &upgraded, None) { + panic!("the upgraded index differs from a fresh init:\n{report}"); + } +} + +/// `path` as an absolute SQLite `file:` URI: a Windows verbatim prefix (`\\?\`) +/// is dropped and every byte outside the unreserved set is percent-encoded. +fn sqlite_file_uri(path: &Path) -> String { + let text = path.to_string_lossy().replace('\\', "/"); + let text = text.strip_prefix("//?/").unwrap_or(&text); + let mut uri = String::from("file://"); + if !text.starts_with('/') { + uri.push('/'); + } + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || b"/-._~:".contains(&byte) { + uri.push(char::from(byte)); + } else { + uri.push_str(&format!("%{byte:02X}")); + } + } + uri +} + +fn mini_fixture() -> PathBuf { + workspace_root().join("crates/codegraph-bench/fixtures/mini") +} + +fn v0_54_database() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/upgrade_v0_54/codegraph.db") +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("codegraph-cli is under crates/") + .to_path_buf() +} + +fn index_paths(project: &Path) -> IndexPaths { + IndexPaths::resolve(project, None).expect("resolve index paths") +} + +fn path_str(path: &Path) -> &str { + path.to_str().expect("utf-8 test path") +} + +/// Author one authoritative state slot and remove its companion, the same way +/// `batch_m_outdated_migration.rs` stages protocol records. +fn stage_state_slot( + paths: &IndexPaths, + sequence: u64, + storage_protocol: u64, + extraction_version: u64, + phase: &str, +) { + let identity = paths.project_identity(); + let checksum = checksum_hex( + sequence, + storage_protocol, + extraction_version, + phase, + identity, + ); + let body = format!( + "{{\"sequence\":{sequence},\"storageProtocol\":{storage_protocol},\ + \"extractionVersion\":{extraction_version},\"phase\":\"{phase}\",\ + \"projectIdentity\":\"{identity}\",\"checksum\":\"{checksum}\"}}\n" + ); + let [slot0, slot1] = paths.state_slots(); + fs::write(&slot0, body).expect("stage authoritative state slot"); + let _ = fs::remove_file(&slot1); +} + +struct Run { + stdout: String, + stderr: String, + ok: bool, +} + +fn cli(args: &[&str]) -> Run { + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(args) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env_remove("CODEGRAPH_DIR") + .output() + .expect("run codegraph binary"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + ok: output.status.success(), + } +} + +fn copy_tree(src: &Path, dst: &Path) { + fs::create_dir_all(dst).unwrap(); + for entry in fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if from.is_dir() { + copy_tree(&from, &to); + } else { + fs::copy(&from, &to).unwrap(); + } + } +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-upgrade-v054-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} diff --git a/crates/codegraph-cli/tests/value_refs_impact.rs b/crates/codegraph-cli/tests/value_refs_impact.rs new file mode 100644 index 00000000..9f218408 --- /dev/null +++ b/crates/codegraph-cli/tests/value_refs_impact.rs @@ -0,0 +1,79 @@ +//! A constant's same-file readers are in its impact radius (upstream #895): +//! the value reference from a reader to the constant it reads is an incoming +//! edge `impact` follows. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-cli-value-refs-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn cli(args: &[&str]) -> (String, String, bool) { + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(args) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .output() + .expect("run codegraph binary"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.success(), + ) +} + +#[test] +fn a_constants_same_file_readers_are_in_its_impact() { + let dir = TestDir::new("impact"); + let project = dir.path().join("palette"); + fs::create_dir_all(&project).unwrap(); + fs::write( + project.join("palette.ts"), + "export const COLOR_PALETTE = { red: \"#f00\", blue: \"#00f\" };\nexport function pickRed() { return COLOR_PALETTE.red; }\n", + ) + .unwrap(); + let p = project.to_str().unwrap(); + let (out, err, ok) = cli(&["init", p]); + assert!(ok, "init failed: stdout={out} stderr={err}"); + let (out, err, ok) = cli(&["index", "--force", p]); + assert!(ok, "index --force failed: stdout={out} stderr={err}"); + + let (stdout, err, ok) = cli(&["impact", "COLOR_PALETTE", "-p", p, "--json"]); + assert!(ok, "impact failed: stdout={stdout} stderr={err}"); + let value: serde_json::Value = + serde_json::from_str(&stdout).expect("impact emits valid JSON on stdout"); + let affected: Vec<&str> = value["affected"] + .as_array() + .expect("affected is an array") + .iter() + .filter_map(|node| node["name"].as_str()) + .collect(); + assert!(affected.contains(&"pickRed"), "{value}"); +} diff --git a/crates/codegraph-core/src/file_class.rs b/crates/codegraph-core/src/file_class.rs index caf559f9..8433e249 100644 --- a/crates/codegraph-core/src/file_class.rs +++ b/crates/codegraph-core/src/file_class.rs @@ -26,8 +26,9 @@ const TEST_DIR_SEGMENTS: &[&str] = &["test", "tests", "__tests__", "spec", "e2e"]; /// Filename markers that mark a test file. `_test.` is Go's convention -/// (`math_test.go`); `.test.` / `.spec.` are the JS/TS ones. -const TEST_FILE_MARKERS: &[&str] = &[".test.", ".spec.", "_test."]; +/// (`math_test.go`); `.test.` / `.spec.` are the JS/TS ones; `_unittest.` is +/// Google's C++ one (`foo_unittest.cc`, upstream #2434). +const TEST_FILE_MARKERS: &[&str] = &[".test.", ".spec.", "_test.", "_unittest."]; /// Whether `path` names a test file, by directory segment or filename marker. /// @@ -41,6 +42,171 @@ pub fn is_test_file(path: &str) -> bool { .any(|seg| contains_path_segment(path, seg)) } +/// Whether `path` names a test, the narrow reading of upstream's `isTestPath` +/// the resolver's test-suite rule reads a reference's file with (upstream +/// #2209): a test file by name (`test_foo.py`, `foo_test.go`, `foo.spec.ts`, +/// `foo_unittest.cc`, `FooTest.kt`, `BarTests.swift`) or one under a test or +/// test-support directory (`tests/`, `__tests__/`, `spec/`, `e2e/`, +/// `testing/`, `jvmTest/`, `testdata/`, `fakes/`, `mocks/`, a `*-test` +/// module). +pub fn is_test_path(path: &str) -> bool { + let lower = path.to_lowercase(); + let file_name = path.rsplit('/').next().unwrap_or(path); + let lower_name = file_name.to_lowercase(); + if lower_name.starts_with("test_") + || lower_name.starts_with("test.") + || has_separated_suffix( + &lower_name, + &["test", "tests", "unittest", "unittests", "spec", "specs"], + &['.', '_', '-'], + ) + || has_camel_suffix( + file_name, + &["Test", "Tests", "TestCase", "Tester", "Spec", "Specs"], + None, + ) + { + return true; + } + const DIRS: [&str; 8] = [ + "/tests/", + "/test/", + "/__tests__/", + "/spec/", + "/specs/", + "/testlib/", + "/testing/", + "/e2e/", + ]; + const ROOTS: [&str; 5] = ["test/", "tests/", "spec/", "specs/", "e2e/"]; + DIRS.iter().any(|dir| lower.contains(dir)) + || ROOTS.iter().any(|root| lower.starts_with(root)) + || directories(path).any(is_camel_test_directory) + || directories(&lower).any(is_test_support_directory) +} + +/// Whether `path` is a test suite, as opposed to test-support code a project +/// ships (`testing/`, `fakes/`, a `*-test` module), which its own code may use +/// (upstream #2209 `isTestSuitePath`). `…Spec.java` alone is no test (halo's +/// `IndexSpecs`, okhttp's `ConnectionSpec`), nor is `useTests.ts`, a hook. +pub fn is_test_suite_path(path: &str) -> bool { + if !is_test_path(path) { + return false; + } + let lower = path.to_lowercase(); + let file_name = path.rsplit('/').next().unwrap_or(path); + let name = lower.rsplit('/').next().unwrap_or(&lower); + if name.starts_with("test_") + || has_separated_suffix( + name, + &["test", "tests", "unittest", "unittests"], + &['.', '_', '-'], + ) + || has_separated_suffix(name, &["spec", "specs"], &['.', '_']) + || has_camel_suffix( + file_name, + &["Test", "Tests", "TestCase"], + Some(&[ + "java", "kt", "kts", "swift", "cs", "scala", "groovy", "m", "mm", "vb", "fs", + ]), + ) + || name == "conftest.py" + { + return true; + } + directories(&lower).any(|dir| { + matches!( + dir, + "test" | "tests" | "__tests__" | "spec" | "specs" | "e2e" + ) + }) || directories(path).any(is_camel_test_directory) +} + +/// The directory segments of `path`: every `/`-delimited segment but the +/// last. +fn directories(path: &str) -> impl Iterator { + let dirs = path.rsplit_once('/').map_or("", |(dirs, _)| dirs); + dirs.split('/').filter(|segment| !segment.is_empty()) +} + +/// `[sep](suffix)\.[a-z0-9]+$` over a lowercase file name: the stem before +/// the last `.` ends in one of `suffixes`, right after one of `separators`, +/// and the extension is lowercase alphanumeric. +fn has_separated_suffix(lower_name: &str, suffixes: &[&str], separators: &[char]) -> bool { + let Some((stem, ext)) = lower_name.rsplit_once('.') else { + return false; + }; + if ext.is_empty() + || !ext + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return false; + } + suffixes.iter().any(|suffix| { + stem.strip_suffix(suffix) + .and_then(|rest| rest.chars().next_back()) + .is_some_and(|sep| separators.contains(&sep)) + }) +} + +/// `(suffix)\.$` over a file name in its own case: the stem before the +/// last `.` ends in one of `suffixes` (`FooTest`, `BarTests`), and the +/// extension is alphanumeric, or one of `extensions` when given. +fn has_camel_suffix(file_name: &str, suffixes: &[&str], extensions: Option<&[&str]>) -> bool { + let Some((stem, ext)) = file_name.rsplit_once('.') else { + return false; + }; + let ext_ok = match extensions { + Some(allowed) => allowed.contains(&ext), + None => !ext.is_empty() && ext.bytes().all(|b| b.is_ascii_alphanumeric()), + }; + ext_ok && suffixes.iter().any(|suffix| stem.ends_with(suffix)) +} + +/// `[A-Za-z0-9]*(Test|Tests|Spec)` as a whole directory: a Kotlin +/// Multiplatform / Gradle / Xcode test source set (`jvmTest/`, `UnitTests/`). +fn is_camel_test_directory(dir: &str) -> bool { + dir.bytes().all(|b| b.is_ascii_alphanumeric()) + && ["Test", "Tests", "Spec"] + .iter() + .any(|suffix| dir.ends_with(suffix)) +} + +/// A test-support directory by its lowercase name: a `*-test` / `*_tests` / +/// `*-testing` module, `testdata`, `testutil(s)`, `test-util(s)`, `fake(s)`, +/// `mock(s)`, `__mocks__`, `stubs`. +fn is_test_support_directory(dir: &str) -> bool { + if matches!( + dir, + "testdata" + | "testutil" + | "testutils" + | "test-util" + | "test_util" + | "test-utils" + | "test_utils" + | "fake" + | "fakes" + | "mock" + | "mocks" + | "__mocks__" + | "stubs" + ) { + return true; + } + ["test", "tests", "testing"].iter().any(|suffix| { + dir.strip_suffix(suffix) + .and_then(|rest| rest.strip_suffix(['-', '_'])) + .is_some_and(|prefix| { + !prefix.is_empty() + && prefix + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.') + }) + }) +} + /// Whether `segment` appears in `path` as a complete `/`-delimited segment. /// /// Scans forward over every occurrence rather than allocating `"/{segment}/"`, @@ -100,9 +266,12 @@ const GENERATED_SUFFIXES: &[&str] = &[ "_grpc_pb.ts", "_grpc_pb.js", // Minified bundles vendored into a repo: single-letter symbols make - // name-based edges pure noise. + // name-based edges pure noise. Upstream's `[.-]min` also takes + // `underscore-min.js` (#2122). ".min.js", ".min.mjs", + "-min.js", + "-min.mjs", // Python — protobuf / gRPC "_pb2.py", "_pb2_grpc.py", @@ -159,6 +328,60 @@ fn basename(path: &str) -> &str { #[cfg(test)] mod tests { + /// Upstream #2209 / #2434: what `is_test_path` and `is_test_suite_path` + /// read as a test, and what they leave production or test support. + #[test] + fn test_paths_and_test_suites() { + for suite in [ + "test/functional/entity/Record.ts", + "src/test/java/app/InterceptorTest.java", + "app/__tests__/a.ts", + "spec/models/user_spec.rb", + "e2e/login.ts", + "tools/test_foo.py", + "src/foo.test.ts", + "src/cpp/stack_unittest.cc", + "src/jvmTest/kotlin/A.kt", + "Sources/FooTests.swift", + "tests/conftest.py", + ] { + assert!(is_test_path(suite), "{suite}"); + assert!(is_test_suite_path(suite), "{suite}"); + } + for support in [ + "src/testing/stub.ts", + "core/data-test/src/Fake.kt", + "internal/testdata/x.go", + "src/fakes/clock.ts", + "src/__mocks__/fs.ts", + "app/Specs/IndexSpecs.java", + ] { + assert!(is_test_path(support), "{support}"); + } + for support in [ + "src/testing/stub.ts", + "core/data-test/src/Fake.kt", + "internal/testdata/x.go", + "src/fakes/clock.ts", + ] { + assert!(!is_test_suite_path(support), "{support}"); + } + for production in [ + "src/columns.ts", + "src/latest.kt", + "src/manifest/x.kt", + "src/hooks/useTests.ts", + "src/okhttp/ConnectionSpec.java", + "cmd/promtool/unittest.go", + ] { + assert!(!is_test_suite_path(production), "{production}"); + } + assert!(!is_test_path("src/columns.ts")); + // A `conftest.py` names a suite only where a test path already does. + assert!(!is_test_path("pkg/conftest.py")); + assert!(is_test_file("src/cpp/stack_unittest.cc")); + } + use super::*; #[test] @@ -230,6 +453,8 @@ mod tests { "svc_grpc_pb.js", "vendor/chart.min.js", "vendor/chart.min.mjs", + "vendor/underscore/underscore-min.js", + "vendor/underscore/underscore-min.mjs", "svc_pb2.py", "svc_pb2_grpc.py", "svc_pb2.pyi", @@ -260,6 +485,7 @@ mod tests { "src/genesis.go", "src/pb.go", "src/table.js", + "src/admin.js", "docs/min.css", "src/grpc.cs", "src/outerclass.java", diff --git a/crates/codegraph-core/src/generated_header.rs b/crates/codegraph-core/src/generated_header.rs index d67ca23f..b814c95a 100644 --- a/crates/codegraph-core/src/generated_header.rs +++ b/crates/codegraph-core/src/generated_header.rs @@ -333,14 +333,101 @@ fn find_standalone_at_generated(lower: &str) -> Option { None } -/// The union signal — path convention OR content banner — and the value the -/// indexer persists to `files.generated`. +/// The union signal — path convention, content banner, or a minified script — +/// and the value the indexer persists to `files.generated`. /// /// The content half only ever ADDS: a file the path check already flags stays /// flagged whatever its header says, so this can never REMOVE a demotion an /// existing index applied. pub fn detect_generated_file(path: &str, content: &str) -> bool { - is_generated_file(path) || has_generated_header(content) + is_generated_file(path) || has_generated_header(content) || is_minified_content(path, content) +} + +/// A line this long is not written by hand (upstream `MINIFIED_LINE_CHARS`). +const MINIFIED_LINE_CHARS: usize = 1000; + +/// A script a bundler or minifier can write, `/\.(?:m?js|cjs)$/i` (upstream +/// `MINIFIABLE_SCRIPT`). +pub fn is_minifiable_script(path: &str) -> bool { + path.rsplit_once('.').is_some_and(|(_, extension)| { + ["js", "mjs", "cjs"] + .iter() + .any(|known| extension.eq_ignore_ascii_case(known)) + }) +} + +/// A minified or bundled script that is not NAMED so — a docs site's +/// `bundle.js`, a sample's vendored `app.js` (upstream #2122 +/// `isMinifiedContent`): most of its text sits on lines of a thousand +/// characters or more, and that text is code, dense with `;{}(),`, not one +/// long string literal (a base64 fixture) in an ordinary file. A webpack build +/// counts by its module loader, however readable its lines. Lengths are +/// counted in UTF-16 code units, as upstream's JavaScript strings are. +pub fn is_minified_content(path: &str, content: &str) -> bool { + if !is_minifiable_script(path) { + return false; + } + let total = utf16_len(content); + if total < 4 * MINIFIED_LINE_CHARS { + return false; + } + if has_webpack_runtime(content) { + return true; + } + let mut long = 0usize; + let mut punctuation = 0usize; + for line in content.split('\n') { + let length = utf16_len(line); + if length >= MINIFIED_LINE_CHARS { + long += length; + punctuation += line + .bytes() + .filter(|byte| matches!(byte, b';' | b'{' | b'}' | b'(' | b')' | b',')) + .count(); + } + } + // The JavaScript original compares doubles; every operand is an integer + // well below 2^53, so the f64 arithmetic is the same. + long as f64 >= total as f64 * 0.5 && punctuation as f64 >= long as f64 * 0.03 +} + +fn utf16_len(text: &str) -> usize { + if text.is_ascii() { + text.len() + } else { + text.encode_utf16().count() + } +} + +/// `/\bfunction __webpack_require__\s*\(/`: the loader every webpack bundle +/// defines for its modules. +fn has_webpack_runtime(content: &str) -> bool { + const LOADER: &str = "function __webpack_require__"; + content.match_indices(LOADER).any(|(at, _)| { + let bounded = content[..at] + .bytes() + .next_back() + .is_none_or(|byte| !(byte.is_ascii_alphanumeric() || byte == b'_')); + bounded + && content[at + LOADER.len()..] + .trim_start_matches(is_js_whitespace) + .starts_with('(') + }) +} + +/// JavaScript's `\s`: whitespace and line terminators, `U+FEFF` included. +pub fn is_js_whitespace(c: char) -> bool { + matches!( + c, + '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) } #[cfg(test)] @@ -637,6 +724,76 @@ mod tests { // ---- the union written to files.generated ----------------------------- + // ---- a bundle not named .min.js (upstream #2122) ------------------------ + + fn bundle(count: usize) -> String { + (0..count) + .map(|i| format!("function f{i}(e){{return n(e,{i})+t(e)}}")) + .collect::>() + .join(";") + } + + #[test] + fn a_minified_script_is_recognized_by_its_enormous_code_lines() { + let bundle = bundle(300); + assert!(is_minified_content("docs/assets/bundle.js", &bundle)); + assert!(is_minified_content("site/app.mjs", &bundle)); + assert!(is_minified_content("site/app.CJS", &bundle)); + assert!(detect_generated_file("docs/assets/bundle.js", &bundle)); + } + + #[test] + fn a_bundle_of_two_nine_kilobyte_lines_is_generated() { + let line = bundle(260); + assert!(line.len() > 9_000 && line.len() < 10_000, "{}", line.len()); + let two_lines = format!("{line}\n{line}\n"); + assert!(is_minified_content("public/app.js", &two_lines)); + assert!(detect_generated_file("public/app.js", &two_lines)); + assert!(!detect_generated_file("public/app.ts", &two_lines)); + } + + #[test] + fn a_readable_webpack_build_is_recognized_by_its_module_loader() { + let webpack = format!( + "/*! lib */\n(function(modules) {{\n function __webpack_require__(moduleId) {{\n return modules[moduleId];\n }}\n{}}})([]);\n", + " var x = 1;\n".repeat(400) + ); + assert!(is_minified_content( + "assets/js/syntaxhighlighter.js", + &webpack + )); + assert!(!has_webpack_runtime("my_function __webpack_require__(")); + assert!(has_webpack_runtime("(function __webpack_require__ \u{a0}(")); + assert!(is_generated_file("vendor/underscore/underscore-min.js")); + } + + #[test] + fn an_ordinary_file_with_one_long_string_literal_is_not_minified() { + let fixture = [ + "export function decode(input) {".to_string(), + " return atob(input);".to_string(), + "}".to_string(), + format!("export const LOGO = \"{}\";", "QUJD".repeat(4000)), + "export function size() { return LOGO.length; }".to_string(), + ] + .join("\n"); + assert!(!is_minified_content("src/logo.js", &fixture)); + } + + #[test] + fn hand_written_code_typescript_and_short_files_are_not_minified() { + let code = (0..400) + .map(|i| format!("export function f{i}(e) {{\n return e + {i};\n}}")) + .collect::>() + .join("\n"); + assert!(!is_minified_content("src/code.js", &code)); + assert!(!is_minified_content("src/bundle.ts", &bundle(300))); + assert!(!is_minified_content( + "src/tiny.js", + "function n(e){return e};function t(e){return n(e)}" + )); + } + #[test] fn detect_generated_file_unions_path_and_content() { // Path signal alone: a `.pb.go` with NO banner is still generated. diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index 9a214dae..732f6531 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -518,7 +518,7 @@ pub struct Edge { pub provenance: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FileRecord { pub path: String, diff --git a/crates/codegraph-daemon/Cargo.toml b/crates/codegraph-daemon/Cargo.toml index 26373848..d44c30d1 100644 --- a/crates/codegraph-daemon/Cargo.toml +++ b/crates/codegraph-daemon/Cargo.toml @@ -13,6 +13,12 @@ rust-version.workspace = true description = "Per-project single-instance daemon (pid + socket, ppid watchdog) for CodeGraph-rs." readme = "../../README.md" +[features] +# Test-only seams: let a test build's daemon claim another version in its hello +# and leave control frames unanswered, so the replacement of an older daemon can +# be driven end to end. Never enabled in a release build. +test-hooks = [] + [dependencies] serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/codegraph-daemon/src/control.rs b/crates/codegraph-daemon/src/control.rs index fc3b87bc..39265d94 100644 --- a/crates/codegraph-daemon/src/control.rs +++ b/crates/codegraph-daemon/src/control.rs @@ -142,6 +142,9 @@ pub enum ShutdownOutcome { /// A live owner was recorded but never ACKed within the bounded budget. The /// caller MUST fail closed; the pid is reported, never signalled. Unresponsive { pid: u32, detail: String }, + /// The daemon answering on the rendezvous is not the one the caller meant + /// to drain (its hello failed the caller's check), so no frame was sent. + Declined { pid: u32, detail: String }, } /// Ask the daemon recorded for `project_root` to shut down, and wait a bounded @@ -152,6 +155,19 @@ pub enum ShutdownOutcome { pub fn request_daemon_shutdown( project_root: &Path, project_identity: &str, +) -> Result { + request_daemon_shutdown_of(project_root, project_identity, |_| true) +} + +/// [`request_daemon_shutdown`] of one particular daemon: the frame is sent +/// only after the hello on that same connection passes `is_target`. A caller +/// that chose to drain a daemon by its hello (an older release) passes the same +/// test, so a daemon that took the project in the meantime (a newer release) is +/// never asked; that ends in [`ShutdownOutcome::Declined`]. +pub fn request_daemon_shutdown_of( + project_root: &Path, + project_identity: &str, + is_target: impl Fn(&serde_json::Value) -> bool + Send + 'static, ) -> Result { let pid_path = crate::paths::daemon_pid_path(project_root)?; let Some(info) = std::fs::read_to_string(&pid_path) @@ -170,8 +186,12 @@ pub fn request_daemon_shutdown( info.socket_path.clone() }; - match bounded_exchange_shutdown(socket_path, project_identity.to_string()) { - Ok(()) => Ok(ShutdownOutcome::Drained { pid: info.pid }), + match bounded_exchange_shutdown(socket_path, project_identity.to_string(), is_target) { + Ok(Exchange::Drained) => Ok(ShutdownOutcome::Drained { pid: info.pid }), + Ok(Exchange::Declined(detail)) => Ok(ShutdownOutcome::Declined { + pid: info.pid, + detail, + }), Err(detail) => Ok(ShutdownOutcome::Unresponsive { pid: info.pid, detail, @@ -179,6 +199,13 @@ pub fn request_daemon_shutdown( } } +/// How a shutdown exchange that reached a daemon ended. +enum Exchange { + Drained, + /// The hello failed the caller's check; nothing was sent. + Declined(String), +} + /// Run the exchange on a worker thread and wait for it with a monotonic channel /// deadline. /// @@ -186,11 +213,15 @@ pub fn request_daemon_shutdown( /// wedged Windows named pipe would otherwise block forever. Bounding the WAIT /// itself (not the socket) is finite on every supported platform; the abandoned /// worker owns only its own connection and never touches index bytes. -fn bounded_exchange_shutdown(socket_path: PathBuf, project_identity: String) -> Result<(), String> { +fn bounded_exchange_shutdown( + socket_path: PathBuf, + project_identity: String, + is_target: impl Fn(&serde_json::Value) -> bool + Send + 'static, +) -> Result { let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { - let result = - exchange_shutdown(&socket_path, &project_identity).map_err(|e| format!("{e:#}")); + let result = exchange_shutdown(&socket_path, &project_identity, &is_target) + .map_err(|e| format!("{e:#}")); let _ = tx.send(result); }); match rx.recv_timeout(SHUTDOWN_ACK_TIMEOUT + SHUTDOWN_WAIT_MARGIN) { @@ -205,7 +236,11 @@ fn bounded_exchange_shutdown(socket_path: PathBuf, project_identity: String) -> } } -fn exchange_shutdown(socket_path: &Path, project_identity: &str) -> Result<()> { +fn exchange_shutdown( + socket_path: &Path, + project_identity: &str, + is_target: &dyn Fn(&serde_json::Value) -> bool, +) -> Result { let rendezvous = Rendezvous::from_socket_path(socket_path); let stream = connect(&rendezvous) .with_context(|| format!("connecting to daemon socket {}", socket_path.display()))?; @@ -221,12 +256,21 @@ fn exchange_shutdown(socket_path: &Path, project_identity: &str) -> Result<()> { } // The daemon writes its versioned hello on accept; consume that line first so - // the ACK read below cannot mistake it for the reply. + // the ACK read below cannot mistake it for the reply. It also says which + // daemon this connection reached, which the caller may require. let mut reader = BufReader::new(&stream); let mut hello = String::new(); reader .read_line(&mut hello) .context("reading the daemon hello before sending a control frame")?; + let announced = + serde_json::from_str::(hello.trim()).unwrap_or(serde_json::Value::Null); + if !is_target(&announced) { + return Ok(Exchange::Declined(format!( + "the daemon on the rendezvous announced {}", + hello.trim() + ))); + } let frame = ControlFrame::shutdown(project_identity); (&stream) @@ -245,7 +289,7 @@ fn exchange_shutdown(socket_path: &Path, project_identity: &str) -> Result<()> { "daemon reported an incomplete drain (drained={})", ack.drained ); - Ok(()) + Ok(Exchange::Drained) } #[cfg(test)] diff --git a/crates/codegraph-daemon/src/install.rs b/crates/codegraph-daemon/src/install.rs new file mode 100644 index 00000000..7f6ecd1f --- /dev/null +++ b/crates/codegraph-daemon/src/install.rs @@ -0,0 +1,179 @@ +//! The installed executable a long-lived process runs (upstream #2346). +//! +//! A daemon outlives an upgrade: it keeps running the code it started with +//! while the install it came from is replaced or removed. Recording the +//! executable's identity at start and comparing it later tells a daemon, or a +//! session, that this happened, so it can step aside for the current install +//! instead of holding the project with replaced code. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +/// Env var name: milliseconds between a daemon's checks of its own install +/// (default 30000). `0` turns the check off. Mirrors upstream. +pub const CODEGRAPH_DAEMON_INSTALL_CHECK_MS: &str = "CODEGRAPH_DAEMON_INSTALL_CHECK_MS"; + +const DEFAULT_INSTALL_CHECK_MS: u64 = 30_000; + +/// One installed executable as it was when recorded: its canonical path, and +/// the file's physical identity, length and modification time there. Replacing +/// or rewriting the binary changes at least one of them, and removing it leaves +/// none to compare. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallIdentity { + path: PathBuf, + stamp: InstallStamp, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InstallStamp { + identity: codegraph_store::PathIdentity, + len: u64, + modified: Option, +} + +impl InstallIdentity { + /// The running executable's install as it is now, or `None` when the + /// executable cannot be located or read (then nothing can be checked). + #[must_use] + pub fn current() -> Option { + Self::of(&std::env::current_exe().ok()?) + } + + /// The install at `path` as it is now. A symlink is resolved first, so a + /// package manager that retargets the link to a new version reads as a + /// changed install once the old target is gone. + #[must_use] + pub fn of(path: &Path) -> Option { + let path = std::fs::canonicalize(path).ok()?; + let stamp = stamp(&path)?; + Some(Self { path, stamp }) + } + + /// The canonical path recorded. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// Whether the file at the recorded path is no longer the one recorded: + /// replaced, rewritten, or gone. + #[must_use] + pub fn changed(&self) -> bool { + stamp(&self.path).as_ref() != Some(&self.stamp) + } +} + +fn stamp(path: &Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + Some(InstallStamp { + identity: codegraph_store::PathIdentity::of(path).ok()?, + len: metadata.len(), + modified: metadata.modified().ok(), + }) +} + +/// How often a daemon checks its install: [`CODEGRAPH_DAEMON_INSTALL_CHECK_MS`], +/// or `None` when it is `0`. Unset, empty or malformed values use the default. +#[must_use] +pub fn install_check_interval() -> Option { + parse_install_check_ms( + std::env::var(CODEGRAPH_DAEMON_INSTALL_CHECK_MS) + .ok() + .as_deref(), + ) +} + +fn parse_install_check_ms(raw: Option<&str>) -> Option { + let millis = raw + .map(str::trim) + .filter(|raw| !raw.is_empty()) + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(DEFAULT_INSTALL_CHECK_MS); + (millis > 0).then(|| Duration::from_millis(millis)) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TempDir(PathBuf); + + impl TempDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-install-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn an_untouched_install_is_unchanged() { + let dir = TempDir::new("untouched"); + let binary = dir.0.join("codegraph"); + std::fs::write(&binary, b"build one").unwrap(); + let install = InstallIdentity::of(&binary).expect("readable install"); + assert!(!install.changed()); + } + + #[test] + fn a_replaced_install_is_changed() { + let dir = TempDir::new("replaced"); + let binary = dir.0.join("codegraph"); + std::fs::write(&binary, b"build one").unwrap(); + let install = InstallIdentity::of(&binary).expect("readable install"); + // An upgrade writes the new build beside the old one and renames it + // over, the way installers replace a running executable. + let staged = dir.0.join("codegraph.new"); + std::fs::write(&staged, b"build two!").unwrap(); + std::fs::rename(&staged, &binary).unwrap(); + assert!(install.changed()); + } + + #[test] + fn a_removed_install_is_changed() { + let dir = TempDir::new("removed"); + let binary = dir.0.join("codegraph"); + std::fs::write(&binary, b"build one").unwrap(); + let install = InstallIdentity::of(&binary).expect("readable install"); + std::fs::remove_file(&binary).unwrap(); + assert!(install.changed()); + } + + #[test] + fn a_missing_executable_records_nothing() { + let dir = TempDir::new("missing"); + assert_eq!(InstallIdentity::of(&dir.0.join("absent")), None); + } + + #[test] + fn the_check_interval_parses_its_env_value() { + assert_eq!(parse_install_check_ms(None), Some(Duration::from_secs(30))); + assert_eq!( + parse_install_check_ms(Some("")), + Some(Duration::from_secs(30)) + ); + assert_eq!( + parse_install_check_ms(Some("soon")), + Some(Duration::from_secs(30)) + ); + assert_eq!(parse_install_check_ms(Some("0")), None); + assert_eq!( + parse_install_check_ms(Some("250")), + Some(Duration::from_millis(250)) + ); + } +} diff --git a/crates/codegraph-daemon/src/lease_keeper.rs b/crates/codegraph-daemon/src/lease_keeper.rs new file mode 100644 index 00000000..995daf52 --- /dev/null +++ b/crates/codegraph-daemon/src/lease_keeper.rs @@ -0,0 +1,404 @@ +//! Keep a stdio session attached to its project's shared daemon (upstream #2293). +//! +//! A cold-start `serve --mcp` answers its handshake direct and spawns the shared +//! daemon fire-and-forget. That session holds no writer and runs no watcher: the +//! daemon is what keeps its index live, and a daemon with no client idle-exits +//! after `CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS`. The keeper retains a passive lease +//! once the daemon's socket is up, so the daemon lives as long as the session. +//! When the lease stops being live (the daemon crashed, was replaced, or the +//! index was re-created) it starts or re-attaches a daemon with backoff: +//! `CODEGRAPH_DAEMON_RETRY_MS` (default 5000, `0` disables retrying), doubling +//! up to `CODEGRAPH_DAEMON_RETRY_MAX_MS` (default 300000). + +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Duration; + +use anyhow::Result; +use codegraph_mcp::ProjectService as _; +use tracing::{debug, info}; + +use crate::install::InstallIdentity; +use crate::project_service::{ + ProjectDaemonLease, attach_project_daemon_passive, retain_project_daemon_passive, +}; + +/// Env var name: milliseconds a stdio session waits before starting or +/// re-attaching the shared daemon after its lease was lost (default 5000). +/// `0` turns retrying off. Mirrors upstream. +pub const CODEGRAPH_DAEMON_RETRY_MS: &str = "CODEGRAPH_DAEMON_RETRY_MS"; + +/// Env var name: the cap the retry delay doubles up to (default 300000). +pub const CODEGRAPH_DAEMON_RETRY_MAX_MS: &str = "CODEGRAPH_DAEMON_RETRY_MAX_MS"; + +const DEFAULT_DAEMON_RETRY_MS: u64 = 5_000; +const DEFAULT_DAEMON_RETRY_MAX_MS: u64 = 300_000; + +/// How often a held lease is checked for liveness. The check reads the daemon's +/// pid record and stats the index database, so it is cheap. +const LEASE_LIVENESS_POLL: Duration = Duration::from_millis(500); + +/// Backoff for re-attaching the shared daemon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DaemonRetryPolicy { + base: Duration, + max: Duration, +} + +impl DaemonRetryPolicy { + /// Read [`CODEGRAPH_DAEMON_RETRY_MS`] and [`CODEGRAPH_DAEMON_RETRY_MAX_MS`]. + #[must_use] + pub fn from_env() -> Self { + let base = parse_delay_ms( + std::env::var(CODEGRAPH_DAEMON_RETRY_MS).ok().as_deref(), + DEFAULT_DAEMON_RETRY_MS, + ); + let max = parse_delay_ms( + std::env::var(CODEGRAPH_DAEMON_RETRY_MAX_MS).ok().as_deref(), + DEFAULT_DAEMON_RETRY_MAX_MS, + ); + Self::from_millis(base, max) + } + + /// A policy starting at `base_ms` and doubling up to `max_ms`; the cap is + /// never below the base. + #[must_use] + pub fn from_millis(base_ms: u64, max_ms: u64) -> Self { + Self { + base: Duration::from_millis(base_ms), + max: Duration::from_millis(max_ms.max(base_ms)), + } + } + + /// Whether a lost or failed lease is retried at all. + #[must_use] + pub fn retries(&self) -> bool { + !self.base.is_zero() + } + + fn next(&self, delay: Duration) -> Duration { + delay.saturating_mul(2).min(self.max) + } +} + +/// A non-negative millisecond value; unset, empty, malformed or negative gives +/// `fallback`, and a fraction is truncated (upstream `parseDelayMs`). +fn parse_delay_ms(raw: Option<&str>, fallback: u64) -> u64 { + let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else { + return fallback; + }; + if let Ok(millis) = raw.parse::() { + return millis; + } + match raw.parse::() { + // `as` saturates, so an out-of-range value becomes u64::MAX. + Ok(millis) if millis.is_finite() && millis >= 0.0 => millis.floor() as u64, + _ => fallback, + } +} + +/// How the keeper asks for a lease. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Attach { + /// This process has just spawned the daemon: wait for its socket and never + /// start a second one. + JustSpawned, + /// Start a daemon when no live one owns the project, then attach. + StartIfAbsent, +} + +/// Keeps a passive daemon lease for as long as it is alive; dropping it stops +/// the background thread at its next wait. +#[derive(Debug)] +pub struct SessionLeaseKeeper { + _stop: mpsc::Sender<()>, +} + +/// Keep this session attached to the daemon it has just spawned for +/// `project_root`, and start or re-attach one with backoff whenever the lease +/// is lost. The work runs on a background thread; the returned keeper must stay +/// alive for the session. +#[must_use] +pub fn keep_session_attached(project_root: PathBuf, no_watch: bool) -> SessionLeaseKeeper { + let (stop, stopped) = mpsc::channel::<()>(); + let policy = DaemonRetryPolicy::from_env(); + // The install this session runs. Once it is replaced or removed, the + // session runs replaced code and must not start daemons for the project. + let install = InstallIdentity::current(); + let spawned = std::thread::Builder::new() + .name("codegraph-daemon-lease".to_string()) + .spawn(move || { + run_keeper( + policy, + LEASE_LIVENESS_POLL, + |attach| -> Result { + match attach { + Attach::JustSpawned => attach_project_daemon_passive(&project_root), + Attach::StartIfAbsent => { + retain_project_daemon_passive(&project_root, no_watch) + } + } + }, + ProjectDaemonLease::is_live, + |delay| wait_unless_stopped(&stopped, delay), + || own_install_unchanged(install.as_ref()), + ); + }); + if let Err(error) = spawned { + debug!(%error, "could not start the daemon lease keeper; the session serves without one"); + } + SessionLeaseKeeper { _stop: stop } +} + +/// Whether this session may still start or re-attach daemons: false once its +/// own install was upgraded or removed (upstream #2346), which it reports once. +fn own_install_unchanged(install: Option<&InstallIdentity>) -> bool { + if install.is_some_and(InstallIdentity::changed) { + // stdout carries JSON-RPC only; MCP hosts log stderr. + eprintln!( + "[CodeGraph MCP] This session's CodeGraph install was upgraded or removed; it keeps \ + serving reads, but restart the session to use the current install." + ); + return false; + } + true +} + +/// Wait `delay`; `false` once the keeper was dropped. +fn wait_unless_stopped(stopped: &mpsc::Receiver<()>, delay: Duration) -> bool { + matches!( + stopped.recv_timeout(delay), + Err(mpsc::RecvTimeoutError::Timeout) + ) +} + +/// The keeper loop, with every effect injected so the backoff is unit-testable: +/// `attach` acquires a lease, `is_live` checks a held one, `wait` sleeps for +/// the given delay and returns `false` when the keeper should stop, and +/// `may_retry` is asked before every retry. +fn run_keeper( + policy: DaemonRetryPolicy, + liveness_poll: Duration, + mut attach: impl FnMut(Attach) -> Result, + is_live: impl Fn(&L) -> bool, + mut wait: impl FnMut(Duration) -> bool, + mut may_retry: impl FnMut() -> bool, +) { + let mut mode = Attach::JustSpawned; + let mut delay = policy.base; + loop { + match attach(mode) { + Ok(lease) => { + debug!(?mode, "retained a passive lease on the shared daemon"); + delay = policy.base; + loop { + if !wait(liveness_poll) { + return; + } + if !is_live(&lease) { + break; + } + } + drop(lease); + if policy.retries() { + info!( + retry_in_ms = delay.as_millis(), + "shared daemon lease lost; starting or re-attaching a daemon" + ); + } else { + info!("shared daemon lease lost; retrying is disabled"); + } + } + Err(error) => { + debug!(?mode, error = %format!("{error:#}"), "no shared daemon lease"); + } + } + if !policy.retries() || !wait(delay) || !may_retry() { + return; + } + delay = policy.next(delay); + mode = Attach::StartIfAbsent; + } +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + + use anyhow::anyhow; + + use super::*; + + const POLL: Duration = Duration::from_millis(500); + + fn ms(millis: u64) -> Duration { + Duration::from_millis(millis) + } + + /// One scripted attach result: `Some(n)` is a lease that stays live for + /// `n` liveness polls, `None` a failed attach. + struct Script { + attaches: RefCell>>, + modes: RefCell>, + waits: RefCell>, + polls_left: Cell, + max_waits: usize, + may_retry: Cell, + } + + impl Script { + fn new(attaches: Vec>, max_waits: usize) -> Self { + Self { + attaches: RefCell::new(attaches.into_iter().rev().collect()), + modes: RefCell::new(Vec::new()), + waits: RefCell::new(Vec::new()), + polls_left: Cell::new(0), + max_waits, + may_retry: Cell::new(true), + } + } + + fn run(&self, policy: DaemonRetryPolicy) { + run_keeper( + policy, + POLL, + |mode| { + self.modes.borrow_mut().push(mode); + match self.attaches.borrow_mut().pop() { + Some(Some(live_polls)) => { + self.polls_left.set(live_polls); + Ok(()) + } + Some(None) | None => Err(anyhow!("no daemon")), + } + }, + |()| { + let left = self.polls_left.get(); + self.polls_left.set(left.saturating_sub(1)); + left > 0 + }, + |delay| { + let mut waits = self.waits.borrow_mut(); + waits.push(delay); + waits.len() < self.max_waits + }, + || self.may_retry.get(), + ); + } + + fn retry_waits(&self) -> Vec { + self.waits + .borrow() + .iter() + .copied() + .filter(|&delay| delay != POLL) + .collect() + } + } + + #[test] + fn retry_delay_doubles_up_to_the_cap() { + let script = Script::new(vec![None, None, None, None, None], 5); + script.run(DaemonRetryPolicy::from_millis(5_000, 15_000)); + assert_eq!( + script.retry_waits(), + [ms(5_000), ms(10_000), ms(15_000), ms(15_000), ms(15_000)] + ); + } + + #[test] + fn first_attach_never_starts_a_daemon_and_later_ones_may() { + // Stops on the fourth wait, while the third attach's lease is held. + let script = Script::new(vec![None, None, Some(9)], 4); + script.run(DaemonRetryPolicy::from_millis(5_000, 300_000)); + assert_eq!( + *script.modes.borrow(), + [ + Attach::JustSpawned, + Attach::StartIfAbsent, + Attach::StartIfAbsent + ] + ); + } + + #[test] + fn a_lost_lease_is_retried_from_the_base_delay() { + // Fails twice (5 s, 10 s), holds a lease for two polls, loses it, then + // the next retry waits the base delay again rather than 20 s. + let script = Script::new(vec![None, None, Some(2), None], 8); + script.run(DaemonRetryPolicy::from_millis(5_000, 300_000)); + assert_eq!( + *script.waits.borrow(), + [ + ms(5_000), + ms(10_000), + POLL, + POLL, + POLL, + ms(5_000), + ms(10_000), + ms(20_000) + ] + ); + } + + #[test] + fn zero_retry_delay_attaches_once_and_never_retries() { + let failed = Script::new(vec![None, Some(9)], 10); + failed.run(DaemonRetryPolicy::from_millis(0, 300_000)); + assert_eq!(*failed.modes.borrow(), [Attach::JustSpawned]); + assert!(failed.waits.borrow().is_empty()); + + let lost = Script::new(vec![Some(1), Some(9)], 10); + lost.run(DaemonRetryPolicy::from_millis(0, 300_000)); + assert_eq!(*lost.modes.borrow(), [Attach::JustSpawned]); + assert_eq!(*lost.waits.borrow(), [POLL, POLL]); + } + + #[test] + fn a_stop_request_ends_the_keeper_while_a_lease_is_held() { + let script = Script::new(vec![Some(u32::MAX)], 3); + script.run(DaemonRetryPolicy::from_millis(5_000, 300_000)); + assert_eq!(*script.modes.borrow(), [Attach::JustSpawned]); + assert_eq!(*script.waits.borrow(), [POLL, POLL, POLL]); + } + + #[test] + fn a_session_whose_install_changed_never_retries() { + // The first attach fails; the retry is refused, so no daemon is started. + let script = Script::new(vec![None, Some(9)], 10); + script.may_retry.set(false); + script.run(DaemonRetryPolicy::from_millis(5_000, 300_000)); + assert_eq!(*script.modes.borrow(), [Attach::JustSpawned]); + assert_eq!(*script.waits.borrow(), [ms(5_000)]); + } + + #[test] + fn dropping_the_keeper_stops_its_waits() { + let (stop, stopped) = mpsc::channel::<()>(); + assert!(wait_unless_stopped(&stopped, ms(1))); + drop(stop); + assert!(!wait_unless_stopped(&stopped, ms(60_000))); + } + + #[test] + fn delay_values_parse_like_upstream() { + assert_eq!(parse_delay_ms(None, 5_000), 5_000); + assert_eq!(parse_delay_ms(Some(""), 5_000), 5_000); + assert_eq!(parse_delay_ms(Some(" "), 5_000), 5_000); + assert_eq!(parse_delay_ms(Some("0"), 5_000), 0); + assert_eq!(parse_delay_ms(Some("250"), 5_000), 250); + assert_eq!(parse_delay_ms(Some("1500.9"), 5_000), 1_500); + assert_eq!(parse_delay_ms(Some("-1"), 5_000), 5_000); + assert_eq!(parse_delay_ms(Some("soon"), 5_000), 5_000); + assert_eq!(parse_delay_ms(Some("NaN"), 5_000), 5_000); + } + + #[test] + fn the_cap_is_never_below_the_base() { + let policy = DaemonRetryPolicy::from_millis(10_000, 1_000); + assert_eq!(policy.next(ms(10_000)), ms(10_000)); + assert!(policy.retries()); + assert!(!DaemonRetryPolicy::from_millis(0, 1_000).retries()); + } +} diff --git a/crates/codegraph-daemon/src/lib.rs b/crates/codegraph-daemon/src/lib.rs index 3e563c3d..56a3595e 100644 --- a/crates/codegraph-daemon/src/lib.rs +++ b/crates/codegraph-daemon/src/lib.rs @@ -7,6 +7,8 @@ pub mod control; pub mod http_registry; +mod install; +mod lease_keeper; mod lock; pub mod mcp_registry; mod paths; @@ -28,7 +30,12 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; pub use control::{ CONTROL_PROTOCOL, ControlAck, ControlFrame, ShutdownOutcome, parse_control_frame, - request_daemon_shutdown, + request_daemon_shutdown, request_daemon_shutdown_of, +}; +pub use install::{CODEGRAPH_DAEMON_INSTALL_CHECK_MS, InstallIdentity, install_check_interval}; +pub use lease_keeper::{ + CODEGRAPH_DAEMON_RETRY_MAX_MS, CODEGRAPH_DAEMON_RETRY_MS, DaemonRetryPolicy, + SessionLeaseKeeper, keep_session_attached, }; pub use lock::{ AcquireResult, DaemonLockInfo, clear_stale_daemon_lock, clear_stale_daemon_socket, @@ -40,8 +47,11 @@ pub use process::{ SupervisionState, current_ppid, is_process_alive, is_session_leader, supervision_lost_reason, terminate_pid, }; -pub use project_service::{ProjectDaemonLease, project_service_broker, retain_project_daemon}; -pub use proxy::{ProxyOutcome, run_proxy, verify_daemon_hello}; +pub use project_service::{ + ProjectDaemonLease, attach_project_daemon_passive, project_service_broker, + retain_project_daemon, retain_project_daemon_passive, +}; +pub use proxy::{DaemonPeer, ProxyOutcome, classify_daemon_hello, run_proxy, verify_daemon_hello}; pub use session::{SessionRegistry, read_daemon_hello, run_session_recv}; pub use spawn::{ CODEGRAPH_HTTP_DETACH_INTERNAL, CODEGRAPH_SKIP_STARTUP_CATCHUP, spawn_detached_daemon, @@ -195,6 +205,14 @@ pub struct DaemonOptions { /// acknowledges success, so the caller fails closed. Defaults to /// [`DRAIN_TIMEOUT`]. pub drain_budget: Duration, + /// The install this daemon runs, recorded at start. Once the file there is + /// replaced or removed (an upgrade), the daemon drains and exits so the next + /// session starts one from the current install (upstream #2346). Defaults + /// to the running executable; `None` disables the check. + pub install: Option, + /// How often [`Self::install`] is checked. Defaults to + /// [`install_check_interval`]; `None` disables the check. + pub install_check: Option, } impl Default for DaemonOptions { @@ -206,6 +224,8 @@ impl Default for DaemonOptions { run_mcp: true, watch: true, drain_budget: DRAIN_TIMEOUT, + install: InstallIdentity::current(), + install_check: install_check_interval(), } } } @@ -363,6 +383,22 @@ pub fn run_foreground_gated( } } +/// Whether a daemon could be listening at the recorded rendezvous +/// `socket_path` now. On Unix that is its socket file existing. On Windows the +/// record holds a bare pipe name with no file behind it, so only connecting +/// can tell; the probe's connection is dropped at once and its session ends. +pub fn rendezvous_accepts(socket_path: &Path) -> bool { + #[cfg(unix)] + { + socket_path.exists() + } + #[cfg(windows)] + { + !socket_path.as_os_str().is_empty() + && connect(&Rendezvous::from_socket_path(socket_path)).is_ok() + } +} + pub fn attach_to_daemon(socket_path: &Path) -> Result { let rendezvous = Rendezvous::from_socket_path(socket_path); let mut stream = connect(&rendezvous) @@ -563,6 +599,7 @@ async fn run_accept_loop_async( let mut ticker = tokio::time::interval(Duration::from_millis(tick_ms)); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let mut last_sweep = std::time::Instant::now(); + let mut last_install_check = std::time::Instant::now(); let stop_reason: Option = loop { if shutdown.load(Ordering::SeqCst) { @@ -630,6 +667,21 @@ async fn run_accept_loop_async( sweep_dead_clients(®istry); last_sweep = std::time::Instant::now(); } + // A daemon keeps running the code it started with. Once its + // install is replaced or removed it steps aside, so the next + // session starts a daemon from the current install (#2346). + if let (Some(install), Some(every)) = (&options.install, options.install_check) + && last_install_check.elapsed() >= every + { + last_install_check = std::time::Instant::now(); + if install.changed() { + info!( + install = %install.path().display(), + "daemon exiting: its install was upgraded or removed" + ); + break None; + } + } let idle_ms = registry.millis_since_active(); if idle_ms > max_idle_ms { sweep_dead_clients(®istry); @@ -1276,6 +1328,31 @@ mod tests { let _ = fs::remove_dir_all(&root); } + #[test] + fn a_rendezvous_nobody_binds_accepts_nothing() { + let root = temp_root("accepts-dead"); + assert!(!rendezvous_accepts(&socket_path_of(&root))); + assert!(!rendezvous_accepts(Path::new(""))); + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn a_bound_rendezvous_accepts() { + let root = temp_root("accepts-bound"); + create_rendezvous_dir(&root); + let (_listener, bound) = + bind_with_fallback(&root, socket_path_of(&root)).expect("the preferred socket binds"); + assert!( + rendezvous_accepts(&bound), + "a bound rendezvous is found, by file on Unix and by connecting on Windows" + ); + #[cfg(unix)] + if let Some(stale) = Rendezvous::from_socket_path(&bound).cleanup_path() { + let _ = fs::remove_file(stale); + } + let _ = fs::remove_dir_all(&root); + } + #[test] fn attach_to_a_dead_socket_path_errors() { let root = temp_root("attach-dead"); diff --git a/crates/codegraph-daemon/src/project_service.rs b/crates/codegraph-daemon/src/project_service.rs index f9cb13ef..d1418b5e 100644 --- a/crates/codegraph-daemon/src/project_service.rs +++ b/crates/codegraph-daemon/src/project_service.rs @@ -83,21 +83,7 @@ pub fn project_service_broker(no_watch: bool) -> codegraph_mcp::ProjectServiceBr /// lease serializes writers; this final catch-up runs after whichever pass won /// the lease and therefore observes its result before the tool query executes. pub fn retain_project_daemon(project_root: &Path, no_watch: bool) -> Result { - let paths = IndexPaths::resolve(project_root, std::env::var("CODEGRAPH_DIR").ok().as_deref())?; - let project_root = paths.project().to_path_buf(); - if !paths.current_root().is_dir() || !paths.current_db().is_file() { - bail!( - "refusing live services for unindexed project {}; run `codegraph init {}` first", - project_root.display(), - project_root.display() - ); - } - if let Some(reason) = codegraph_watch::too_broad_root_reason(&project_root) { - bail!( - "refusing live services for {}: {reason}", - project_root.display() - ); - } + let project_root = live_service_root(project_root)?; if !daemon_owner_live(&project_root) { // Liveness-gated cleanup only; a live owner's rendezvous is never @@ -119,6 +105,59 @@ pub fn retain_project_daemon(project_root: &Path, no_watch: bool) -> Result Result { + let project_root = live_service_root(project_root)?; + if !daemon_owner_live(&project_root) { + let _ = crate::clear_stale_daemon_socket(&project_root); + let executable = std::env::current_exe().context("resolving codegraph executable")?; + crate::spawn_detached_daemon(&executable, &project_root, no_watch) + .context("starting shared daemon for a stdio session")?; + } + connect_project_daemon_bounded(project_root) +} + +/// Retain a passive connection to a daemon this process has just spawned, +/// waiting for its socket without starting another one: a fresh daemon may not +/// have published its pid yet, and a second spawn would only lose the pid-lock +/// race. +pub fn attach_project_daemon_passive(project_root: &Path) -> Result { + let project_root = live_service_root(project_root)?; + connect_project_daemon_bounded(project_root) +} + +/// Resolve `project_root` to its index's project and refuse an unindexed or +/// too-broad root before any process or sync action, so no live-service path +/// ever creates `.codegraph`. +fn live_service_root(project_root: &Path) -> Result { + let paths = IndexPaths::resolve(project_root, std::env::var("CODEGRAPH_DIR").ok().as_deref())?; + let project_root = paths.project().to_path_buf(); + if !paths.current_root().is_dir() || !paths.current_db().is_file() { + bail!( + "refusing live services for unindexed project {}; run `codegraph init {}` first", + project_root.display(), + project_root.display() + ); + } + if let Some(reason) = codegraph_watch::too_broad_root_reason(&project_root) { + bail!( + "refusing live services for {}: {reason}", + project_root.display() + ); + } + Ok(project_root) +} + fn daemon_owner_live(project_root: &Path) -> bool { daemon_owner_pid(project_root).is_some() } diff --git a/crates/codegraph-daemon/src/proxy.rs b/crates/codegraph-daemon/src/proxy.rs index ee99a3ae..30578913 100644 --- a/crates/codegraph-daemon/src/proxy.rs +++ b/crates/codegraph-daemon/src/proxy.rs @@ -64,6 +64,60 @@ pub enum ProxyOutcome { VersionMismatch, } +/// How a running daemon's build compares with this one, read from its hello. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaemonPeer { + /// The same version and protocol: attach to it. + Same, + /// A plain `X.Y.Z` release older than this plain `X.Y.Z` build. A launcher + /// replaces it (upstream #2343). + OlderRelease { version: String }, + /// Anything else: a newer release, a prerelease, an unknown version, or + /// another protocol at the same version. It is never stopped, so two + /// installs cannot take turns replacing each other's daemon. + Foreign { version: Option }, +} + +/// Classify a daemon hello against this build; see [`DaemonPeer`]. +#[must_use] +pub fn classify_daemon_hello(hello: &Value) -> DaemonPeer { + classify_against(hello, env!("CARGO_PKG_VERSION")) +} + +fn classify_against(hello: &Value, ours: &str) -> DaemonPeer { + let version = hello.get("codegraph").and_then(Value::as_str); + let protocol = hello.get("protocol").and_then(Value::as_u64); + if version == Some(ours) && protocol == Some(EXPECTED_PROTOCOL) { + return DaemonPeer::Same; + } + match ( + version, + version.and_then(plain_release), + plain_release(ours), + ) { + (Some(version), Some(peer), Some(ours)) if peer < ours => DaemonPeer::OlderRelease { + version: version.to_string(), + }, + _ => DaemonPeer::Foreign { + version: version.map(str::to_string), + }, + } +} + +/// `X.Y.Z` with three all-digit parts and nothing else, or `None`: a +/// prerelease or build suffix is never ordered against a release. +fn plain_release(version: &str) -> Option<(u64, u64, u64)> { + let mut parts = version.split('.'); + let mut next = || { + parts + .next() + .filter(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) + .and_then(|part| part.parse::().ok()) + }; + let release = (next()?, next()?, next()?); + parts.next().is_none().then_some(release) +} + /// Verify the daemon hello matches THIS build: `codegraph` version equals /// `CARGO_PKG_VERSION` and `protocol` equals [`EXPECTED_PROTOCOL`]. /// @@ -691,6 +745,65 @@ mod tests { ); } + #[test] + fn only_an_older_plain_release_is_classified_for_replacement() { + let hello = |version: Option<&str>, protocol: u64| match version { + Some(version) => json!({ "codegraph": version, "protocol": protocol }), + None => json!({ "protocol": protocol }), + }; + let ours = "0.55.0"; + assert_eq!( + classify_against(&hello(Some(ours), EXPECTED_PROTOCOL), ours), + DaemonPeer::Same + ); + for older in ["0.54.0", "0.54.12", "0.0.1"] { + assert_eq!( + classify_against(&hello(Some(older), EXPECTED_PROTOCOL), ours), + DaemonPeer::OlderRelease { + version: older.to_string() + }, + "{older}" + ); + } + for foreign in [ + "0.55.1", + "1.0.0", + "0.56.0-rc.1", + "0.54.0-rc.1", + "0.54", + "0.54.0.1", + "v0.54.0", + "0.54.x", + "", + ] { + assert_eq!( + classify_against(&hello(Some(foreign), EXPECTED_PROTOCOL), ours), + DaemonPeer::Foreign { + version: Some(foreign.to_string()) + }, + "{foreign:?}" + ); + } + assert_eq!( + classify_against(&hello(None, EXPECTED_PROTOCOL), ours), + DaemonPeer::Foreign { version: None } + ); + // Another protocol at the same version is never replaced either. + assert_eq!( + classify_against(&hello(Some(ours), EXPECTED_PROTOCOL + 1), ours), + DaemonPeer::Foreign { + version: Some(ours.to_string()) + } + ); + // A prerelease build never replaces anything. + assert_eq!( + classify_against(&hello(Some("0.54.0"), EXPECTED_PROTOCOL), "0.55.0-dev"), + DaemonPeer::Foreign { + version: Some("0.54.0".to_string()) + } + ); + } + #[test] fn verify_daemon_hello_accepts_matching_build() { let hello = json!({ diff --git a/crates/codegraph-daemon/src/session.rs b/crates/codegraph-daemon/src/session.rs index 432de282..1f0867b5 100644 --- a/crates/codegraph-daemon/src/session.rs +++ b/crates/codegraph-daemon/src/session.rs @@ -330,6 +330,30 @@ impl ControlHandle { } } +/// The version this daemon announces in its hello: its own build's. A +/// `test-hooks` build may announce `CODEGRAPH_TEST_DAEMON_HELLO_VERSION` +/// instead, so a test can stand up a daemon that claims to be an older or a +/// newer release. +fn hello_version() -> String { + #[cfg(feature = "test-hooks")] + if let Ok(version) = std::env::var("CODEGRAPH_TEST_DAEMON_HELLO_VERSION") { + return version; + } + env!("CARGO_PKG_VERSION").to_string() +} + +/// Whether a `test-hooks` build was asked (`CODEGRAPH_TEST_DAEMON_IGNORE_CONTROL`) +/// to close every control frame's connection without an answer, the way a +/// wedged or pre-control-protocol daemon fails to drain. Always false in a +/// normal build. +fn ignores_control_frames() -> bool { + #[cfg(feature = "test-hooks")] + if std::env::var_os("CODEGRAPH_TEST_DAEMON_IGNORE_CONTROL").is_some() { + return true; + } + false +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DaemonHello<'a> { @@ -375,8 +399,9 @@ pub(crate) async fn serve_session_async( } } + let version = hello_version(); let hello = DaemonHello { - codegraph: env!("CARGO_PKG_VERSION"), + codegraph: &version, pid: std::process::id(), socket_path, protocol: 1, @@ -413,6 +438,10 @@ pub(crate) async fn serve_session_async( // `uninit --force` holds the namespace's exclusive lease. if let Some(frame) = parse_control_frame(&line) { drop(guard); + if ignores_control_frames() { + debug!("test hook: closing a control frame's connection unanswered"); + return Ok(()); + } return serve_control_frame(send, &frame, control.as_ref()).await; } if !run_mcp { diff --git a/crates/codegraph-daemon/src/spawn.rs b/crates/codegraph-daemon/src/spawn.rs index f60d3c24..6f73db60 100644 --- a/crates/codegraph-daemon/src/spawn.rs +++ b/crates/codegraph-daemon/src/spawn.rs @@ -65,6 +65,7 @@ fn spawn_detached_daemon_inner( // Resolved once, fail-closed: the log target must be the project's own v2 // rendezvous log, never a reconstructed path. let log_path = daemon_log_path(root)?; + // console-window-guard: `detach` starts it with no console (DETACHED_PROCESS). let mut command = Command::new(exe); command .arg("serve") @@ -119,6 +120,7 @@ pub fn spawn_detached_http( project: Option<&Path>, log_file: &Path, ) -> Result { + // console-window-guard: `detach` starts it with no console (DETACHED_PROCESS). let mut command = Command::new(exe); command .arg("serve") diff --git a/crates/codegraph-daemon/tests/install_check.rs b/crates/codegraph-daemon/tests/install_check.rs new file mode 100644 index 00000000..7eb039b2 --- /dev/null +++ b/crates/codegraph-daemon/tests/install_check.rs @@ -0,0 +1,120 @@ +//! A daemon whose install was replaced steps aside (upstream #2346). +//! +//! The daemon records the executable it runs when it starts. These tests point +//! that record at a stand-in file, replace or remove the file, and require the +//! daemon to drain and exit within two check intervals, removing its own +//! rendezvous. An untouched install keeps the daemon running. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use codegraph_daemon::{ + DaemonHandle, DaemonOptions, InstallIdentity, StartOrAttach, daemon_pid_path, start_or_attach, +}; + +const CHECK_EVERY: Duration = Duration::from_millis(100); +/// Two check intervals, plus slack for a loaded runner to finish the drain. +const EXIT_WITHIN: Duration = Duration::from_millis(2 * 100 + 1_800); + +struct TempDir(PathBuf); + +impl TempDir { + fn new(label: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "codegraph-daemon-install-{label}-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(path.join("project")).expect("create project"); + Self(path.canonicalize().expect("canonicalize temp dir")) + } + + fn project(&self) -> PathBuf { + self.0.join("project") + } + + fn install(&self) -> PathBuf { + self.0.join("codegraph-install") + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn start_daemon(dir: &TempDir) -> DaemonHandle { + fs::write(dir.install(), b"the build the daemon started from").expect("write stand-in install"); + let options = DaemonOptions { + watchdog_interval: Duration::from_millis(10), + run_mcp: false, + watch: false, + install: Some(InstallIdentity::of(&dir.install()).expect("readable stand-in install")), + install_check: Some(CHECK_EVERY), + ..DaemonOptions::default() + }; + match start_or_attach(dir.project(), options).expect("daemon starts") { + StartOrAttach::Started(handle) => handle, + StartOrAttach::Attached(_) => panic!("a fresh project attached to a daemon"), + } +} + +fn wait_finished(handle: &DaemonHandle, within: Duration) -> bool { + let deadline = Instant::now() + within; + while Instant::now() < deadline { + if handle.is_finished() { + return true; + } + thread::sleep(Duration::from_millis(10)); + } + handle.is_finished() +} + +fn assert_rendezvous_removed(project: &Path) { + let pid_path = daemon_pid_path(project).expect("resolve the rendezvous pid path"); + assert!( + !pid_path.exists(), + "an exiting daemon removes its own pid record" + ); +} + +#[test] +fn a_replaced_install_makes_the_daemon_exit() { + let dir = TempDir::new("replaced"); + let handle = start_daemon(&dir); + assert!( + !wait_finished(&handle, 3 * CHECK_EVERY), + "an untouched install keeps the daemon running" + ); + + // An upgrade writes the new build beside the old one and renames it over. + let staged = dir.0.join("codegraph-install.new"); + fs::write(&staged, b"the build an upgrade installed").expect("stage the upgrade"); + fs::rename(&staged, dir.install()).expect("install the upgrade"); + + assert!( + wait_finished(&handle, EXIT_WITHIN), + "the daemon kept running {EXIT_WITHIN:?} after its install was replaced" + ); + handle.stop().expect("the finished daemon joins"); + assert_rendezvous_removed(&dir.project()); +} + +#[test] +fn a_removed_install_makes_the_daemon_exit() { + let dir = TempDir::new("removed"); + let handle = start_daemon(&dir); + fs::remove_file(dir.install()).expect("uninstall"); + assert!( + wait_finished(&handle, EXIT_WITHIN), + "the daemon kept running {EXIT_WITHIN:?} after its install was removed" + ); + handle.stop().expect("the finished daemon joins"); + assert_rendezvous_removed(&dir.project()); +} diff --git a/crates/codegraph-extract/src/commonjs_requires.rs b/crates/codegraph-extract/src/commonjs_requires.rs new file mode 100644 index 00000000..1174db4f --- /dev/null +++ b/crates/codegraph-extract/src/commonjs_requires.rs @@ -0,0 +1,165 @@ +//! A CommonJS `require('./x')` as a file import (upstream #2263 +//! `commonJsRequireRefs`). +//! +//! An ESM `import … from './application'` is an import statement the walker +//! turns into an `imports` reference from the file, which the import +//! resolver lands on the module's file. A `require('./application')` is only +//! a call, so a CommonJS file depended on nothing at file level: Express's +//! `lib/express.js` requires `./application`, `./request` and `./response` +//! and the index said it imports nothing. Each local specifier a +//! `require(…)` call names is now an `imports` reference from the file. A +//! package (`express`, `lodash/fp`) stays outside the index, and the names a +//! binding is called through keep resolving through the require binding. +//! +//! Upstream reads the comment-stripped source with a regex; the syntax tree +//! is read here, which leaves comments, strings and TypeScript's +//! `import x = require('y')` (an import statement already) out by +//! construction. + +use codegraph_core::types::{EdgeKind, Language, UnresolvedRef}; +use tree_sitter::Node as SyntaxNode; + +/// The `imports` references of `root`'s local `require('…')` calls, once per +/// specifier in source order, from the file node of `file_path`. Empty +/// outside JavaScript, JSX, TypeScript and TSX. +pub(crate) fn require_refs( + file_path: &str, + source: &str, + root: SyntaxNode<'_>, + language: Language, +) -> Vec { + if !matches!( + language, + Language::JavaScript | Language::Jsx | Language::TypeScript | Language::Tsx + ) || !source.contains("require") + { + return Vec::new(); + } + let mut out: Vec = Vec::new(); + let mut stack = vec![root]; + let mut found: Vec<(usize, String, i64, i64)> = Vec::new(); + while let Some(node) = stack.pop() { + if node.kind() == "call_expression" + && let Some(specifier) = required_specifier(node, source) + { + let position = node.start_position(); + found.push(( + node.start_byte(), + specifier, + position.row as i64 + 1, + position.column as i64, + )); + } + let mut cursor = node.walk(); + let children: Vec> = node.named_children(&mut cursor).collect(); + stack.extend(children.into_iter().rev()); + } + found.sort_by_key(|(at, ..)| *at); + for (_, specifier, line, col) in found { + if !is_local_specifier(&specifier) || out.iter().any(|r| r.reference_name == specifier) { + continue; + } + out.push(UnresolvedRef { + id: None, + from_node_id: format!("file:{file_path}"), + reference_name: specifier, + reference_kind: EdgeKind::Imports, + line, + col, + candidates: None, + file_path: file_path.to_string(), + language, + is_function_ref: false, + reference_subkind: None, + }); + } + out +} + +/// The specifier of `require('x')`: a bare `require` (not `foo.require(…)`, +/// not `require.resolve(…)`) called with one literal string, or a template +/// literal with no `${…}` hole, on one line. +fn required_specifier(call: SyntaxNode<'_>, source: &str) -> Option { + let function = call.child_by_field_name("function")?; + if function.kind() != "identifier" || function.utf8_text(source.as_bytes()).ok()? != "require" { + return None; + } + let arguments = call.child_by_field_name("arguments")?; + if arguments.kind() != "arguments" + || !source + .get(function.end_byte()..arguments.start_byte())? + .trim() + .is_empty() + { + return None; + } + let mut cursor = arguments.walk(); + let mut named = arguments.named_children(&mut cursor); + let literal = named.next()?; + if named.next().is_some() { + return None; + } + let text = literal.utf8_text(source.as_bytes()).ok()?; + let inner = match literal.kind() { + "string" => text + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .or_else(|| { + text.strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + })?, + "template_string" => { + let inner = text.strip_prefix('`')?.strip_suffix('`')?; + if inner.contains('$') { + return None; + } + inner + } + _ => return None, + }; + let specifier = inner.trim(); + (!specifier.is_empty() && !inner.contains('\n')).then(|| specifier.to_string()) +} + +/// A module of this project: a relative or rooted path, or a project alias +/// (`@/lib/x`, `~/x`, `#internal`). A package is outside the index. +fn is_local_specifier(specifier: &str) -> bool { + matches!(specifier, "." | "..") + || specifier.starts_with("./") + || specifier.starts_with("../") + || specifier.starts_with('/') + || specifier.starts_with("@/") + || specifier.starts_with("~/") + || specifier.starts_with('#') +} + +#[cfg(test)] +mod tests { + use super::is_local_specifier; + + #[test] + fn local_specifiers_are_paths_and_project_aliases() { + for local in [ + ".", + "..", + "./x", + "../x", + "/abs", + "@/lib/x", + "~/x", + "#internal", + ] { + assert!(is_local_specifier(local), "{local}"); + } + for package in [ + "express", + "lodash/fp", + "@scope/pkg", + "node:fs", + ".hidden", + "~x", + ] { + assert!(!is_local_specifier(package), "{package}"); + } + } +} diff --git a/crates/codegraph-extract/src/embedded/astro.rs b/crates/codegraph-extract/src/embedded/astro.rs index d21d9100..3610a040 100644 --- a/crates/codegraph-extract/src/embedded/astro.rs +++ b/crates/codegraph-extract/src/embedded/astro.rs @@ -5,15 +5,18 @@ //! JSX-like template, and optional client ` +{ \"en\": { \"a\": \"{{ notTemplate() }}\" } }" + ), + ["cond", "inner", "after"] + ); + assert!(names("").is_empty()); + assert!(names("").is_empty()); + } +} diff --git a/crates/codegraph-extract/src/engine.rs b/crates/codegraph-extract/src/engine.rs index f7d3885d..f92413c9 100644 --- a/crates/codegraph-extract/src/engine.rs +++ b/crates/codegraph-extract/src/engine.rs @@ -181,6 +181,11 @@ pub fn detect_language_with( ) -> Language { let path = file_path.as_ref(); let normalized = normalize_path(path); + // A Shopify theme's JSON template or section group is Liquid, and only + // inside a theme under the project root (upstream #2393). + if crate::embedded::shopify::is_shopify_liquid_json(&normalized, overrides.project_root()) { + return Language::Liquid; + } if let Some(language) = crate::embedded::detect_embedded_language(&normalized) { return language; } @@ -202,6 +207,148 @@ pub fn detect_language_with( Language::Unknown } +/// The language `file_path` is indexed as, given its `source`: the language +/// of its extension, except that a `.h` header holding C++ or Objective-C is +/// that language, a JavaScript file carrying Flow's `@flow` pragma is TSX, +/// and an `.inc` include of Pascal shapes is Pascal. Extraction parses a file +/// with it and every path that records a file row stores it, so the row +/// agrees with the file's nodes. +pub fn detect_language_for_source( + file_path: &str, + source: &str, + overrides: &ExtensionOverrides, +) -> Language { + sniff_include_language( + file_path, + sniff_flow_language( + sniff_header_language( + file_path, + detect_language_with(file_path, overrides), + source, + ), + source, + ), + source, + overrides, + ) +} + +/// [`detect_language_for_source`] for a file as the bounded reader returned +/// it. A file over the size limit was never read, so only its extension +/// decides; it has no nodes to disagree with. +pub fn detect_language_of( + file_path: &str, + source: &SourceText, + overrides: &ExtensionOverrides, +) -> Language { + match source { + SourceText::Text(text) => detect_language_for_source(file_path, text, overrides), + SourceText::Oversize(_) | SourceText::MpegTransportStream => { + detect_language_with(file_path, overrides) + } + } +} + +/// `language` refined by `source` when `file_path` is a `.h` header of it: +/// C++ or Objective-C content makes the header that language. +fn sniff_header_language(file_path: &str, language: Language, source: &str) -> Language { + let header = Path::new(file_path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("h")); + if language != Language::C || !header { + language + } else if looks_like_cpp(source) { + Language::Cpp + } else if looks_like_objc(source) { + Language::ObjC + } else { + language + } +} + +/// `language` refined by `source` when `file_path` is an `.inc` include of +/// it (upstream #2279): PHP's include extension and Pascal's too, so an +/// untagged include of Pascal shapes is Pascal +/// ([`crate::pascal_include::looks_like_pascal_include`]). A project that +/// maps `.inc` in `codegraph.json` has answered, and is not second-guessed; +/// its mapping, like any of a built-in extension, leaves `.inc` PHP. +fn sniff_include_language( + file_path: &str, + language: Language, + source: &str, + overrides: &ExtensionOverrides, +) -> Language { + let include = Path::new(file_path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("inc")); + if language == Language::Php + && include + && overrides.language_for("inc").is_none() + && crate::pascal_include::looks_like_pascal_include(source) + { + Language::Pascal + } else { + language + } +} + +/// `language` refined by `source` when it is JavaScript: a Flow-typed file +/// (upstream #2161) is read as TSX. The JavaScript grammar cannot parse Flow's +/// annotations (`render(): React.Node` cut a class short), TypeScript's syntax +/// covers most of Flow's, and the TypeScript spec's `pre_parse` blanks the +/// rest. +fn sniff_flow_language(language: Language, source: &str) -> Language { + if matches!(language, Language::JavaScript | Language::Jsx) && has_flow_pragma(source) { + Language::Tsx + } else { + language + } +} + +/// Whether a JavaScript file's leading comments carry Flow's `@flow` pragma +/// and not `@noflow` (upstream `hasFlowPragma`): the comments, after an +/// optional shebang line, before any code in the first 4096 UTF-16 code units. +pub(crate) fn has_flow_pragma(source: &str) -> bool { + static FLOW: OnceLock = OnceLock::new(); + static NOFLOW: OnceLock = OnceLock::new(); + let mut units = 0; + let head = source + .char_indices() + .find(|(_, ch)| { + units += ch.len_utf16(); + units > 4096 + }) + .map_or(source, |(at, _)| &source[..at]); + let head = head + .strip_prefix("#!") + .and_then(|rest| rest.split_once('\n')) + .map_or(head, |(_, rest)| rest); + let mut lead = 0; + loop { + let rest = &head[lead..]; + let code = rest.trim_start_matches(codegraph_core::generated_header::is_js_whitespace); + let comment = if let Some(line) = code.strip_prefix("//") { + 2 + line.find('\n').unwrap_or(line.len()) + } else if let Some(block) = code.strip_prefix("/*") { + match block.find("*/") { + Some(close) => 2 + close + 2, + None => break, + } + } else { + break; + }; + lead += rest.len() - code.len() + comment; + } + let lead = &head[..lead]; + FLOW.get_or_init(|| Regex::new(r"@flow(?-u:\b)").expect("flow pragma")) + .is_match(lead) + && !NOFLOW + .get_or_init(|| Regex::new(r"@noflow(?-u:\b)").expect("noflow pragma")) + .is_match(lead) +} + /// A `.h` file maps to `Language::C` by extension, but may hold C++ or /// Objective-C. The ordinary unique-C++ probes retain their bounded 8 KiB pass; /// a second full-source pass recognizes a plain class/struct base clause, whose @@ -287,20 +434,22 @@ pub fn extract_source_with_observer( ) -> ExtractionResult { let start = Instant::now(); observer(ExtractionStage::DetectLanguage); - let mut language = language.unwrap_or_else(|| detect_language_with(file_path, overrides)); + let named = language; + let language = language.unwrap_or_else(|| detect_language_with(file_path, overrides)); observer(ExtractionStage::Prepare); - if language == Language::C - && Path::new(file_path) - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("h")) - { - if looks_like_cpp(source) { - language = Language::Cpp; - } else if looks_like_objc(source) { - language = Language::ObjC; - } - } + let language = sniff_header_language(file_path, language, source); + // A caller that names the language (an embedded block's delegation) keeps + // it, as upstream's `language || detectLanguage(filePath, source)` does. + let language = if named.is_none() { + sniff_include_language( + file_path, + sniff_flow_language(language, source), + source, + overrides, + ) + } else { + language + }; observer(ExtractionStage::Embedded); if let Some(result) = crate::embedded::extract_embedded(file_path, source, language) { return result; @@ -363,7 +512,24 @@ pub fn extract_source_with_observer( observer(ExtractionStage::Walk); let parse_has_error = tree.root_node().has_error(); let mut result = TreeSitterWalker::new(file_path, &parsed_source, spec, tree.root_node()) + .with_raw_source(source) .extract(start.elapsed().as_millis() as i64); + // A CommonJS `require('./x')` is a file import, as ESM's `import` is + // (upstream #2263). + if result + .nodes + .iter() + .any(|node| node.kind == codegraph_core::types::NodeKind::File) + { + result + .unresolved_references + .extend(crate::commonjs_requires::require_refs( + file_path, + &parsed_source, + tree.root_node(), + language, + )); + } if parse_has_error && result .nodes @@ -893,20 +1059,61 @@ pub fn size_skip_result(file_path: &str, size: u64, max: u64) -> ExtractionResul nodes: Vec::new(), edges: Vec::new(), unresolved_references: Vec::new(), - errors: vec![format!( - "File exceeds max size ({size} > {max}): {file_path}" - )], + errors: vec![format!("{SIZE_SKIP_PREFIX} ({size} > {max}): {file_path}")], duration_ms: 0, } } -/// The project-root `.gitignore`, read with git's own rules through the -/// `ignore` crate, as upstream reads it with the `ignore` package: a leading or -/// inner `/` anchors a rule to the root, a slash-less rule applies at any -/// depth, `*` and `**` glob, a trailing `/` matches directories only, and `!` -/// re-includes. Shared with `codegraph-watch`, so the watcher's `.gitignore` -/// verdict is the scan's by construction. An unreadable, non-UTF-8 or NUL-laden -/// file is treated as absent and an unparseable line is skipped, never fatal. +/// The repository's own exclude file, relative to the project root. git reads +/// it beside every `.gitignore` for files nobody wants committed (upstream +/// #1728). +pub const REPOSITORY_EXCLUDE: &str = ".git/info/exclude"; + +/// The largest exclude file read; a bigger one is treated as absent. +const REPOSITORY_EXCLUDE_MAX_BYTES: u64 = 1024 * 1024; + +/// The directory holding [`REPOSITORY_EXCLUDE`], when both it and `.git` are +/// real directories of the project root. A `.git` file (a linked worktree or a +/// submodule) names a git directory outside the project, and a symlinked +/// `.git` or `info` can lead there too, so none of them is followed. +pub fn repository_exclude_dir(root: &Path) -> Option { + let real_dir = |path: &Path| { + fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir()) + }; + let git = root.join(".git"); + let info = git.join("info"); + (real_dir(&git) && real_dir(&info)).then_some(info) +} + +/// The text of [`REPOSITORY_EXCLUDE`]: a regular file (not a link) in +/// [`repository_exclude_dir`], read through the bounded source reader, so it +/// is stat-ed before it is opened and a FIFO there is never opened. `None` +/// when there is no such file, or it is unreadable, over the size limit or +/// NUL-laden. +pub fn read_repository_exclude(root: &Path) -> Option { + let path = repository_exclude_dir(root)?.join("exclude"); + if !fs::symlink_metadata(&path).ok()?.file_type().is_file() { + return None; + } + match read_source_file(&path, REPOSITORY_EXCLUDE, REPOSITORY_EXCLUDE_MAX_BYTES) + .ok()? + .1 + { + SourceText::Text(text) if !text.contains('\0') => Some(text), + _ => None, + } +} + +/// The project-root `.gitignore`, together with the repository's own +/// [`REPOSITORY_EXCLUDE`], read with git's own rules through the `ignore` +/// crate, as upstream reads them with the `ignore` package: a leading or inner +/// `/` anchors a rule to the root, a slash-less rule applies at any depth, `*` +/// and `**` glob, a trailing `/` matches directories only, and `!` re-includes. +/// git ranks a `.gitignore` above the exclude file, so the exclude rules are +/// read first and a `.gitignore` line wins a conflict with them. Shared with +/// `codegraph-watch`, so the watcher's verdict is the scan's by construction. +/// An unreadable, non-UTF-8 or NUL-laden `.gitignore` is treated as absent and +/// an unparseable line is skipped, never fatal. #[derive(Debug, Clone, Default)] pub struct RootGitignore { matcher: Option, @@ -914,17 +1121,19 @@ pub struct RootGitignore { impl RootGitignore { pub fn load(root: &Path) -> Self { - let Ok(text) = fs::read_to_string(root.join(".gitignore")) else { - return Self::default(); - }; - if text.contains('\0') { - return Self::default(); - } + let gitignore = fs::read_to_string(root.join(".gitignore")) + .ok() + .filter(|text| !text.contains('\0')); // Rooted at `.` so a root-relative candidate is never prefix-stripped. let mut builder = ignore::gitignore::GitignoreBuilder::new("."); - for line in text.lines() { - // An invalid glob drops only its own line; the rest still apply. - let _ = builder.add_line(None, line); + for text in [read_repository_exclude(root), gitignore] + .into_iter() + .flatten() + { + for line in text.lines() { + // An invalid glob drops only its own line; the rest still apply. + let _ = builder.add_line(None, line); + } } let matcher = builder.build().ok().filter(|matcher| !matcher.is_empty()); Self { matcher } @@ -1130,10 +1339,50 @@ fn is_extractable_source_path(relative: &str, overrides: &ExtensionOverrides) -> let language = detect_language_with(relative, overrides); language != Language::Unknown && (crate::lang::spec_for_language(language).is_some() - || crate::embedded::is_embedded_source_path(relative) + || crate::embedded::is_embedded_source_path(relative, overrides.project_root()) || is_file_level_only_language(language)) } +/// Why an indexed file whose content is current has none of the symbols it +/// should have (upstream #2336). A content-hash comparison calls such a file +/// up to date, so only its stored row can tell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MissingSymbols { + /// Stored with no nodes and no recorded reason; every parse stores at least + /// the file node, so the row was wiped and a rebuild restores it. + NeedsReindex, + /// A recorded parse failure left it without symbols; it stays this way + /// until the file or the parser changes. + ParseError, +} + +/// Classify one stored file row; `None` when it has its symbols or is empty on +/// purpose (a file over the size limit, a file-level-only language). +#[must_use] +pub fn missing_symbols(file: &codegraph_core::types::FileRecord) -> Option { + let collapsed = file + .errors + .iter() + .any(|error| error.contains(PARSE_COLLAPSE_WARNING)); + if file.node_count > 0 { + return collapsed.then_some(MissingSymbols::ParseError); + } + if is_file_level_only_language(file.language) { + return None; + } + if file.errors.is_empty() { + return Some(MissingSymbols::NeedsReindex); + } + let skipped_for_size = file + .errors + .iter() + .all(|error| error.starts_with(SIZE_SKIP_PREFIX)); + (!skipped_for_size).then_some(MissingSymbols::ParseError) +} + +/// The start of the error [`size_skip_result`] records. +const SIZE_SKIP_PREFIX: &str = "File exceeds max size"; + fn is_file_level_only_language(language: Language) -> bool { matches!( language, @@ -1156,6 +1405,46 @@ fn normalize_path(path: impl AsRef) -> String { #[cfg(test)] mod tests { + #[test] + fn missing_symbols_classifies_stored_rows_like_upstream() { + use codegraph_core::types::FileRecord; + let row = |language: Language, node_count: i64, errors: &[&str]| FileRecord { + path: "src/a.ts".to_string(), + content_hash: String::new(), + language, + size: 1, + modified_at: 0, + indexed_at: 0, + node_count, + errors: errors.iter().map(|error| (*error).to_string()).collect(), + generated: false, + }; + let collapsed = format!( + "src/a.ts: {PARSE_COLLAPSE_WARNING} - the file is indexed but contributes nothing to the graph" + ); + assert_eq!(missing_symbols(&row(Language::TypeScript, 3, &[])), None); + assert_eq!( + missing_symbols(&row(Language::TypeScript, 1, &[&collapsed])), + Some(MissingSymbols::ParseError) + ); + assert_eq!( + missing_symbols(&row(Language::TypeScript, 0, &[])), + Some(MissingSymbols::NeedsReindex) + ); + assert_eq!(missing_symbols(&row(Language::Yaml, 0, &[])), None); + assert_eq!(missing_symbols(&row(Language::GodotScene, 0, &[])), None); + let oversized = size_skip_result("src/a.ts", 9, 4).errors; + let oversized = oversized.iter().map(String::as_str).collect::>(); + assert_eq!( + missing_symbols(&row(Language::TypeScript, 0, &oversized)), + None + ); + assert_eq!( + missing_symbols(&row(Language::TypeScript, 0, &["could not read the file"])), + Some(MissingSymbols::ParseError) + ); + } + use super::*; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::SystemTime; @@ -1988,6 +2277,121 @@ mod tests { assert!(result.errors.is_empty(), "errors: {:?}", result.errors); } + /// Upstream #2161's Flow-typed component (`__tests__/flow-js.test.ts`). + const FLOW_COMPONENT: &str = "/**\n * @flow strict-local\n * @format\n */\nimport typeof * as React from 'react';\nimport * as React from 'react';\n\ntype Props = $ReadOnly<{|\n values: ?Array,\n onChange?: ?(event: {nativeEvent: {value: string}, ...}) => mixed,\n|}>;\n\nopaque type Token = string;\n\nclass SegmentedControl extends React.Component {\n render(): React.Node {\n return this._renderItems();\n }\n\n _renderItems(): React.Node {\n return null;\n }\n}\n\nmodule.exports = SegmentedControl;\n"; + + #[test] + fn a_flow_pragma_reads_a_javascript_file_as_tsx() { + let none = ExtensionOverrides::default(); + let detect = |path: &str, source: &str| detect_language_for_source(path, source, &none); + assert_eq!( + detect("js/SegmentedControl.js", FLOW_COMPONENT), + Language::Tsx + ); + assert_eq!( + detect("js/plain.js", "function plain() {}"), + Language::JavaScript + ); + assert_eq!( + detect("js/x.js", "// @noflow\nconst a = 1;"), + Language::JavaScript + ); + assert_eq!( + detect("js/x.jsx", "// @flow\nconst a = ;"), + Language::Tsx + ); + assert_eq!( + detect("js/x.mjs", "/* @flow */\nexport const a = 1;"), + Language::Tsx + ); + // Only JavaScript reads the pragma. + assert_eq!( + detect("src/x.ts", "// @flow\nconst a = 1;"), + Language::TypeScript + ); + // A shebang line comes first; a byte-order mark is whitespace. + assert_eq!( + detect("bin/cli.js", "#!/usr/bin/env node\n// @flow\nmain();"), + Language::Tsx + ); + assert_eq!( + detect("js/bom.js", "\u{feff}// @flow\nconst a = 1;"), + Language::Tsx + ); + } + + #[test] + fn the_flow_pragma_is_read_from_the_leading_comments_only() { + for source in [ + "const a = 1; // @flow\n", + "'use strict';\n// @flow\n", + "/* @flowtype */\nconst a = 1;\n", + "// @flow\n// @noflow\nconst a = 1;\n", + "/* @flow\nconst a = 1;\n", + "#!/usr/bin/env node // @flow", + ] { + assert!(!has_flow_pragma(source), "{source:?}"); + } + assert!(has_flow_pragma("// a\n\n/* b */ /** @flow */\n")); + // The leading comments are read in the first 4096 UTF-16 code units + // (`é` is one unit and two bytes). + assert!(has_flow_pragma(&format!( + "// {}\n// @flow\n", + "é".repeat(4000) + ))); + assert!(!has_flow_pragma(&format!( + "// {}\n// @flow\n", + "é".repeat(4096) + ))); + } + + #[test] + fn the_typescript_specs_blank_flow_syntax_keeping_every_offset() { + let flow_only = Regex::new(r"\{\||\|\}|import\s+typeof|opaque\s+type|:\s*\?Array") + .expect("Flow-only syntax"); + for language in [Language::TypeScript, Language::Tsx] { + let spec = crate::lang::spec_for_language(language).expect("TypeScript spec"); + let out = spec.pre_parse(FLOW_COMPONENT, "js/SegmentedControl.js"); + assert_eq!(out.len(), FLOW_COMPONENT.len()); + assert_eq!(out.lines().count(), FLOW_COMPONENT.lines().count()); + assert!(!flow_only.is_match(&out), "{out}"); + // Not a Flow file, or not a JavaScript path: unchanged. + assert_eq!( + spec.pre_parse("const a = {| b |};", "plain.js"), + "const a = {| b |};" + ); + assert_eq!(spec.pre_parse(FLOW_COMPONENT, "src/x.tsx"), FLOW_COMPONENT); + } + } + + #[test] + fn a_flow_typed_javascript_file_keeps_its_class_and_methods() { + let flow = extract_source("js/SegmentedControl.js", FLOW_COMPONENT, None); + let mut methods: Vec<(&str, Language)> = flow + .nodes + .iter() + .filter(|node| node.kind == codegraph_core::types::NodeKind::Method) + .map(|node| (node.name.as_str(), node.language)) + .collect(); + methods.sort_by_key(|(name, _)| *name); + assert_eq!( + methods, + [("_renderItems", Language::Tsx), ("render", Language::Tsx)] + ); + // A caller that names the language keeps it. + let named = extract_source( + "js/SegmentedControl.js", + FLOW_COMPONENT, + Some(Language::JavaScript), + ); + assert!( + named + .nodes + .iter() + .all(|node| node.language == Language::JavaScript) + ); + } + fn cpp_raw_string(delimiter: &str) -> String { format!( "const char* kTemplate = R\"{delimiter}(\nstruct Ignored {{ int v; }};\n){delimiter}\";\n\nint after_the_raw_string(int x) {{\n return x + 1;\n}}\n" diff --git a/crates/codegraph-extract/src/ext_config.rs b/crates/codegraph-extract/src/ext_config.rs index 8a58fb63..433066c9 100644 --- a/crates/codegraph-extract/src/ext_config.rs +++ b/crates/codegraph-extract/src/ext_config.rs @@ -18,7 +18,7 @@ use codegraph_core::IndexPaths; use codegraph_core::types::Language; use serde::Deserialize; use std::collections::BTreeMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Debug, Deserialize)] @@ -35,6 +35,10 @@ struct CodegraphJson { #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ExtensionOverrides { map: BTreeMap, + /// The project root the scope's paths are relative to, which tells a + /// Shopify theme's JSON templates apart from other JSON (W4-LIQ-01, + /// upstream #2393); `None` where there is no project to look in. + project_root: Option, } impl ExtensionOverrides { @@ -59,7 +63,26 @@ impl ExtensionOverrides { /// logged, matching the opt-in contract. #[must_use] pub fn load_for_paths(paths: &IndexPaths) -> Arc { - Self::load_from_file(&paths.extension_config()) + let loaded = Self::load_from_file(&paths.extension_config()); + Arc::new(Self { + map: loaded.map.clone(), + project_root: Some(paths.project().to_path_buf()), + }) + } + + /// These overrides, read for paths relative to `project_root`. + #[must_use] + pub fn with_project_root(&self, project_root: &Path) -> Arc { + Arc::new(Self { + map: self.map.clone(), + project_root: Some(project_root.to_path_buf()), + }) + } + + /// The project root the scope's paths are relative to, if any. + #[must_use] + pub fn project_root(&self) -> Option<&Path> { + self.project_root.as_deref() } /// Load overrides from an explicit `codegraph.json` path. Exposed so a @@ -107,7 +130,10 @@ impl ExtensionOverrides { } } } - Self { map } + Self { + map, + project_root: None, + } } /// `true` when the project declared no usable override. diff --git a/crates/codegraph-extract/src/lang/arkts.rs b/crates/codegraph-extract/src/lang/arkts.rs index ed5f97e5..0c6775fa 100644 --- a/crates/codegraph-extract/src/lang/arkts.rs +++ b/crates/codegraph-extract/src/lang/arkts.rs @@ -171,7 +171,9 @@ impl LanguageSpec for ArkTsSpec { } current = parent.parent(); } - false + // TypeScript's ambient module bodies, as the TypeScript spec reads + // them (upstream #2418: ArkTS shares the TypeScript extractor). + super::typescript::is_ambient_export(node) } fn is_async(&self, node: Node<'_>) -> bool { diff --git a/crates/codegraph-extract/src/lang/c.rs b/crates/codegraph-extract/src/lang/c.rs index 3a50f124..71c7279c 100644 --- a/crates/codegraph-extract/src/lang/c.rs +++ b/crates/codegraph-extract/src/lang/c.rs @@ -59,6 +59,10 @@ impl LanguageSpec for CSpec { } fn resolve_name(&self, node: Node<'_>, source: &str) -> Option { crate::lang::cpp::recover_single_arg_macro_defined_name(node, source) + .or_else(|| unnamed_type_variable_name(node, source)) + } + fn declaration_wrapper<'tree>(&self, node: Node<'tree>) -> Option> { + defining_declaration(node) } fn name_field(&self) -> &'static str { "declarator" @@ -94,12 +98,19 @@ impl LanguageSpec for CSpec { } fn pre_parse(&self, source: &str, _file_path: &str) -> String { let blanked = blank_c_designated_macro_args(source); + // Upstream's deferral round 2 (#1353), in its order. + let blanked = crate::lang::c_preparse::blank_c_round_two(&blanked); let blanked = blank_c_leading_attr_macros(&blanked); - if crate::lang::cpp::looks_like_cuda_source(&blanked) { + let blanked = if crate::lang::cpp::looks_like_cuda_source(&blanked) { crate::lang::cpp::blank_cuda_constructs_str(&blanked) } else { blanked - } + }; + // Every directive line as written (upstream #1346), then the one pass + // that edits a `#define` on purpose. + crate::lang::c_preparse::blank_c_named_variadic_define_dots( + &crate::lang::cpp_preparse::restore_directive_lines(source, &blanked), + ) } } @@ -225,7 +236,7 @@ fn blank_c_designated_macro_args(source: &str) -> String { /// Blanking replaces the macro with equal-length ASCII spaces, so every byte /// offset — and therefore every line/column and every node ID — is preserved, /// exactly like the C++ blanks in `lang/cpp.rs`. -fn blank_c_leading_attr_macros(source: &str) -> String { +pub(crate) fn blank_c_leading_attr_macros(source: &str) -> String { let attr_macros = attribute_like_defines(source); if attr_macros.is_empty() { return source.to_string(); @@ -410,6 +421,67 @@ fn valid_ident(text: &str) -> bool { && chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) } +/// Whether a C/C++ type specifier defines a class, struct, union or enum, +/// as opposed to naming one (`struct Foo *p`, the forward declaration +/// `class Foo;`): upstream `isClassLikeDefinition`. +pub(crate) fn is_class_like_definition(node: Node<'_>) -> bool { + matches!( + node.kind(), + "class_specifier" | "struct_specifier" | "union_specifier" | "enum_specifier" + ) && child_by_field(node, "body").is_some() +} + +/// The `declaration` a class, struct, union or enum is defined in the type of +/// (`struct Foo { … } foo;`), or `None` for one written on its own, in a +/// typedef or in a member's type (upstream #2446 `definingDeclaration`). +pub(crate) fn defining_declaration(node: Node<'_>) -> Option> { + if !is_class_like_definition(node) { + return None; + } + let parent = node.parent()?; + (parent.kind() == "declaration" + && child_by_field(parent, "type").map(|t| t.id()) == Some(node.id())) + .then_some(parent) +} + +/// The name of an unnamed class, struct, union or enum a declaration +/// defines: the first variable the declaration declares (upstream #2446 +/// `unnamedTypeVariableName`). No code can name the type of `static struct { +/// … } SPT;`, only `SPT`, so the struct is `SPT`, as `typedef struct { … } +/// Name;` is `Name`. `None` for a named type, and when the declaration +/// declares no variable (a function's return type). +pub(crate) fn unnamed_type_variable_name(node: Node<'_>, source: &str) -> Option { + if child_by_field(node, "name").is_some() { + return None; + } + let declaration = defining_declaration(node)?; + let mut cursor = declaration.walk(); + declaration + .children_by_field_name("declarator", &mut cursor) + .find_map(|declarator| c_declarator_identifier(Some(declarator))) + .map(|identifier| node_text(identifier, source)) +} + +/// The identifier a C declarator declares (upstream `cDeclaratorIdentifier`): +/// through `init_declarator`, `pointer_declarator`, `array_declarator` and +/// `parenthesized_declarator`, each by its `declarator` field. A +/// `function_declarator` is a prototype or a function pointer, never a value. +pub(crate) fn c_declarator_identifier(node: Option>) -> Option> { + let mut current = node; + for _ in 0..12 { + let node = current?; + match node.kind() { + "identifier" => return Some(node), + "init_declarator" + | "pointer_declarator" + | "array_declarator" + | "parenthesized_declarator" => current = child_by_field(node, "declarator"), + _ => return None, + } + } + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/codegraph-extract/src/lang/c_preparse.rs b/crates/codegraph-extract/src/lang/c_preparse.rs new file mode 100644 index 00000000..cb0bf511 --- /dev/null +++ b/crates/codegraph-extract/src/lang/c_preparse.rs @@ -0,0 +1,667 @@ +//! C pre-parse passes of upstream's deferral round 2 (#1353, `b9d0f57`): +//! kernel-style annotations, macro arguments and declaration macros that +//! tree-sitter-c reads as errors, rewritten so the declaration around them +//! parses. Every pass keeps every byte offset: it replaces bytes with ASCII +//! spaces (newlines and carriage returns kept), except +//! [`rewrite_c_prefixed_decl_macro_initializers`], which moves a type token +//! left of a name whose column it keeps. C only. + +use std::sync::OnceLock; + +use regex::Regex; + +fn regex(pattern: &'static str, cell: &'static OnceLock) -> &'static Regex { + cell.get_or_init(|| Regex::new(pattern).expect("C pre-parse regex")) +} + +/// Blank `bytes[range]`, newlines and carriage returns kept. +fn blank(bytes: &mut [u8], range: std::ops::Range) { + for byte in &mut bytes[range] { + if *byte != b'\n' && *byte != b'\r' { + *byte = b' '; + } + } +} + +fn into_text(bytes: Vec, source: &str) -> String { + String::from_utf8(bytes).unwrap_or_else(|_| source.to_string()) +} + +fn is_word_byte(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() +} + +/// Lowercase compiler annotations a storage class can carry before the rest +/// of a declaration (the kernel's `compiler.h` markers). +const C_SANDWICHED_ANNOTATIONS: &str = + "noinline_for_stack|nokprobe_inline|noinline|notrace|noinstr"; + +/// Blank a lowercase compiler-annotation word sandwiched between a storage +/// class and the rest of a declaration (upstream `blankCSandwichedAnnotations`): +/// `static notrace void tick(…)`, `static nokprobe_inline void arm(…)`. The +/// word counts only right after `static` / `extern` / `inline` and before +/// another word, where it cannot be a variable (`int notrace = 1;` stays). +pub(crate) fn blank_c_sandwiched_annotations(source: &str) -> String { + static SANDWICH: OnceLock = OnceLock::new(); + static NEXT_WORD: OnceLock = OnceLock::new(); + let sandwich = SANDWICH.get_or_init(|| { + Regex::new(&format!( + r"\b(?:static|extern|inline)[ \t]+({C_SANDWICHED_ANNOTATIONS})\b" + )) + .expect("sandwiched annotation regex") + }); + let next_word = regex(r"^[ \t]+[A-Za-z_]", &NEXT_WORD); + let mut bytes: Option> = None; + for found in sandwich.captures_iter(source) { + let whole = found.get(0).expect("match"); + if !next_word.is_match(&source[whole.end()..]) { + continue; + } + let annotation = found.get(1).expect("annotation"); + blank( + bytes.get_or_insert_with(|| source.as_bytes().to_vec()), + annotation.range(), + ); + } + bytes.map_or_else(|| source.to_string(), |bytes| into_text(bytes, source)) +} + +/// Blank a C23 `auto` type-inference keyword (upstream `blankCAutoInference`): +/// `auto hb = hbr.hb;` leaves a plain assignment. The storage-class reading +/// has a type between `auto` and the `=` (`auto int x = 1;`) and stays. +pub(crate) fn blank_c_auto_inference(source: &str) -> String { + static AUTO: OnceLock = OnceLock::new(); + let auto = regex(r"\bauto[ \t]+[A-Za-z_][0-9A-Za-z_]*[ \t]*=", &AUTO); + if !source.contains("auto") || !auto.is_match(source) { + return source.to_string(); + } + let mut bytes = source.as_bytes().to_vec(); + for found in auto.find_iter(source) { + blank(&mut bytes, found.start()..found.start() + "auto".len()); + } + into_text(bytes, source) +} + +/// Annotations whose argument list goes with them, longest first. +const C_PARAMETERIZED_ANNOTATIONS: &str = "__assume_aligned|__pt_guarded_by|__counted_by_le|__counted_by_be|__bpf_md_ptr|__counted_by|__guarded_by|__must_hold|__acquires|__releases|__section|__aligned|__cleanup|__printf|__scanf|__free"; + +/// Blank a parameterized sparse or compiler annotation whole, name and +/// argument list (upstream `blankCParameterizedAnnotationMacros`): `struct +/// file *f __free(fput) = NULL;`, `static void __printf(4, 0) log_it(…)`, +/// `} owners[] __counted_by(count);`. Blanking only the name would strand the +/// parenthesis. The list is curated: `__hash(key)` is a real call of the same +/// shape. One nesting level of parentheses is allowed. A match that is the +/// whole field of its line (`__bpf_md_ptr(struct bpf_iter_meta *, meta);`) +/// takes its `;` too: a lone `;` field is itself a parse error. +pub(crate) fn blank_c_parameterized_annotation_macros(source: &str) -> String { + static ANNOTATION: OnceLock = OnceLock::new(); + static FIELD_SEMICOLON: OnceLock = OnceLock::new(); + if !source.contains("__") { + return source.to_string(); + } + let annotation = ANNOTATION.get_or_init(|| { + Regex::new(&format!( + r"\b(?:{C_PARAMETERIZED_ANNOTATIONS})[ \t]*\((?:[^()]|\([^()]*\))*\)" + )) + .expect("parameterized annotation regex") + }); + let field_semicolon = regex(r"^[ \t]*;", &FIELD_SEMICOLON); + if !annotation.is_match(source) { + return source.to_string(); + } + let mut bytes = source.as_bytes().to_vec(); + for found in annotation.find_iter(source) { + let start = found.start(); + let mut end = found.end(); + let line_start = source[..start].rfind('\n').map_or(0, |at| at + 1); + if source[line_start..start] + .bytes() + .all(|byte| byte == b' ' || byte == b'\t') + && let Some(semicolon) = field_semicolon.find(&source[end..]) + { + end += semicolon.end(); + } + blank(&mut bytes, start..end); + } + into_text(bytes, source) +} + +/// Heads whose `struct T` argument is valid C (or has its own pass). +const C_TYPE_ARG_HEAD_EXCLUSIONS: [&str; 14] = [ + "sizeof", + "alignof", + "_Alignof", + "typeof", + "__typeof__", + "__typeof", + "offsetof", + "_Generic", + "va_arg", + "if", + "while", + "for", + "switch", + "case", +]; + +/// How far past a call's `(` the argument scan reads. +const C_TYPE_ARG_SCAN_CAP: usize = 600; + +/// Blank a bare `struct` / `union` / `enum` type-keyword argument of a macro +/// call (upstream `blankCTypeKeywordArgs`): `kzalloc_obj(struct +/// bpf_mount_opts)`, `list_first_entry(&pending,\n struct async_entry, +/// domain_list)`, and the stars of `DEFINE_PER_CPU(struct task_struct *, +/// ksoftirqd)`. A bare type is no C expression, so tree-sitter recovers at +/// every such call; without the keyword the argument is an identifier. +/// Valid look-alikes keep theirs: `sizeof` / `offsetof` / `_Generic` heads; a +/// head preceded by a type (`int wf(struct a, struct b);`, a wrapped +/// prototype), unless the word before it is `return`; a cast (`f((struct T +/// *)p)`) and a declaration argument (`TP_PROTO(struct foo *bar)`). A bounded +/// hand scanner, not a nested regex, reads the arguments. +pub(crate) fn blank_c_type_keyword_args(source: &str) -> String { + static ANY_KEYWORD: OnceLock = OnceLock::new(); + static HEAD: OnceLock = OnceLock::new(); + static OPENER: OnceLock = OnceLock::new(); + if !regex(r"\b(?:struct|union|enum)[ \t\r\n]", &ANY_KEYWORD).is_match(source) { + return source.to_string(); + } + let head = regex(r"\b([A-Za-z_][0-9A-Za-z_]*)[ \t]*\(", &HEAD); + let opener = regex( + r"^(struct|union|enum)([ \t\r\n]+)([A-Za-z_][0-9A-Za-z_]*)([ \t\r\n]*\*+)?", + &OPENER, + ); + let src = source.as_bytes(); + let mut bytes: Option> = None; + // `return` precedes real calls; any other word or `*` before the head + // makes it a declaration. + let returns_before = |at: usize| -> bool { + let mut j = at; + while j > 0 && matches!(src[j - 1], b' ' | b'\t') { + j -= 1; + } + if j == 0 { + return true; + } + let previous = src[j - 1]; + if !(is_word_byte(previous) || previous == b'*') { + return true; + } + let mut w = j; + while w > 0 && is_word_byte(src[w - 1]) { + w -= 1; + } + &source[w..j] == "return" + }; + // After an opener, the argument must end, within the opener's window: + // blanks, then `,` or `)`. + let ends_argument = |at: usize, window_end: usize| -> bool { + src[at..window_end] + .iter() + .find(|byte| !matches!(byte, b' ' | b'\t' | b'\r' | b'\n')) + .is_some_and(|byte| matches!(byte, b',' | b')')) + }; + for found in head.captures_iter(source) { + let whole = found.get(0).expect("match"); + let name = found.get(1).expect("head").as_str(); + if C_TYPE_ARG_HEAD_EXCLUSIONS.contains(&name) || !returns_before(whole.start()) { + continue; + } + let open = whole.end() - 1; + let mut depth = 1usize; + let mut at_argument_start = true; + let mut k = open + 1; + while k < src.len() && k - open <= C_TYPE_ARG_SCAN_CAP { + let c = src[k]; + if c == b'"' || c == b'\'' { + k += 1; + while k < src.len() && src[k] != c { + if src[k] == b'\\' { + k += 1; + } + k += 1; + } + at_argument_start = false; + k += 1; + continue; + } + match c { + b' ' | b'\t' | b'\n' | b'\r' => { + k += 1; + continue; + } + b'(' => { + depth += 1; + at_argument_start = false; + k += 1; + continue; + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + at_argument_start = false; + k += 1; + continue; + } + b';' | b'{' | b'}' => break, + b',' => { + if depth == 1 { + at_argument_start = true; + } + k += 1; + continue; + } + _ => {} + } + if depth == 1 && at_argument_start { + let window_end = (k + 160).min(src.len()); + let window = source.get(k..window_end).or_else(|| source.get(k..)); + if let Some(window) = window + && let Some(open_type) = opener.captures(window) + { + let keyword = open_type.get(1).expect("keyword"); + let ident = open_type.get(3).expect("type name"); + // With its stars when they end the argument, else without. + let window_end = k + window.len(); + let stars = open_type + .get(4) + .filter(|stars| ends_argument(k + stars.end(), window_end)); + let matched_end = match stars { + Some(stars) => Some(stars.end()), + None if ends_argument(k + ident.end(), window_end) => Some(ident.end()), + None => None, + }; + if let Some(matched_end) = matched_end { + let out = bytes.get_or_insert_with(|| src.to_vec()); + blank(out, k + keyword.start()..k + keyword.end()); + if let Some(stars) = stars { + let star_count = stars + .as_str() + .bytes() + .rev() + .take_while(|b| *b == b'*') + .count(); + blank(out, k + stars.end() - star_count..k + stars.end()); + } + k += matched_end; + at_argument_start = false; + continue; + } + } + } + at_argument_start = false; + k += 1; + } + } + bytes.map_or_else(|| source.to_string(), |bytes| into_text(bytes, source)) +} + +/// The end of the `( … )` that opens at byte `open` of `line`, string and +/// character literals skipped; `None` when it does not close on the line. +fn paren_close_on_line(line: &[u8], open: usize) -> Option { + let mut depth = 0usize; + let mut k = open; + while k < line.len() { + let c = line[k]; + if c == b'"' || c == b'\'' { + k += 1; + while k < line.len() && line[k] != c { + if line[k] == b'\\' { + k += 1; + } + k += 1; + } + k += 1; + continue; + } + if c == b'(' { + depth += 1; + } else if c == b')' { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(k); + } + } + k += 1; + } + None +} + +/// Blank a `static` / `extern`-prefixed all-caps declaration macro line at +/// any scope (upstream `blankCFileScopePrefixedDeclMacros`): `static +/// DEFINE_PER_CPU(struct llist_head, rstat_backlog_list);`, `static +/// DECLARE_WORK(init_free_wq, do_free_init);`. A storage class before a call +/// is never valid C. The parentheses must close on the line and `;` end it; +/// an initializer form belongs to +/// [`rewrite_c_prefixed_decl_macro_initializers`], and an unprefixed +/// `EXPORT_SYMBOL(x);` parses as a K&R declaration and stays. +pub(crate) fn blank_c_file_scope_prefixed_decl_macros(source: &str) -> String { + static ANY: OnceLock = OnceLock::new(); + static LINE: OnceLock = OnceLock::new(); + if !regex(r"(?m)^[ \t]*(?:static|extern)[ \t]+[A-Z]", &ANY).is_match(source) { + return source.to_string(); + } + let line_head = regex( + r"^[ \t]*(?:static|extern)[ \t]+[A-Z][A-Z0-9_]{2,}[ \t]*\(", + &LINE, + ); + let mut bytes: Option> = None; + let mut offset = 0usize; + for line in source.split('\n') { + if let Some(found) = line_head.find(line) { + let open = found.end() - 1; + if let Some(close) = paren_close_on_line(line.as_bytes(), open) + && line[close + 1..].trim_end_matches('\r').trim() == ";" + { + blank( + bytes.get_or_insert_with(|| source.as_bytes().to_vec()), + offset..offset + line.len(), + ); + } + } + offset += line.len() + 1; + } + bytes.map_or_else(|| source.to_string(), |bytes| into_text(bytes, source)) +} + +/// Rewrite a two-argument `static CAPS_MACRO(type, name) = {` declaration +/// macro into the declaration it expands to (upstream +/// `rewriteCPrefixedDeclMacroInitializers`): `static +/// DEFINE_PER_CPU(struct cpuhp_cpu_state, cpuhp_state) = {` becomes `static +/// struct cpuhp_cpu_state` padded up to `cpuhp_state`, which keeps its exact +/// column, and the `= {` tail its exact offsets; the initializer's function +/// references (`.startup.single = bringup_cpu`) survive. Three-argument +/// macros and `;`-terminated forms never match. +pub(crate) fn rewrite_c_prefixed_decl_macro_initializers(source: &str) -> String { + static ANY: OnceLock = OnceLock::new(); + static LINE: OnceLock = OnceLock::new(); + static TYPE_RUN: OnceLock = OnceLock::new(); + static SPACES: OnceLock = OnceLock::new(); + if !regex(r"(?m)^[ \t]*static[ \t]+[A-Z]", &ANY).is_match(source) { + return source.to_string(); + } + let line_re = regex( + r#"^([ \t]*)static[ \t]+[A-Z][A-Z0-9_]{2,}[ \t]*\(([^();'"]*?),[ \t]*([A-Za-z_][0-9A-Za-z_]*)[ \t]*\)([ \t]*=[ \t]*\{?[ \t]*\r?)$"#, + &LINE, + ); + let type_run = regex(r"^[A-Za-z_][0-9A-Za-z_ \t*]*$", &TYPE_RUN); + let spaces = regex(r"[ \t]+", &SPACES); + let mut changed = false; + let mut lines: Vec = Vec::new(); + for line in source.split('\n') { + let Some(found) = line_re.captures(line) else { + lines.push(line.to_string()); + continue; + }; + let indent = found.get(1).expect("indent").as_str(); + let arg1 = spaces + .replace_all(found.get(2).expect("type").as_str().trim(), " ") + .into_owned(); + let name = found.get(3).expect("name"); + let tail = found.get(4).expect("tail"); + let prefix = format!("{indent}static {arg1}"); + if !type_run.is_match(&arg1) || prefix.len() + 1 > name.start() { + lines.push(line.to_string()); + continue; + } + lines.push(format!( + "{prefix}{}{}{}{}", + " ".repeat(name.start() - prefix.len()), + name.as_str(), + " ".repeat(tail.start() - name.end()), + &line[tail.start()..] + )); + changed = true; + } + if changed { + lines.join("\n") + } else { + source.to_string() + } +} + +/// Blank a qualified or pointer type argument of `va_arg` (upstream +/// `blankCVaArgQualifiedTypeArgs`): `va_arg(ap, const char *)` becomes a +/// one-argument call; `va_arg(ap, int)` parses as it is and stays. A +/// function-pointer type contains parentheses and is left alone. (Upstream +/// rewrites the head as `va_arg(`, which drops blanks before the `(`; the +/// head is kept as written here, so no offset moves.) +pub(crate) fn blank_c_va_arg_qualified_type_args(source: &str) -> String { + static VA_ARG: OnceLock = OnceLock::new(); + static SINGLE: OnceLock = OnceLock::new(); + if !source.contains("va_arg") { + return source.to_string(); + } + let va_arg = regex(r"\bva_arg[ \t]*\(([^(),]+)(,[^()]*)\)", &VA_ARG); + let single = regex(r"^,[ \t]*[A-Za-z_][0-9A-Za-z_]*[ \t]*$", &SINGLE); + let mut bytes: Option> = None; + for found in va_arg.captures_iter(source) { + let rest = found.get(2).expect("type argument"); + if single.is_match(rest.as_str()) { + continue; + } + blank( + bytes.get_or_insert_with(|| source.as_bytes().to_vec()), + rest.range(), + ); + } + bytes.map_or_else(|| source.to_string(), |bytes| into_text(bytes, source)) +} + +/// Blank the dots of a GNU named-variadic `#define` parameter (upstream +/// `blankCNamedVariadicDefineDots`): `#define verbose(env, fmt, args...)` +/// becomes `args `, which the grammar reads as an ordinary parameter; the +/// body (`##args` included) is untouched. Runs after the directive lines are +/// restored, on purpose. +pub(crate) fn blank_c_named_variadic_define_dots(source: &str) -> String { + static DEFINE: OnceLock = OnceLock::new(); + if !source.contains("...") { + return source.to_string(); + } + let define = regex( + r"^[ \t]*#[ \t]*define[ \t]+[A-Za-z_][0-9A-Za-z_]*\([^()\n]*?\b[0-9A-Za-z_]+\.\.\.", + &DEFINE, + ); + let mut bytes: Option> = None; + let mut offset = 0usize; + for line in source.split('\n') { + if let Some(found) = define.find(line) { + blank( + bytes.get_or_insert_with(|| source.as_bytes().to_vec()), + offset + found.end() - 3..offset + found.end(), + ); + } + offset += line.len() + 1; + } + bytes.map_or_else(|| source.to_string(), |bytes| into_text(bytes, source)) +} + +/// Round 2's passes before the leading attribute macro, in upstream's order. +pub(crate) fn blank_c_round_two(source: &str) -> String { + rewrite_c_prefixed_decl_macro_initializers(&blank_c_file_scope_prefixed_decl_macros( + &blank_c_va_arg_qualified_type_args(&blank_c_type_keyword_args( + &blank_c_parameterized_annotation_macros(&blank_c_auto_inference( + &blank_c_sandwiched_annotations(source), + )), + )), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(text: &[&str]) -> String { + text.join("\n") + } + + /// Whether tree-sitter-c reads `source` with an error anywhere. + fn parses_with_errors(source: &str) -> bool { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_c::LANGUAGE.into()) + .expect("C grammar"); + parser + .parse(source, None) + .expect("a tree") + .root_node() + .has_error() + } + + /// Each pass's target shape errors as written and parses once the C + /// pre-parse has run (upstream's aim: the file stops deferring). + #[test] + fn each_shape_parses_without_errors_after_the_pre_parse() { + use crate::spec::LanguageSpec as _; + let spec = crate::lang::c::CSpec; + for source in [ + "static notrace void tick_do(void) { do_tick(); }\n", + "void q(void) { auto hb = get_hb(); use(hb); }\n", + "struct file *f __free(fput) = NULL;\nstatic void __printf(4, 0) log_it(int a, const char *fmt, ...) { }\n", + "void j(void *head) {\n\tvoid *p = kzalloc_obj(struct bpf_mount_opts);\n\tuse(p);\n}\n", + "static DEFINE_PER_CPU(struct llist_head, rstat_backlog_list);\nint after(void) { return 4; }\n", + "static DEFINE_PER_CPU(struct cpuhp_cpu_state, cpuhp_state) = {\n\t.startup = bringup_cpu,\n};\n", + "void f(va_list ap) {\n\tconst char *s = va_arg(ap, const char *);\n\tuse(s);\n}\n", + "#define verbose(env, fmt, args...) log_write(env, fmt, ##args)\nint after(void) { return 7; }\n", + ] { + assert!(parses_with_errors(source), "already parses: {source:?}"); + let pre_parsed = spec.pre_parse(source, "src/kernel.c"); + assert_eq!(pre_parsed.len(), source.len(), "{source:?}"); + assert!(!parses_with_errors(&pre_parsed), "{pre_parsed:?}"); + } + } + + #[test] + fn a_parameterized_annotation_blanks_whole_and_a_field_eats_its_semicolon() { + let src = lines(&[ + "struct file *f __free(fput) = NULL;", + "static void __printf(4, 0) log_it(int a, const char *fmt, ...);", + "struct ctx {", + "\t__bpf_md_ptr(struct bpf_iter_meta *, meta);", + "};", + "int keep = __hash(key);", + "", + ]); + let out = blank_c_parameterized_annotation_macros(&src); + assert_eq!(out.len(), src.len()); + assert!(!out.contains("__free") && !out.contains("__printf")); + assert!(out.contains("= NULL;")); + assert!(!out.contains("__bpf_md_ptr")); + assert_eq!(out.split('\n').nth(3).map(str::trim), Some("")); + assert!(out.contains("__hash(key)")); + } + + #[test] + fn a_type_keyword_argument_blanks_and_valid_look_alikes_stay() { + let src = lines(&[ + "void j(void *head, void *map) {", + "\tvoid *p = kzalloc_obj(struct bpf_mount_opts);", + "\tvoid *e = list_first_entry(head,", + "\t\t\tstruct async_entry, domain_list);", + "\tvoid *n = hlist_entry_safe(rcu_dereference_raw(hlist_next_rcu(head)),", + "\t\t\tstruct bpf_dtab_netdev, index_hlist);", + "\treturn container_of(map, struct bpf_map, inner);", + "}", + "DEFINE_PER_CPU(struct task_struct *, ksoftirqd);", + "", + ]); + let out = blank_c_type_keyword_args(&src); + assert_eq!(out.len(), src.len()); + assert!(!out.contains("struct bpf_mount_opts")); + assert!(out.contains(" bpf_mount_opts")); + assert!(out.contains(" async_entry, domain_list")); + assert!(out.contains(" bpf_dtab_netdev")); + assert!(out.contains(" bpf_map, inner")); + assert!(out.contains(" task_struct , ksoftirqd")); + for valid in [ + "int a = sizeof(struct point);", + "int b = offsetof(struct point, y);", + "int c = _Generic(x, struct foo *: 1, default: 0);", + "int wf(struct a,\n\tstruct b);", + "void cast(void *p) { use((struct foo *)p); }", + "struct ops { int (*probe)(struct device *dev); };", + ] { + assert_eq!(blank_c_type_keyword_args(valid), valid, "{valid:?}"); + } + } + + #[test] + fn a_prefixed_declaration_macro_line_blanks_at_any_scope() { + let src = lines(&[ + "static DEFINE_PER_CPU(struct llist_head, rstat_backlog_list);", + "void f(void) {", + "\tstatic DEFINE_RATELIMIT_STATE(ratelimit, 5 * HZ, 5);", + "}", + "EXPORT_SYMBOL(vmalloc);", + "static DEFINE_PER_CPU(struct cpuhp_cpu_state, cpuhp_state) = {", + "", + ]); + let out = blank_c_file_scope_prefixed_decl_macros(&src); + assert_eq!(out.len(), src.len()); + assert!(!out.contains("DEFINE_PER_CPU(struct llist_head")); + assert!(!out.contains("DEFINE_RATELIMIT_STATE")); + assert!(out.contains("EXPORT_SYMBOL(vmalloc);")); + assert!(out.contains("cpuhp_state) = {")); + } + + #[test] + fn an_initialized_declaration_macro_rewrites_into_its_declaration() { + let line = "static DEFINE_PER_CPU(struct cpuhp_cpu_state, cpuhp_state) = {"; + let src = lines(&[line, "\t.fail = CPUHP_INVALID,", "};", ""]); + let out = rewrite_c_prefixed_decl_macro_initializers(&src); + assert_eq!(out.len(), src.len()); + let rewritten = out.split('\n').next().expect("first line"); + assert!(rewritten.contains("static struct cpuhp_cpu_state")); + assert!(!rewritten.contains("DEFINE_PER_CPU")); + assert_eq!( + rewritten[30..].find("cpuhp_state").map(|at| at + 30), + line[30..].find("cpuhp_state").map(|at| at + 30) + ); + assert_eq!(rewritten.find("= {"), line.find("= {")); + let three = "static DEFINE_TIMER(t, fn, 0) = {\n};\n"; + assert_eq!(rewrite_c_prefixed_decl_macro_initializers(three), three); + } + + #[test] + fn a_multi_token_va_arg_type_blanks_and_a_single_token_stays() { + let src = "void f(va_list ap) {\n\tconst char *s = va_arg(ap, const char *);\n\tint n = va_arg(ap, int);\n}\n"; + let out = blank_c_va_arg_qualified_type_args(src); + assert_eq!(out.len(), src.len()); + assert!(out.contains("va_arg(ap );")); + assert!(out.contains("va_arg(ap, int);")); + // The head keeps its blanks (upstream drops them, moving offsets). + let spaced = "x = va_arg (ap, const char *);\n"; + let out = blank_c_va_arg_qualified_type_args(spaced); + assert_eq!(out.len(), spaced.len()); + assert!(out.starts_with("x = va_arg (ap")); + } + + #[test] + fn only_the_dots_of_a_named_variadic_parameter_blank() { + let named = "#define verbose(env, fmt, args...) log_write(env, fmt, ##args)\nint x;\n"; + let out = blank_c_named_variadic_define_dots(named); + assert_eq!(out.len(), named.len()); + assert!(out.contains("args )")); + assert!(out.contains("##args")); + let standard = "#define pr(fmt, ...) printk(fmt, __VA_ARGS__)\nint y;\n"; + assert_eq!(blank_c_named_variadic_define_dots(standard), standard); + } + + #[test] + fn a_sandwiched_annotation_and_c23_auto_blank_and_look_alikes_stay() { + let src = + "static notrace void tick_do(void) { }\nstatic nokprobe_inline void arm(void) { }\n"; + let out = blank_c_sandwiched_annotations(src); + assert_eq!(out.len(), src.len()); + assert!(!out.contains("notrace") && !out.contains("nokprobe_inline")); + let variable = "void f(void) { int notrace = 1; use(notrace); }\n"; + assert_eq!(blank_c_sandwiched_annotations(variable), variable); + let c23 = "void q(void) { auto hb = get_hb(); }\n"; + let out = blank_c_auto_inference(c23); + assert_eq!(out.len(), c23.len()); + assert!(out.contains(" hb = get_hb();")); + let storage = "void s(void) { auto int x = 1; }\n"; + assert_eq!(blank_c_auto_inference(storage), storage); + } +} diff --git a/crates/codegraph-extract/src/lang/cfml.rs b/crates/codegraph-extract/src/lang/cfml.rs index 4a8250c7..bdb2f302 100644 --- a/crates/codegraph-extract/src/lang/cfml.rs +++ b/crates/codegraph-extract/src/lang/cfml.rs @@ -18,9 +18,13 @@ //! script `component` (file-name rename + script-style `extends`) and the tag //! grammar's `cf_component_open_tag`/`cf_function_tag`. //! -//! DEFERRED (recorded follow-ups, consistent with H1–H5): the -//! ``-in-tag-body re-parse delegation, the `cfquery` SQL-body -//! extraction (`LANGUAGE_CFQUERY`), and the CFML framework RESOLVER bridges. +//! A tag file's `` bodies and `` SQL bodies are parsed a +//! second time in place, with the crate's cfscript and cfquery grammars over +//! the file's own bytes (upstream #2296, W4-CF-02; the walker's +//! `delegate_cfml_script_tag` / `delegate_cfml_query_tag`). +//! +//! DEFERRED (recorded follow-up, consistent with H1–H5): the CFML framework +//! RESOLVER bridges. use codegraph_core::types::Language; use tree_sitter::{Language as TsLanguage, Node}; @@ -279,6 +283,67 @@ pub(crate) fn cfml_tag_attr(tag: Node<'_>, name: &str, source: &str) -> Option) -> Option> { + tag.named_children(&mut tag.walk()) + .find(|c| matches!(c.kind(), "cf_start_tag" | "cf_start_tag_with_selfclose")) +} + +/// A generic `cf_tag`'s name, lowercased, without the `cf` prefix +/// (`` -> `loop`; upstream `cfTagName`). +pub(crate) fn cfml_tag_name(tag: Node<'_>, source: &str) -> Option { + let start = cfml_start_tag(tag)?; + let name = start + .named_children(&mut start.walk()) + .find(|c| c.kind() == "cf_tag_name")?; + Some(node_text(name, source).to_ascii_lowercase()) +} + +/// The `attribute_value` node of a tag attribute (upstream +/// `tagAttrValueNode`): `None` when the tag has no such attribute, +/// `Some(None)` when its value is not plain text (empty, or a `#hash#` +/// expression). +pub(crate) fn cfml_tag_attr_value_node<'t>( + tag: Node<'t>, + name: &str, + source: &str, +) -> Option>> { + let mut attrs = Vec::new(); + for child in tag.named_children(&mut tag.walk()) { + match child.kind() { + "cf_attribute" => attrs.push(child), + "cf_tag_attributes" => attrs.extend( + child + .named_children(&mut child.walk()) + .filter(|inner| inner.kind() == "cf_attribute"), + ), + _ => {} + } + } + for attr in attrs { + let Some(attr_name) = attr + .named_children(&mut attr.walk()) + .find(|c| c.kind() == "cf_attribute_name") + else { + continue; + }; + if !node_text(attr_name, source).eq_ignore_ascii_case(name) { + continue; + } + let value = attr + .named_children(&mut attr.walk()) + .find(|c| matches!(c.kind(), "quoted_cf_attribute_value" | "cf_attribute_value")) + .and_then(|wrapper| { + wrapper + .named_children(&mut wrapper.walk()) + .find(|c| c.kind() == "attribute_value") + }); + return Some(value); + } + None +} + /// A script-style `component_attribute(identifier, string)` value by name /// (case-insensitive), for the cfscript grammar — used for script-style /// `extends="Base"`. Matches the `identifier` child and returns the `string` @@ -308,6 +373,38 @@ pub(crate) fn cfml_string_attr_value(node: Node<'_>, name: &str, source: &str) - None } +/// A script-style `component_attribute` by label (case-insensitive): the +/// value's text with its quotes stripped, and the value node (upstream reads +/// the first child that is not the `identifier` label). +pub(crate) fn cfml_component_attr<'t>( + node: Node<'t>, + name: &str, + source: &str, +) -> Option<(String, Node<'t>)> { + for attr in node.named_children(&mut node.walk()) { + if attr.kind() != "component_attribute" { + continue; + } + let children: Vec> = attr.named_children(&mut attr.walk()).collect(); + let label = children.iter().copied().find(|c| c.kind() == "identifier"); + let value = children.iter().copied().find(|c| c.kind() != "identifier"); + let (Some(label), Some(value)) = (label, value) else { + continue; + }; + if !node_text(label, source).eq_ignore_ascii_case(name) { + continue; + } + let text = node_text(value, source); + let text = text.strip_prefix(['"', '\'']).unwrap_or(&text).to_string(); + let text = text + .strip_suffix(['"', '\'']) + .map(str::to_string) + .unwrap_or(text); + return Some((text, value)); + } + None +} + /// The name of a `property_declaration`. Tries the optional `name` field (the /// typed form `property String x;`) then the attribute form `property name="x";` /// whose name lives in a `component_attribute(identifier="name", string)` pair. diff --git a/crates/codegraph-extract/src/lang/cpp.rs b/crates/codegraph-extract/src/lang/cpp.rs index 3cbacfc1..ba6bb0da 100644 --- a/crates/codegraph-extract/src/lang/cpp.rs +++ b/crates/codegraph-extract/src/lang/cpp.rs @@ -75,12 +75,18 @@ impl LanguageSpec for CppSpec { { return Some(name); } - let qid = declarator_qualified_id(child_by_field(node, "declarator")?)?; - node_text(qid, source) - .rsplit("::") - .filter(|part| !part.is_empty()) - .next() - .map(str::to_string) + child_by_field(node, "declarator") + .and_then(declarator_qualified_id) + .and_then(|qid| { + node_text(qid, source) + .rsplit("::") + .find(|part| !part.is_empty()) + .map(str::to_string) + }) + .or_else(|| crate::lang::c::unnamed_type_variable_name(node, source)) + } + fn declaration_wrapper<'tree>(&self, node: Node<'tree>) -> Option> { + crate::lang::c::defining_declaration(node) } fn get_receiver_type(&self, node: Node<'_>, source: &str) -> Option { let qid = declarator_qualified_id(child_by_field(node, "declarator")?)?; @@ -134,12 +140,17 @@ impl LanguageSpec for CppSpec { } None } - fn is_misparsed_function(&self, name: &str, _node: Node<'_>, _source: &str) -> bool { + fn is_misparsed_function(&self, name: &str, node: Node<'_>, _source: &str) -> bool { name.starts_with("namespace") || matches!( name, "switch" | "if" | "for" | "while" | "do" | "case" | "return" ) + // `class MACRO Name : public Base { … }` misparses to a function + // definition named after the class or its base. The ALL-CAPS + // export-macro shapes are recovered as classes before this; this is + // the fallback for any misparse left (upstream #946). + || is_macro_misparsed_type_decl(node) } fn extract_import(&self, node: Node<'_>, source: &str) -> Option { include_import(node, source) @@ -149,6 +160,28 @@ impl LanguageSpec for CppSpec { } } +/// tree-sitter's reading of a class or struct annotated with a macro it does +/// not know (upstream #946 `isMacroMisparsedTypeDecl`): `class MACRO` is an +/// elaborated type specifier, a *bodyless* `class_specifier` or +/// `struct_specifier`, and the rest a function definition whose declarator is +/// no function declarator. A real inline-defined return type (`struct P { int +/// x; } makeP() { … }`) has a field list, and a real function returning an +/// elaborated type (`class Foo f() { … }`) has a function declarator. +fn is_macro_misparsed_type_decl(node: Node<'_>) -> bool { + let Some(type_node) = child_by_field(node, "type") else { + return false; + }; + if !matches!(type_node.kind(), "class_specifier" | "struct_specifier") + || type_node + .named_children(&mut type_node.walk()) + .any(|child| child.kind() == "field_declaration_list") + { + return false; + } + !child_by_field(node, "declarator") + .is_some_and(|declarator| declarator.kind() == "function_declarator") +} + /// A C++ pure-virtual declaration is a `field_declaration`, not a /// `function_definition`: its declarator unwraps to a function declarator and /// the pure specifier is a direct `number_literal` child equal to `0`. A default @@ -422,26 +455,54 @@ fn find_descendant_of_kind<'tree>(node: Node<'tree>, kind: &str) -> Option String { - let bytes = blank_msvc_com_interface_keyword(blank_cpp_annotation_macro_calls( - blank_cpp_inline_annotation_macros(blank_cpp_api_prefix_macros(source.as_bytes().to_vec())), +/// Offset-preserving C++ pre-parse, in upstream `preParseCppSource`'s order +/// (`b635dd46`): access specifiers spelled as macros are rewritten and +/// conditionals inside a declaration flattened (#2186); then the MSVC COM +/// `interface` keyword, macros in class heads (#1070, #2449) and in front of +/// return types (#1100), Unreal-Engine markup (member `*_API` prefixes, +/// mid-line `UMETA`/`UPARAM`/`UE_DEPRECATED`, line-leading annotation calls, +/// #1158), attribute macros around pointers, after parameter lists and +/// declared names (#2449), the C leading attribute macro (only with a +/// `#define` proving it, KEEP-RUST), macros opening a declaration (#2449) and +/// lone macro lines (#1346); the Metal and CUDA dialects; and last, every +/// directive line as the source wrote it (#1346). Blanking replaces bytes +/// with ASCII spaces on char boundaries, so byte length (and thus every +/// line/column) is preserved. Each pass is gated, so macro-free C++ is +/// returned byte-identical. +pub(crate) fn pre_parse_cpp_source(source: &str, file_path: &str) -> String { + // Every pass replaces whole ASCII spans, so the text stays UTF-8; were it + // ever not to, the source goes to the parser unchanged. + try_pre_parse_cpp_source(source, file_path).unwrap_or_else(|| source.to_string()) +} + +fn try_pre_parse_cpp_source(source: &str, file_path: &str) -> Option { + use super::cpp_preparse as passes; + let source = + passes::flatten_mid_statement_conditionals(&passes::rewrite_cpp_access_macros(source)); + let text = |bytes: Vec| String::from_utf8(bytes).ok(); + let blanked = text(blank_msvc_com_interface_keyword(source.as_bytes().to_vec()))?; + let blanked = passes::blank_cpp_inline_macros(&passes::blank_cpp_export_macros(&blanked)); + let blanked = text(blank_cpp_inline_annotation_macros( + blank_cpp_api_prefix_macros(blanked.into_bytes()), + ))?; + let blanked = passes::blank_cpp_declarator_attribute_macros( + &passes::blank_cpp_trailing_attribute_macros(&passes::blank_cpp_pointer_annotation_macros( + &blanked, + )), + ); + let blanked = text(blank_cpp_annotation_macro_calls(blanked.into_bytes()))?; + let blanked = passes::blank_lone_macro_lines(&passes::blank_cpp_leading_attribute_macros( + &super::c::blank_c_leading_attr_macros(&blanked), )); let lower = file_path.to_ascii_lowercase(); let bytes = if lower.ends_with(".metal") { - blank_metal_attributes(bytes) - } else if lower.ends_with(".cu") || lower.ends_with(".cuh") || looks_like_cuda_source(source) { - blank_cuda_constructs(bytes) + blank_metal_attributes(blanked.into_bytes()) + } else if lower.ends_with(".cu") || lower.ends_with(".cuh") || looks_like_cuda_source(&source) { + blank_cuda_constructs(blanked.into_bytes()) } else { - bytes + blanked.into_bytes() }; - String::from_utf8(bytes).unwrap_or_else(|_| source.to_string()) + Some(passes::restore_directive_lines(&source, &text(bytes)?)) } /// Blank Metal Shading Language `[[attribute]]` annotations (`[[position]]`, @@ -836,8 +897,28 @@ fn blank_msvc_com_interface_keyword(bytes: Vec) -> Vec { /// node's docstring, so a purely textual match corrupts extraction output even /// though it never mints a node. pub(crate) fn cpp_code_mask(src: &str) -> Vec { + cpp_code_classes(src) + .into_iter() + .map(|class| class == CODE) + .collect() +} + +/// [`cpp_code_classes`]: ordinary code. +pub(crate) const CODE: u8 = 0; +/// [`cpp_code_classes`]: a line or block comment. +pub(crate) const COMMENT: u8 = 1; +/// [`cpp_code_classes`]: a string, char or raw string literal. +pub(crate) const LITERAL: u8 = 2; +/// [`cpp_code_classes`]: a logical preprocessor line. +pub(crate) const DIRECTIVE: u8 = 3; + +/// The lexical class of every byte of `src` ([`CODE`], [`COMMENT`], +/// [`LITERAL`], [`DIRECTIVE`]): the one scan [`cpp_code_mask`] reads, so the +/// C++ pre-parse passes that need to tell a literal from a comment see the +/// same boundaries as every other pass. +pub(crate) fn cpp_code_classes(src: &str) -> Vec { let bytes = src.as_bytes(); - let mut mask = vec![true; bytes.len()]; + let mut classes = vec![CODE; bytes.len()]; let mut i = 0usize; let mut at_line_start = true; while i < bytes.len() { @@ -854,28 +935,28 @@ pub(crate) fn cpp_code_mask(src: &str) -> Vec { } at_line_start = false; if b == b'#' { - i = mask_preprocessor_line(bytes, &mut mask, i); + i = mask_preprocessor_line(bytes, &mut classes, i); at_line_start = true; continue; } } if b == b'/' && bytes.get(i + 1) == Some(&b'/') { while i < bytes.len() && bytes[i] != b'\n' { - mask[i] = false; + classes[i] = COMMENT; i += 1; } continue; } if b == b'/' && bytes.get(i + 1) == Some(&b'*') { - mask[i] = false; - mask[i + 1] = false; + classes[i] = COMMENT; + classes[i + 1] = COMMENT; i += 2; while i < bytes.len() { let closing = bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/'); - mask[i] = false; + classes[i] = COMMENT; i += 1; if closing { - mask[i] = false; + classes[i] = COMMENT; i += 1; break; } @@ -885,28 +966,28 @@ pub(crate) fn cpp_code_mask(src: &str) -> Vec { // A raw string is recognised by the `R` immediately before the quote, // which also covers every encoding prefix (`LR"`, `u8R"`, `uR"`, `UR"`). if b == b'"' && i > 0 && bytes[i - 1] == b'R' { - i = mask_raw_string(bytes, &mut mask, i); + i = mask_raw_string(bytes, &mut classes, i); continue; } if b == b'"' || b == b'\'' { - i = mask_quoted_literal(bytes, &mut mask, i, b); + i = mask_quoted_literal(bytes, &mut classes, i, b); continue; } i += 1; } - mask + classes } -/// Mask a whole LOGICAL preprocessor line starting at `start` (the `#`), +/// Mark a whole LOGICAL preprocessor line starting at `start` (the `#`), /// following every backslash-newline continuation. Returns the index just past /// the logical line. The continuation matters: `#define D \` + a following /// ` interface IFoo` line is one directive, and its second physical line is /// not code. -fn mask_preprocessor_line(bytes: &[u8], mask: &mut [bool], start: usize) -> usize { +fn mask_preprocessor_line(bytes: &[u8], classes: &mut [u8], start: usize) -> usize { let mut i = start; loop { while i < bytes.len() && bytes[i] != b'\n' { - mask[i] = false; + classes[i] = DIRECTIVE; i += 1; } let mut back = i; @@ -915,7 +996,7 @@ fn mask_preprocessor_line(bytes: &[u8], mask: &mut [bool], start: usize) -> usiz } let continued = back > start && bytes[back - 1] == b'\\'; if i < bytes.len() { - mask[i] = false; + classes[i] = DIRECTIVE; i += 1; } if !continued || i >= bytes.len() { @@ -924,10 +1005,10 @@ fn mask_preprocessor_line(bytes: &[u8], mask: &mut [bool], start: usize) -> usiz } } -/// Mask `R"delim( … )delim"` from its opening quote. The delimiter is captured +/// Mark `R"delim( … )delim"` from its opening quote. The delimiter is captured /// so a custom one (`R"xy( … )xy"`) closes on its own terminator instead of the /// first `)"`. A malformed opener falls back to ordinary string rules. -fn mask_raw_string(bytes: &[u8], mask: &mut [bool], quote: usize) -> usize { +fn mask_raw_string(bytes: &[u8], classes: &mut [u8], quote: usize) -> usize { let mut delim_end = quote + 1; while delim_end < bytes.len() && bytes[delim_end] != b'(' @@ -937,7 +1018,7 @@ fn mask_raw_string(bytes: &[u8], mask: &mut [bool], quote: usize) -> usize { delim_end += 1; } if delim_end >= bytes.len() || bytes[delim_end] != b'(' { - return mask_quoted_literal(bytes, mask, quote, b'"'); + return mask_quoted_literal(bytes, classes, quote, b'"'); } let mut closer = Vec::with_capacity(delim_end - quote + 1); closer.push(b')'); @@ -953,28 +1034,28 @@ fn mask_raw_string(bytes: &[u8], mask: &mut [bool], quote: usize) -> usize { } k += 1; }; - for m in mask.iter_mut().take(end).skip(quote) { - *m = false; + for class in classes.iter_mut().take(end).skip(quote) { + *class = LITERAL; } end } -/// Mask a `"…"` or `'…'` literal from its opening quote, honouring backslash +/// Mark a `"…"` or `'…'` literal from its opening quote, honouring backslash /// escapes. An unterminated literal stops at the newline rather than swallowing /// the rest of the file. -fn mask_quoted_literal(bytes: &[u8], mask: &mut [bool], start: usize, quote: u8) -> usize { - mask[start] = false; +fn mask_quoted_literal(bytes: &[u8], classes: &mut [u8], start: usize, quote: u8) -> usize { + classes[start] = LITERAL; let mut i = start + 1; while i < bytes.len() { let c = bytes[i]; if c == b'\n' { break; } - mask[i] = false; + classes[i] = LITERAL; i += 1; if c == b'\\' { if i < bytes.len() && bytes[i] != b'\n' { - mask[i] = false; + classes[i] = LITERAL; i += 1; } continue; @@ -1510,6 +1591,55 @@ mod tests { /// tree-sitter's error recovery happens to extract identically for that /// file's text (measured). The exclusion is still required: it stops the pass /// emitting invalid C++ from input that parses correctly today. + /// Upstream #2449's whole-pipeline offset check: every macro of a class + /// written around attribute macros goes, every line keeps its length, + /// CRLF included. + #[test] + fn pre_parse_keeps_every_offset_through_the_whole_pipeline() { + let src = "class PROTOBUF_EXPORT X final : public Y {\n const D* PROTOBUF_NONNULL d() const ABSL_ATTRIBUTE_LIFETIME_BOUND;\n int n_ GUARDED_BY(mu_);\n};\r\n"; + let out = pre_parse_cpp_source(src, "x.h"); + assert_eq!(out.len(), src.len()); + let lengths = |text: &str| text.split('\n').map(str::len).collect::>(); + assert_eq!(lengths(&out), lengths(src)); + for gone in ["PROTOBUF_", "ABSL_", "GUARDED_BY"] { + assert!(!out.contains(gone), "{out:?}"); + } + } + + /// The C leading attribute macro pass runs in C++ only with a `#define` + /// proving the token an attribute (KEEP-RUST): a typedef'd return type or + /// a keyword alias in capitals stays, and so does a macro call inside an + /// expression. + #[test] + fn pre_parse_leaves_unproved_capitals_and_expression_macros_alone() { + for src in [ + "EFI_STATUS f(void) {\n return 0;\n}\n", + "STATIC int x;\n", + "int v = MAX_OF(a, b) + LIMIT_VALUE;\n", + "if (CHECK_FLAG(x)) {\n run();\n}\n", + ] { + assert_eq!(pre_parse_cpp_source(src, "x.cpp"), src, "{src:?}"); + } + // With a `#define` proving it an attribute, the C pass blanks it. + let proved = "#define SEC_ATTR __attribute__((section(\".x\")))\nSEC_ATTR int helper(void) {\n return 0;\n}\n"; + let out = pre_parse_cpp_source(proved, "x.cpp"); + assert!(out.starts_with("#define SEC_ATTR "), "{out:?}"); + assert!(out.contains("\n int helper(void)"), "{out:?}"); + } + + /// A directive keeps its text through every pass: a `#define` whose body + /// holds a macro shape the passes blank elsewhere is restored whole. + #[test] + fn pre_parse_restores_directive_lines() { + let src = "#define DECL(name) PROTOBUF_EXPORT void name()\nclass FOO_API Thing {};\n"; + let out = pre_parse_cpp_source(src, "x.h"); + assert!( + out.starts_with("#define DECL(name) PROTOBUF_EXPORT void name()\n"), + "{out:?}" + ); + assert!(out.contains("class Thing {};"), "{out:?}"); + } + #[test] fn cpp_code_mask_covers_comments_strings_and_preproc() { let must_not_fire: &[(&str, &str)] = &[ diff --git a/crates/codegraph-extract/src/lang/cpp_brace_scopes.rs b/crates/codegraph-extract/src/lang/cpp_brace_scopes.rs new file mode 100644 index 00000000..dad1a55a --- /dev/null +++ b/crates/codegraph-extract/src/lang/cpp_brace_scopes.rs @@ -0,0 +1,868 @@ +//! C++ scopes read from the source's braces, for a file whose parse tree has +//! errors (upstream #2426 `cpp-brace-scopes.ts`). +//! +//! tree-sitter recovers from a construct it can't parse by inserting the +//! token it expected, and inside a class body that token is often a `}`: an +//! unknown macro in front of a member (`NODISCARD absl::string_view name() +//! const`) or in a class head (`struct cacheline_aligned(64U) HandleImpl`) +//! closes the class there. Every `}` after it then closes the scope one level +//! out, so the rest of the class, and of each namespace around it, parses as +//! declarations of an outer scope: protobuf's `FieldDescriptor` indexed as +//! `FieldDescriptor` instead of `google::protobuf::FieldDescriptor`, rocksdb's +//! `struct Opts` outside the class it is declared in. Recovery also runs the +//! other way and keeps a scope open past its own `}`. +//! +//! The braces are rarely what the parser misread, so the walker takes each +//! declaration's namespaces and enclosing classes from them instead. The scan +//! skips comments, string and character literals (raw strings and digit +//! separators too) and preprocessor lines. Each branch of an `#if` is read +//! from the braces open at the `#if`, and the scan continues after `#endif` +//! with the first branch's, so `#if A` `struct X : B {` `#else` `struct X {` +//! `#endif` opens one scope, not two. A file whose braces don't balance gets +//! no scopes and keeps the tree's. +//! +//! Offsets are bytes of the source the parser saw (after the pre-parse), as +//! the tree's own offsets are. + +use std::collections::{BTreeMap, HashMap}; + +/// Intervals that nest properly, added in order of their opening offset. +#[derive(Debug, Clone)] +pub struct NestedIntervals { + opens: Vec, + closes: Vec, + parents: Vec>, + values: Vec, +} + +impl Default for NestedIntervals { + fn default() -> Self { + Self { + opens: Vec::new(), + closes: Vec::new(), + parents: Vec::new(), + values: Vec::new(), + } + } +} + +impl NestedIntervals { + /// Adds the interval `(open, close)`. One that opens before the last one + /// added, or crosses one that holds it, is refused (false). + pub fn add(&mut self, open: usize, close: usize, value: T) -> bool { + if close <= open || self.opens.last().is_some_and(|&last| open <= last) { + return false; + } + let parent = self.innermost(open); + if parent.is_some_and(|parent| close >= self.closes[parent]) { + return false; + } + self.opens.push(open); + self.closes.push(close); + self.parents.push(parent); + self.values.push(value); + true + } + + /// The values of the intervals holding `offset` (open < offset < close), + /// outermost first. + pub fn at(&self, offset: usize) -> Vec<&T> { + let mut out = Vec::new(); + let mut at = self.innermost(offset); + while let Some(k) = at { + out.push(&self.values[k]); + at = self.parents[k]; + } + out.reverse(); + out + } + + /// The innermost interval holding `offset`: the last interval opening + /// before `offset`, or one of the intervals around that one. + fn innermost(&self, offset: usize) -> Option { + let mut k = self + .opens + .partition_point(|&open| open < offset) + .checked_sub(1); + while let Some(at) = k { + if self.closes[at] > offset { + break; + } + k = self.parents[at]; + } + k + } +} + +/// The braces and named namespaces of one C++ source. +#[derive(Debug, Clone)] +pub struct CppBraceScopes { + closes: HashMap, + namespaces: NestedIntervals, + line_starts: Vec, +} + +impl CppBraceScopes { + /// The offset of the `}` that closes the `{` at `open`. + pub fn close_of(&self, open: usize) -> Option { + self.closes.get(&open).copied() + } + + /// The named namespaces whose braces hold `offset`, outermost first, as + /// written (`a::b`). + pub fn namespaces_at(&self, offset: usize) -> Vec { + self.namespaces.at(offset).into_iter().cloned().collect() + } + + /// The 1-based line and the 0-based byte column of `offset`, as the + /// tree's positions are. + pub fn position_of(&self, offset: usize) -> (i64, i64) { + let line = self.line_starts.partition_point(|&start| start <= offset) - 1; + (line as i64 + 1, (offset - self.line_starts[line]) as i64) + } +} + +fn is_blank(c: u8) -> bool { + matches!(c, b' ' | b'\t' | b'\r' | 0x0c | 0x0b) +} + +fn is_word_start(c: u8) -> bool { + c.is_ascii_alphabetic() || c == b'_' || c == b'$' || c >= 0x80 +} + +fn is_word_char(c: u8) -> bool { + is_word_start(c) || c.is_ascii_digit() +} + +fn byte(source: &[u8], at: usize) -> u8 { + source.get(at).copied().unwrap_or(0) +} + +/// Whether `text` is a raw string's prefix (`R`, `u8R`, `uR`, `UR`, `LR`). +fn is_raw_string_prefix(text: &[u8]) -> bool { + matches!(text, b"R" | b"u8R" | b"uR" | b"UR" | b"LR") +} + +/// A raw string's delimiter: up to 16 printable ASCII characters but `"`, +/// `(`, `)`, `\` and the space. +fn is_raw_string_delimiter(text: &[u8]) -> bool { + text.len() <= 16 + && text + .iter() + .all(|&c| (b'!'..=b'~').contains(&c) && !matches!(c, b'"' | b'(' | b')' | b'\\')) +} + +/// Where `needle` next occurs in `source` from `from`. +fn find(source: &[u8], from: usize, needle: &[u8]) -> Option { + if from >= source.len() { + return None; + } + source[from..] + .windows(needle.len()) + .position(|window| window == needle) + .map(|at| from + at) +} + +/// The end of a `//` comment starting at `at`: its newline (a `\` before it +/// continues the comment). +fn line_comment_end(source: &[u8], mut at: usize) -> usize { + loop { + let Some(newline) = find(source, at, b"\n") else { + return source.len(); + }; + let mut k = newline; + if k > 0 && source[k - 1] == b'\r' { + k -= 1; + } + if k == 0 || source[k - 1] != b'\\' { + return newline; + } + at = newline + 1; + } +} + +/// The end of a `/* */` comment opened at `at` (its `/*`). +fn block_comment_end(source: &[u8], at: usize) -> usize { + find(source, at + 2, b"*/").map_or(source.len(), |end| end + 2) +} + +/// Past whitespace and comments from `at`. +fn skip_blank(source: &[u8], mut at: usize) -> usize { + while at < source.len() { + let c = source[at]; + if is_blank(c) || c == b'\n' { + at += 1; + } else if c == b'/' && byte(source, at + 1) == b'/' { + at = line_comment_end(source, at + 2); + } else if c == b'/' && byte(source, at + 1) == b'*' { + at = block_comment_end(source, at); + } else { + break; + } + } + at +} + +/// The end of the identifier at `at`, or `None` when none starts there. +fn word_end(source: &[u8], at: usize) -> Option { + if !is_word_start(byte(source, at)) { + return None; + } + let mut end = at + 1; + while end < source.len() && is_word_char(source[end]) { + end += 1; + } + Some(end) +} + +/// Past the string or character literal whose quote is at `at` (it stops at +/// a newline). +fn quoted_end(source: &[u8], at: usize) -> usize { + let quote = source[at]; + let mut k = at + 1; + while k < source.len() { + let c = source[k]; + if c == b'\\' { + k += if byte(source, k + 1) == b'\r' && byte(source, k + 2) == b'\n' { + 3 + } else { + 2 + }; + } else if c == quote { + return k + 1; + } else if c == b'\n' { + return k; + } else { + k += 1; + } + } + source.len() +} + +/// Past the `(…)` group at `at` (strings inside skipped), or `None`. +fn paren_group_end(source: &[u8], at: usize) -> Option { + let mut depth = 0usize; + let mut k = at; + while k < source.len() { + match source[k] { + b'"' | b'\'' => { + k = quoted_end(source, k); + continue; + } + b'(' => depth += 1, + b')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(k + 1); + } + } + _ => {} + } + k += 1; + } + None +} + +/// After the `namespace` keyword ending at `from`: the name as written and +/// the offset of the `{` that opens the body. `None` for anything else, such +/// as `using namespace std;` or `namespace fs = std::filesystem;`. Takes C++17 +/// attributes (`namespace [[deprecated]] old {`), nested and inline names +/// (`namespace a::inline b {`), and an attribute macro after the name +/// (`namespace std _GLIBCXX_VISIBILITY(default) {`). +fn namespace_head(source: &[u8], from: usize) -> Option<(String, usize)> { + let mut at = skip_blank(source, from); + while source[at..].starts_with(b"[[") { + let end = find(source, at + 2, b"]]")?; + at = skip_blank(source, end + 2); + } + let mut name: Option<(usize, usize)> = None; + while let Some(end) = word_end(source, at) { + let start = name.map_or(at, |(start, _)| start); + name = Some((start, end)); + at = skip_blank(source, end); + if !source[at..].starts_with(b"::") { + break; + } + at = skip_blank(source, at + 2); + if let Some(inline) = word_end(source, at) + && &source[at..inline] == b"inline" + { + at = skip_blank(source, inline); + } + } + if name.is_some() { + while let Some(end) = word_end(source, at) { + let paren = skip_blank(source, end); + if byte(source, paren) != b'(' { + return None; + } + at = skip_blank(source, paren_group_end(source, paren)?); + } + } + if byte(source, at) != b'{' { + return None; + } + let name = name.map_or(String::new(), |(start, end)| { + String::from_utf8_lossy(&source[start..end]).into_owned() + }); + Some((name, at)) +} + +/// Per `#if` group: the braces open at the `#if`, and those open after its +/// first branch (`None` until a second branch starts). +struct Branch { + at_if: Vec, + after_first: Option>, +} + +/// Scan `source` (a C++ file as the parser saw it, after the pre-parse) for +/// its braces and namespaces. `None` when the braces don't balance. +pub fn scan_cpp_brace_scopes(text: &str) -> Option { + let source = text.as_bytes(); + let n = source.len(); + let mut closes: HashMap = HashMap::new(); + let mut open: Vec = Vec::new(); + let mut branches: Vec = Vec::new(); + // `{` offset -> name, for named namespaces. + let mut namespace_at: BTreeMap = BTreeMap::new(); + let mut pending_namespace: Option<(String, usize)> = None; + let mut balanced = true; + let mut line_start = true; + // The last identifier or number read. + let mut word = (usize::MAX, usize::MAX); + // Past a byte-order mark, so line 1 can be a directive. + let mut i = if source.starts_with(b"\xEF\xBB\xBF") { + 3 + } else { + 0 + }; + while i < n { + let c = source[i]; + if c == b'\n' { + line_start = true; + i += 1; + continue; + } + if is_blank(c) { + i += 1; + continue; + } + if c == b'/' && byte(source, i + 1) == b'/' { + i = line_comment_end(source, i + 2); + continue; + } + if c == b'/' && byte(source, i + 1) == b'*' { + i = block_comment_end(source, i); + continue; + } + if c == b'#' && line_start { + // A directive runs to the end of its line, through `\` + // continuations and comments. Only `"` strings are skipped in it: + // an apostrophe in `#error Don't …` is no character literal. + let mut k = i + 1; + while k < n && is_blank(source[k]) { + k += 1; + } + let directive = word_end(source, k).map_or(&b""[..], |end| &source[k..end]); + while k < n { + let d = source[k]; + if d == b'\n' { + let mut back = k; + if back > 0 && source[back - 1] == b'\r' { + back -= 1; + } + if back == 0 || source[back - 1] != b'\\' { + break; + } + k += 1; + } else if d == b'/' && byte(source, k + 1) == b'/' { + k = line_comment_end(source, k + 2); + } else if d == b'/' && byte(source, k + 1) == b'*' { + k = block_comment_end(source, k); + } else if d == b'"' { + k = quoted_end(source, k); + } else { + k += 1; + } + } + match directive { + b"if" | b"ifdef" | b"ifndef" => branches.push(Branch { + at_if: open.clone(), + after_first: None, + }), + b"else" | b"elif" | b"elifdef" | b"elifndef" => { + if let Some(group) = branches.last_mut() { + if group.after_first.is_none() { + group.after_first = Some(std::mem::take(&mut open)); + } + open = group.at_if.clone(); + } + } + b"endif" => { + if let Some(group) = branches.pop() + && let Some(after_first) = group.after_first + { + open = after_first; + } + } + _ => {} + } + i = k; + continue; + } + line_start = false; + if c == b'"' { + // A raw string (`R"x(…)x"`) can hold any brace and quote; its + // prefix is the word that ends right at the quote. + if word.1 == i && i - word.0 <= 3 && is_raw_string_prefix(&source[word.0..i]) { + if let Some(paren) = find(source, i + 1, b"(") { + let delimiter = &source[i + 1..paren]; + if is_raw_string_delimiter(delimiter) { + let mut close = Vec::with_capacity(delimiter.len() + 2); + close.push(b')'); + close.extend_from_slice(delimiter); + close.push(b'"'); + i = find(source, paren + 1, &close) + .map_or(n, |end| end + delimiter.len() + 2); + continue; + } + } + } + i = quoted_end(source, i); + continue; + } + if c == b'\'' { + i = quoted_end(source, i); + continue; + } + if c.is_ascii_digit() || (c == b'.' && byte(source, i + 1).is_ascii_digit()) { + // A number, digit separators (`1'000'000`) and exponent signs + // included. + let mut k = i + 1; + while k < n { + let d = source[k]; + if is_word_char(d) || d == b'.' { + k += 1; + } else if d == b'\'' && is_word_char(byte(source, k + 1)) { + k += 2; + } else if matches!(d, b'+' | b'-') + && matches!(source[k - 1], b'e' | b'E' | b'p' | b'P') + { + k += 1; + } else { + break; + } + } + word = (i, k); + i = k; + continue; + } + if is_word_start(c) { + let end = word_end(source, i).unwrap_or(i + 1); + word = (i, end); + if &source[i..end] == b"namespace" + && let Some(head) = namespace_head(source, end) + { + pending_namespace = Some(head); + } + i = end; + continue; + } + if c == b'{' { + open.push(i); + if let Some((name, brace)) = pending_namespace.take() { + if brace == i { + if !name.is_empty() { + namespace_at.insert(i, name); + } + } else { + pending_namespace = Some((name, brace)); + } + } + } else if c == b'}' { + match open.pop() { + None => balanced = false, + Some(from) => { + closes.entry(from).or_insert(i); + } + } + } + i += 1; + } + if !balanced || !open.is_empty() { + return None; + } + let mut namespaces = NestedIntervals::default(); + for (brace, name) in namespace_at { + if let Some(&close) = closes.get(&brace) { + namespaces.add(brace, close, name); + } + } + let mut line_starts = vec![0]; + line_starts.extend( + source + .iter() + .enumerate() + .filter(|(_, c)| **c == b'\n') + .map(|(at, _)| at + 1), + ); + Some(CppBraceScopes { + closes, + namespaces, + line_starts, + }) +} + +#[cfg(test)] +mod tests { + use super::{NestedIntervals, scan_cpp_brace_scopes}; + + #[test] + fn matches_braces_past_comments_literals_and_preprocessor_lines() { + let source = [ + "namespace a {", + "// } a comment", + "/* { */ const char* s = \"}{\";", + "char c = '}'; int n = 1'000'000;", + "auto r = R\"x(})x\";", + "#define OPEN {", + "struct S { int x; };", + "}", + "", + ] + .join("\n"); + let scopes = scan_cpp_brace_scopes(&source).expect("balanced"); + let open = source.find('{').unwrap(); + assert_eq!(scopes.close_of(open), source.rfind('}')); + let structure = source.find("S {").unwrap() + 2; + let close = scopes.close_of(structure).unwrap(); + assert_eq!(&source[structure..=close], "{ int x; }"); + assert_eq!(scopes.namespaces_at(structure), ["a"]); + } + + #[test] + fn reads_each_if_branch_from_the_braces_open_at_the_if() { + let source = [ + "namespace a {", + "#if defined(X)", + "struct S : Base {", + "#else", + "struct S {", + "#endif", + " int x;", + "};", + "struct T {};", + "}", + "", + ] + .join("\n"); + let scopes = scan_cpp_brace_scopes(&source).expect("balanced"); + let first = source.find("Base {").unwrap() + 5; + assert_eq!(scopes.close_of(first), source.find("};")); + assert_eq!( + scopes.namespaces_at(source.find("struct T").unwrap()), + ["a"] + ); + } + + #[test] + fn names_namespaces_as_written_and_skips_what_declares_none() { + let source = [ + "namespace a::b {", + "namespace [[deprecated]] c {", + "inline namespace v1 {", + "namespace {", + "using namespace std;", + "namespace fs = std::filesystem;", + "int here;", + "}", + "}", + "}", + "}", + "namespace std _GLIBCXX_VISIBILITY(default) {", + "int there;", + "}", + "", + ] + .join("\n"); + let scopes = scan_cpp_brace_scopes(&source).expect("balanced"); + assert_eq!( + scopes.namespaces_at(source.find("int here").unwrap()), + ["a::b", "c", "v1"] + ); + assert_eq!( + scopes.namespaces_at(source.find("int there").unwrap()), + ["std"] + ); + } + + #[test] + fn gives_up_on_braces_that_do_not_balance() { + assert!(scan_cpp_brace_scopes("namespace a {\nint x;\n").is_none()); + assert!(scan_cpp_brace_scopes("int x;\n}\n").is_none()); + } + + /// Upstream places offsets in UTF-16 columns, as its parser does; the + /// tree here gives byte columns, and so do these. + #[test] + fn places_offsets_in_byte_columns_as_the_tree_does() { + let source = "namespace n {\n/* é😀 */ class C { int x; };\n}\n"; + let scopes = scan_cpp_brace_scopes(source).expect("balanced"); + let close = scopes.close_of(source.find("{ int").unwrap()).unwrap(); + assert_eq!(scopes.position_of(close + 1), (2, 31)); + } + + #[test] + fn nested_intervals_refuse_crossing_and_out_of_order_intervals() { + let mut intervals = NestedIntervals::default(); + assert!(intervals.add(0, 100, "outer")); + assert!(intervals.add(10, 20, "first")); + assert!(!intervals.add(5, 30, "before the last")); + assert!(!intervals.add(15, 40, "crosses")); + assert!(intervals.add(30, 50, "second")); + assert_eq!(intervals.at(12), [&"outer", &"first"]); + assert_eq!(intervals.at(25), [&"outer"]); + assert_eq!(intervals.at(40), [&"outer", &"second"]); + assert!(intervals.at(100).is_empty()); + } +} + +/// Upstream #2426's fixtures (`cpp-brace-scopes.test.ts`): classes that tree-sitter's +/// error recovery closes early, keeps open past their `}`, or swallows into an +/// ERROR, walked in the scopes their braces open. +#[cfg(test)] +mod fixtures { + use codegraph_core::types::{EdgeKind, ExtractionResult, Language, Node, NodeKind}; + + const PROTOBUF_SHAPE: &str = r#"namespace google { +namespace protobuf { + +class Names { + public: + NODISCARD absl::string_view name() const { + return absl::string_view(payload_); + } + + private: + const char* payload_; +}; + +class FieldDescriptor : private internal::SymbolBase { + public: + int index() const; +}; + +} // namespace protobuf +} // namespace google +"#; + const ROCKSDB_SHAPE: &str = r#"namespace rocksdb { +namespace clock_cache { + +class FixedTable : public BaseTable { + public: + struct cacheline_aligned(64U) HandleImpl : public ClockHandle { + RelaxedAtomic displacements{}; + bool standalone = false; + }; + + struct Opts : public BaseOpts { + explicit Opts(size_t size) : BaseOpts(0), size(size) {} + size_t size; + }; + + void Insert(const Opts& opts); +}; + +class AutoTable : public BaseTable { + public: + void Erase(); +}; + +} // namespace clock_cache +} // namespace rocksdb +"#; + const BRANCHES_SHAPE: &str = r#"namespace net { + +class Socket { + public: +#ifdef _WIN32 + int Open(SOCKET handle) { +#else + int Open(int fd) { +#endif + return Bind(); + } + + int Bind(); +}; + +class Listener { + public: + void Accept() {} +}; + +} // namespace net +"#; + const FMT_SHAPE: &str = r#"namespace fmt { +namespace detail { + +template class basic_format_args { + public: + FMT_CONSTEXPR auto get(int id) const -> format_arg { + auto arg = format_arg(); + if (!is_packed()) { + if (unsigned(id) < unsigned(max_size())) arg = args_[id]; + return arg; + } + if (unsigned(id) >= detail::max_packed_args) return arg; + return arg; + } + + auto max_size() const -> int { return 0; } +}; + +class context { + public: + void advance_to(int it) {} +}; + +} // namespace detail +} // namespace fmt +"#; + + fn extract(file: &str, source: &str) -> ExtractionResult { + crate::extract_source(file, source, Some(Language::Cpp)) + } + + fn named(result: &ExtractionResult, kind: NodeKind) -> Vec<&str> { + result + .nodes + .iter() + .filter(|node| node.kind == kind) + .map(|node| node.qualified_name.as_str()) + .collect() + } + + fn node_at<'a>(result: &'a ExtractionResult, qualified_name: &str) -> &'a Node { + result + .nodes + .iter() + .find(|node| node.qualified_name == qualified_name) + .unwrap_or_else(|| panic!("no {qualified_name}: {:#?}", result.nodes)) + } + + fn has_error_after_pre_parse(file: &str, source: &str) -> bool { + let text = crate::lang::cpp::pre_parse_cpp_source(source, file); + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_cpp::LANGUAGE.into()) + .expect("cpp grammar"); + parser + .parse(&text, None) + .expect("tree") + .root_node() + .has_error() + } + + #[test] + fn every_fixture_still_misparses() { + for (file, source) in [ + ("descriptor.h", PROTOBUF_SHAPE), + ("clock_cache.h", ROCKSDB_SHAPE), + ("socket.h", BRANCHES_SHAPE), + ("core.h", FMT_SHAPE), + ] { + assert!( + has_error_after_pre_parse(file, source), + "{file} parses cleanly" + ); + } + } + + #[test] + fn a_class_after_one_closed_early_keeps_its_namespaces() { + let result = extract("src/google/protobuf/descriptor.h", PROTOBUF_SHAPE); + assert_eq!( + named(&result, NodeKind::Class), + [ + "google::protobuf::Names", + "google::protobuf::FieldDescriptor" + ] + ); + // `Names` is the one recovery glued to the tokens after it: it ends at its own `};`. + assert_eq!(node_at(&result, "google::protobuf::Names").end_line, 12); + } + + #[test] + fn a_nested_struct_keeps_its_class_and_the_next_class_its_namespaces() { + let result = extract("cache/clock_cache.h", ROCKSDB_SHAPE); + assert_eq!( + named(&result, NodeKind::Class), + [ + "rocksdb::clock_cache::FixedTable", + "rocksdb::clock_cache::AutoTable" + ] + ); + assert_eq!( + named(&result, NodeKind::Struct), + ["rocksdb::clock_cache::FixedTable::Opts"] + ); + assert_eq!( + named(&result, NodeKind::Method), + ["rocksdb::clock_cache::FixedTable::Opts::Opts"] + ); + // The class spans its whole body, so the struct it holds lies inside it. + let fixed = node_at(&result, "rocksdb::clock_cache::FixedTable"); + assert_eq!((fixed.start_line, fixed.end_line), (4, 17)); + let opts = node_at(&result, "rocksdb::clock_cache::FixedTable::Opts"); + let contains = result + .edges + .iter() + .find(|edge| edge.kind == EdgeKind::Contains && edge.target == opts.id) + .expect("contains edge"); + assert_eq!(contains.source, fixed.id); + } + + #[test] + fn a_class_the_tree_kept_open_past_its_brace_lets_go_of_the_next_class() { + let result = extract("net/socket.h", BRANCHES_SHAPE); + assert_eq!( + named(&result, NodeKind::Class), + ["net::Socket", "net::Listener"] + ); + assert!( + named(&result, NodeKind::Method).contains(&"net::Listener::Accept"), + "{:?}", + named(&result, NodeKind::Method) + ); + assert_eq!(node_at(&result, "net::Socket").end_line, 14); + } + + #[test] + fn namespaces_the_tree_swallowed_into_an_error_still_prefix_what_they_hold() { + let result = extract("include/fmt/core.h", FMT_SHAPE); + assert_eq!(named(&result, NodeKind::Class), ["fmt::detail::context"]); + assert_eq!( + named(&result, NodeKind::Method), + ["fmt::detail::context::advance_to"] + ); + } + + #[test] + fn a_file_that_parses_cleanly_keeps_the_tree_scopes() { + let clean = PROTOBUF_SHAPE.replace("NODISCARD ", ""); + assert!(!has_error_after_pre_parse("descriptor.h", &clean)); + let result = extract("src/google/protobuf/descriptor.h", &clean); + assert_eq!( + named(&result, NodeKind::Class), + [ + "google::protobuf::Names", + "google::protobuf::FieldDescriptor" + ] + ); + assert_eq!( + named(&result, NodeKind::Method), + ["google::protobuf::Names::name"] + ); + } +} diff --git a/crates/codegraph-extract/src/lang/cpp_preparse.rs b/crates/codegraph-extract/src/lang/cpp_preparse.rs new file mode 100644 index 00000000..5929b7db --- /dev/null +++ b/crates/codegraph-extract/src/lang/cpp_preparse.rs @@ -0,0 +1,2103 @@ +//! The C++ pre-parse passes upstream added to `preParseCppSource` that the +//! Rust port did not have (W4-CPP-01, upstream `b635dd46`): macros in class +//! heads (#1070) and in front of return types (#1100), access specifiers +//! spelled as macros and preprocessor conditionals inside a declaration +//! (#2186), attribute macros around pointers, after parameter lists, after +//! declared names and opening declarations (#2449), lone macro lines and the +//! restore of directive lines (#1346). +//! +//! Every pass is offset-preserving: it replaces bytes with spaces (or a +//! keyword padded to the same length) and keeps every line ending, so each +//! node keeps its line and column. The macro scans match on [`masked_code`], +//! the source with comments and directives turned to spaces and literals +//! (raw strings included) to 0x01 bytes, from the same lexical scan the other +//! C++ passes use ([`cpp_code_classes`]); a word in a comment or a literal is +//! never blanked. The `regex` crate has no look-around, so each upstream +//! pattern's look-around and backtracking is written out by hand. + +use std::sync::OnceLock; + +use regex::Regex; + +use super::cpp::{LITERAL, cpp_code_classes}; + +/// The text the macro scans match on, at the source's byte offsets (upstream +/// `maskCppCode`): comments and preprocessor lines are spaces, a literal keeps +/// its place as 0x01 bytes (a token, not space: in `"… using " GTEST_NAME_` +/// the macro does not start a line), line endings stay. +pub(crate) fn masked_code(source: &str) -> Vec { + source + .bytes() + .zip(cpp_code_classes(source)) + .map(|(byte, class)| match (byte, class) { + (b'\n' | b'\r', _) => byte, + (_, super::cpp::CODE) => byte, + (_, LITERAL) => 0x01, + _ => b' ', + }) + .collect() +} + +/// JavaScript's `\s` over ASCII. +fn is_space(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c) +} + +fn is_word(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +fn is_ident_start(byte: u8) -> bool { + byte.is_ascii_alphabetic() || byte == b'_' +} + +fn is_macro_char(byte: u8) -> bool { + byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_' +} + +fn skip_space(code: &[u8], mut at: usize) -> usize { + while at < code.len() && is_space(code[at]) { + at += 1; + } + at +} + +/// The end of the identifier `[A-Za-z_]\w*` starting at `at`, if one does. +fn ident_end(code: &[u8], at: usize) -> Option { + if !code.get(at).copied().is_some_and(is_ident_start) { + return None; + } + let mut end = at + 1; + while end < code.len() && is_word(code[end]) { + end += 1; + } + Some(end) +} + +/// Whether the word at `at` is `word` with a word boundary after it. +fn word_at(code: &[u8], at: usize, word: &str) -> bool { + code[at..].starts_with(word.as_bytes()) + && !code.get(at + word.len()).copied().is_some_and(is_word) +} + +/// The index just past the balanced `(…)` opening at `code[open]`, nesting at +/// most `depth` levels deep (upstream's `\((?:[^()]|\(…\))*\)` patterns spell +/// out two or three levels). +fn balanced_args_end(code: &[u8], open: usize, depth: usize) -> Option { + if code.get(open) != Some(&b'(') { + return None; + } + let mut level = 0usize; + for (at, &byte) in code.iter().enumerate().skip(open) { + if byte == b'(' { + level += 1; + if level > depth { + return None; + } + } else if byte == b')' { + level -= 1; + if level == 0 { + return Some(at + 1); + } + } + } + None +} + +/// The end of a macro token `[A-Z]` + [`is_macro_char`]* starting at `at`. +fn macro_run_end(code: &[u8], at: usize) -> usize { + let mut end = at + 1; + while end < code.len() && is_macro_char(code[end]) { + end += 1; + } + end +} + +/// Whether `code[at..end]` is `[A-Z][A-Z0-9]*_[A-Z0-9_]*`: a macro with an +/// underscore. +fn is_underscored_macro(code: &[u8], at: usize, end: usize) -> bool { + let token = &code[at..end]; + token.first().is_some_and(u8::is_ascii_uppercase) + && token.iter().all(|&byte| is_macro_char(byte)) + && token.contains(&b'_') +} + +/// Add a span for each macro of the run `code[start..end]` (upstream +/// `pushCppRunTokens`, `[A-Z][A-Z0-9_]*` with up to three levels of +/// arguments), leaving what lies between them (a comment, in the source). +fn push_run_tokens(spans: &mut Vec<(usize, usize)>, code: &[u8], start: usize, end: usize) { + let mut at = start; + while at < end { + if !code[at].is_ascii_uppercase() { + at += 1; + continue; + } + let name_end = macro_run_end(code, at).min(end); + let open = skip_space(code, name_end); + let token_end = match balanced_args_end(code, open, 3) { + Some(args_end) if open < end && args_end <= end => args_end, + _ => name_end, + }; + spans.push((at, token_end)); + at = token_end; + } +} + +/// Replace each span of `source` with spaces, keeping line endings. +fn blank_spans(source: &str, spans: &[(usize, usize)]) -> String { + if spans.is_empty() { + return source.to_string(); + } + let mut bytes = source.as_bytes().to_vec(); + for &(start, end) in spans { + for byte in bytes.iter_mut().take(end).skip(start) { + if *byte != b'\n' && *byte != b'\r' { + *byte = b' '; + } + } + } + String::from_utf8(bytes).unwrap_or_else(|_| source.to_string()) +} + +/// The index just past the template argument list opening at `code[open]` +/// (`<`), or `None` when it does not close before a `;`, `{` or `}` (it was a +/// comparison). Angle brackets inside parentheses do not count (upstream +/// `skipCppAngleArgs`). +fn skip_angle_args(code: &[u8], open: usize) -> Option { + let mut angles = 0i32; + let mut parens = 0i32; + let limit = code.len().min(open + 4000); + for at in open..limit { + match code[at] { + b'(' => parens += 1, + b')' => { + parens -= 1; + if parens < 0 { + return None; + } + } + b';' | b'{' | b'}' => return None, + b'<' if parens == 0 => angles += 1, + b'>' if parens == 0 && (at == 0 || code[at - 1] != b'-') => { + angles -= 1; + if angles == 0 { + return Some(at + 1); + } + } + _ => {} + } + } + None +} + +// --------------------------------------------------------------------------- +// #2186: access specifiers spelled as macros, conditionals inside a declaration +// --------------------------------------------------------------------------- + +/// Rewrite an access specifier spelled as a macro — nlohmann/json's +/// `JSON_PRIVATE_UNLESS_TESTED:`, Qt's `Q_SIGNALS:` — to the keyword it stands +/// for, padded to the same length (upstream #2186 `rewriteCppAccessMacros`). +/// The line holds the macro (ALL-CAPS with an underscore), its `:` and at +/// most a line comment. tree-sitter-cpp reads `NAME:` inside a class as an +/// error, and json.hpp's recovery lost the members around each one. +pub(crate) fn rewrite_cpp_access_macros(source: &str) -> String { + static GATE: OnceLock = OnceLock::new(); + let gate = GATE.get_or_init(|| Regex::new(r"_[A-Z0-9_]*[ \t]*:").expect("access-macro gate")); + if !gate + .find_iter(source) + .any(|found| source.as_bytes().get(found.end()) != Some(&b':')) + { + return source.to_string(); + } + let classes = cpp_code_classes(source); + let mut out = source.as_bytes().to_vec(); + let mut changed = false; + let mut line_start = 0usize; + while line_start <= source.len() { + let line_end = source[line_start..] + .find('\n') + .map_or(source.len(), |offset| line_start + offset); + if let Some((name_start, name_end, colon)) = + access_macro_line(source.as_bytes(), line_start, line_end) + && classes[name_start] != LITERAL + { + let name = &source[name_start..name_end]; + let keyword = if name.contains("PRIVATE") { + Some("private") + } else if name.contains("PROTECTED") { + Some("protected") + } else if name.contains("PUBLIC") || name == "Q_SIGNALS" || name == "Q_SLOTS" { + Some("public") + } else { + None + }; + if let Some(keyword) = keyword + && keyword.len() <= colon - name_start + { + let field = &mut out[name_start..colon]; + field[..keyword.len()].copy_from_slice(keyword.as_bytes()); + for byte in &mut field[keyword.len()..] { + *byte = b' '; + } + changed = true; + } + } + if line_end >= source.len() { + break; + } + line_start = line_end + 1; + } + if !changed { + return source.to_string(); + } + String::from_utf8(out).unwrap_or_else(|_| source.to_string()) +} + +/// `^([ \t]*)([A-Z][A-Z0-9]*_[A-Z0-9_]*)([ \t]*):(?!:)(?=[ \t]*(?://[^\n]*)?\r?$)` +/// on one line: the macro's span and its `:`. +fn access_macro_line(bytes: &[u8], start: usize, end: usize) -> Option<(usize, usize, usize)> { + let mut at = start; + while at < end && matches!(bytes[at], b' ' | b'\t') { + at += 1; + } + let name_start = at; + if !bytes.get(at).is_some_and(u8::is_ascii_uppercase) { + return None; + } + let name_end = macro_run_end(bytes, at).min(end); + if !is_underscored_macro(bytes, name_start, name_end) { + return None; + } + let mut colon = name_end; + while colon < end && matches!(bytes[colon], b' ' | b'\t') { + colon += 1; + } + if bytes.get(colon) != Some(&b':') || bytes.get(colon + 1) == Some(&b':') { + return None; + } + let mut rest = colon + 1; + while rest < end && matches!(bytes[rest], b' ' | b'\t') { + rest += 1; + } + let tail = &bytes[rest..end]; + let tail = tail.strip_suffix(b"\r").unwrap_or(tail); + (tail.is_empty() || tail.starts_with(b"//")).then_some((name_start, name_end, colon)) +} + +/// Blank a preprocessor conditional in the middle of a declaration or +/// expression, keeping its first branch (upstream #2186 +/// `flattenMidStatementConditionals`): inside a constructor's +/// member-initializer list (`noexcept\n#ifdef X\n : m_okay(false)\n#else\n : +/// m_okay(…)\n#endif`), between a template head and its function, inside an +/// argument list. tree-sitter-cpp reads any of them as an error, and json.hpp's +/// recovery took `basic_json` with it. Where the code before the `#if` has not +/// ended a statement or declaration (`;`, `{`, `}`, a label's `:`), its +/// directives are blanked and so is every branch after the first: one +/// consistent configuration, every newline and offset kept. A conditional +/// between whole declarations stays. +pub(crate) fn flatten_mid_statement_conditionals(source: &str) -> String { + if !source.contains('#') { + return source.to_string(); + } + static DIRECTIVE: OnceLock = OnceLock::new(); + static LINE_COMMENT: OnceLock = OnceLock::new(); + static BLOCK_COMMENT: OnceLock = OnceLock::new(); + static LABEL: OnceLock = OnceLock::new(); + static ACCESS: OnceLock = OnceLock::new(); + static STATEMENT_MACRO: OnceLock = OnceLock::new(); + static CONTINUED: OnceLock = OnceLock::new(); + static CONDITIONAL: OnceLock = OnceLock::new(); + let directive = + DIRECTIVE.get_or_init(|| Regex::new(r"^[ \t]*#[ \t]*((?-u:\w)+)").expect("directive")); + let line_comment = LINE_COMMENT.get_or_init(|| Regex::new(r"//.*$").expect("line comment")); + let block_comment = + BLOCK_COMMENT.get_or_init(|| Regex::new(r"/\*.*?\*/").expect("block comment")); + let label = LABEL.get_or_init(|| Regex::new(r"^(?-u:[\w\s])*:$").expect("label")); + let access = ACCESS.get_or_init(|| { + Regex::new(r"^(?:public|private|protected)\s*:$").expect("access specifier") + }); + let statement_macro = STATEMENT_MACRO + .get_or_init(|| Regex::new(r"^[A-Z][A-Z0-9_]*(?:\s*\(.*\))?$").expect("statement macro")); + let continued = CONTINUED.get_or_init(|| Regex::new(r"\\\s*$").expect("continuation")); + let conditional = CONDITIONAL.get_or_init(|| { + Regex::new(r"^(?:if|ifdef|ifndef|else|elif|elifdef|elifndef|endif)$") + .expect("conditional directive") + }); + let classes = cpp_code_classes(source); + let mut lines: Vec = source.split('\n').map(str::to_string).collect(); + // The byte offset of each line, to read the lexical class of its `#`. + let mut offsets = Vec::with_capacity(lines.len()); + let mut offset = 0usize; + for line in &lines { + offsets.push(offset); + offset += line.len() + 1; + } + // A raw string's line is no directive, as upstream masks raw strings first. + let directive_of = |lines: &[String], index: usize| -> Option { + let line = &lines[index]; + let captures = directive.captures(line)?; + let hash = offsets[index] + line.find('#')?; + (classes.get(hash) != Some(&LITERAL)).then(|| captures[1].to_string()) + }; + let code = |line: &str| -> String { + let without_line = line_comment.replace(line, ""); + block_comment + .replace_all(&without_line, "") + .trim() + .to_string() + }; + let mut changed = false; + // Every byte but `\r` becomes a space, so the line keeps its byte length. + let mut blank = |lines: &mut Vec, index: usize| { + lines[index] = lines[index] + .bytes() + .map(|byte| if byte == b'\r' { '\r' } else { ' ' }) + .collect::(); + changed = true; + }; + let mut last_code = String::new(); + let mut in_block_comment = false; + let mut region = 0usize; + let mut skip_from = 0usize; + let mut in_continuation = false; + for index in 0..lines.len() { + if in_continuation { + in_continuation = continued.is_match(&lines[index]); + continue; + } + let found = directive_of(&lines, index); + if let Some(name) = found.as_deref() + && !in_block_comment + { + if continued.is_match(&lines[index]) { + in_continuation = true; + if region == 0 { + continue; + } + } + let opens = name.starts_with("if"); + if region == 0 { + // Not after a statement macro written without its `;` + // (`RAPIDJSON_DIAG_OFF(padded)`), either. + let mid = opens + && !last_code.is_empty() + && !(last_code.ends_with([';', '{', '}']) + || label.is_match(&last_code) + || last_code.ends_with("*/")) + && !access.is_match(&last_code) + && !statement_macro.is_match(&last_code); + if !mid { + continue; + } + region = 1; + blank(&mut lines, index); + continue; + } + if opens { + region += 1; + } else if (name == "else" || name == "elif") && skip_from == 0 { + skip_from = region; + } else if name == "endif" { + if skip_from == region { + skip_from = 0; + } + region -= 1; + } + if conditional.is_match(name) { + blank(&mut lines, index); + } + continue; + } + let text = lines[index].clone(); + if region > 0 && skip_from > 0 { + if !text.trim().is_empty() { + blank(&mut lines, index); + } + continue; + } + // Track block comments so a `#` or `;` inside one is not read as code. + let trimmed = code(&text); + if in_block_comment { + if text.contains("*/") { + in_block_comment = false; + } + continue; + } + // Some `/*` with no `*/` after it on the line: the last one decides. + let uncommented = line_comment.replace(&text, ""); + if let Some(open) = uncommented.rfind("/*") + && !uncommented[open + 2..].contains("*/") + { + in_block_comment = true; + let before = trimmed.split("/*").next().unwrap_or("").trim().to_string(); + if !before.is_empty() { + last_code = before; + } + continue; + } + if !trimmed.is_empty() { + last_code = trimmed; + } + } + if !changed { + return source.to_string(); + } + lines.join("\n") +} + +// --------------------------------------------------------------------------- +// #1070, #2449: macros in a class head +// --------------------------------------------------------------------------- + +/// Blank the export, visibility and attribute macros between `class` or +/// `struct` and the name of a class it *defines* (upstream #1070, #2449 +/// `blankCppExportMacros`): Unreal's `*_API`, Qt and Boost's `*_EXPORT`, +/// protobuf's `class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED +/// Any final : public Message`, rocksdb's `struct ALIGN_AS(64U) HandleImpl`. +/// tree-sitter reads `class MACRO` as an elaborated type and the rest as a +/// function, so the class, its bases and its members drop out. The whole run +/// is blanked; the last name before the base clause or body is the class, and +/// it may carry template arguments (a specialization). Only a base clause or +/// a body after it counts, so `struct FOO var;` and `class FOO obj = …` stay. +/// The walk-time recovery (`detect_export_macro_class`) stays for what this +/// does not see. +pub(crate) fn blank_cpp_export_macros(source: &str) -> String { + if !source.contains("class") && !source.contains("struct") { + return source.to_string(); + } + static GATE: OnceLock = OnceLock::new(); + let gate = + GATE.get_or_init(|| Regex::new(r"\b(?:class|struct)\s+[A-Z][A-Z0-9_]").expect("gate")); + if !gate.is_match(source) { + return source.to_string(); + } + let code = masked_code(source); + let mut spans = Vec::new(); + let mut at = 0usize; + while at < code.len() { + let keyword = if word_at(&code, at, "class") { + 5 + } else if word_at(&code, at, "struct") { + 6 + } else { + at += 1; + continue; + }; + if at > 0 && is_word(code[at - 1]) { + at += keyword; + continue; + } + let head = at + keyword; + at = head; + let Some((run, name_start)) = class_head_run(&code, head) else { + continue; + }; + let Some(mut name_end) = ident_end(&code, name_start) else { + continue; + }; + // `(?:\s*::\s*[A-Za-z_]\w*)*` + loop { + let colons = skip_space(&code, name_end); + if !code[colons..].starts_with(b"::") { + break; + } + match ident_end(&code, skip_space(&code, colons + 2)) { + Some(end) => name_end = end, + None => break, + } + } + at = name_end; + let mut next = skip_space(&code, name_end); + if code.get(next) == Some(&b'<') { + let Some(after) = skip_angle_args(&code, next) else { + continue; + }; + next = skip_space(&code, after); + } + if word_at(&code, next, "final") { + next = skip_space(&code, next + 5); + } + let opens_definition = code.get(next) == Some(&b'{') + || (code.get(next) == Some(&b':') && code.get(next + 1) != Some(&b':')); + if opens_definition { + for &(start, end) in &run { + push_run_tokens(&mut spans, &code, start, end); + } + } + } + blank_spans(source, &spans) +} + +/// The macro run of a class head (`\s+((?:[A-Z][A-Z0-9_]+(?:\s*\((?:[^()]|\([^()]*\))*\))?\s+)+)` +/// then `(?!final\b)[A-Za-z_]\w*`) after the keyword ending at `head`: the +/// tokens and where the name starts. The run is greedy; when what follows it +/// is no name, the last macro is the name instead, as the regex backtracks. +fn class_head_run(code: &[u8], head: usize) -> Option<(Vec<(usize, usize)>, usize)> { + let mut at = head; + if !code.get(at).copied().is_some_and(is_space) { + return None; + } + at = skip_space(code, at); + let mut tokens: Vec<(usize, usize)> = Vec::new(); + loop { + if !code.get(at).is_some_and(u8::is_ascii_uppercase) { + break; + } + let name_end = macro_run_end(code, at); + if name_end - at < 2 { + break; + } + let open = skip_space(code, name_end); + let token_end = balanced_args_end(code, open, 2).unwrap_or(name_end); + if !code.get(token_end).copied().is_some_and(is_space) { + break; + } + tokens.push((at, token_end)); + at = skip_space(code, token_end); + } + let names_class = |at: usize| ident_end(code, at).is_some() && !word_at(code, at, "final"); + if tokens.is_empty() { + return None; + } + if names_class(at) { + return Some((tokens, at)); + } + if tokens.len() >= 2 { + let last = tokens.pop().expect("two tokens"); + return Some((tokens, last.0)); + } + None +} + +// --------------------------------------------------------------------------- +// #1100: inline-specifier macros +// --------------------------------------------------------------------------- + +/// The inline-specifier macros upstream knows (`CPP_INLINE_MACROS`). +const CPP_INLINE_MACROS: [&str; 60] = [ + "FORCEINLINE_DEBUGGABLE", + "FORCENOINLINE", + "FORCEINLINE", + "PUGI__FN_NO_INLINE", + "PUGI__FN", + "PUGIXML_FUNCTION", + "_ALWAYS_INLINE_", + "_FORCE_INLINE_", + "BOOST_FORCEINLINE", + "BOOST_NOINLINE", + "Q_INVOKABLE", + "Q_SCRIPTABLE", + "Q_ALWAYS_INLINE", + "Q_SLOT", + "Q_SIGNAL", + "FOLLY_ALWAYS_INLINE", + "FOLLY_NOINLINE", + "ABSL_ATTRIBUTE_ALWAYS_INLINE", + "ABSL_ATTRIBUTE_NOINLINE", + "LLVM_ATTRIBUTE_ALWAYS_INLINE", + "LLVM_ATTRIBUTE_NOINLINE", + "V8_INLINE", + "V8_NOINLINE", + "EIGEN_STRONG_INLINE", + "EIGEN_ALWAYS_INLINE", + "EIGEN_DEVICE_FUNC", + "RAPIDJSON_FORCEINLINE", + "MOZ_ALWAYS_INLINE", + "MOZ_NEVER_INLINE", + "PROTOBUF_ALWAYS_INLINE", + "PROTOBUF_NOINLINE", + "FMT_CONSTEXPR20", + "FMT_CONSTEXPR", + "FMT_INLINE", + "JSON_HEDLEY_ALWAYS_INLINE", + "JSON_HEDLEY_NEVER_INLINE", + "HEDLEY_ALWAYS_INLINE", + "HEDLEY_NEVER_INLINE", + "GLM_FUNC_QUALIFIER", + "GLM_FUNC_DECL", + "GLM_CONSTEXPR", + "GLM_INLINE", + "SIMD_FORCE_INLINE", + "SK_ALWAYS_INLINE", + "CV_ALWAYS_INLINE", + "CV_INLINE", + "EA_FORCE_INLINE", + "EA_NOINLINE", + "CC_INLINE", + "NEVER_INLINE", + "G_INLINE_FUNC", + "SQLITE_PRIVATE", + "SQLITE_API", + "STDMETHODCALLTYPE", + "WINAPIV", + "WINAPI", + "APIENTRY", + "ALWAYS_INLINE", + "FORCE_INLINE", + "NOINLINE", +]; + +/// Blank a known inline-specifier macro in specifier position, before the +/// return type or name it qualifies (upstream #1100 `blankCppInlineMacros`): +/// `FORCEINLINE FString GetName(…)` glues the return type onto the name +/// otherwise. Only the curated tokens, as whole words followed by whitespace +/// and an identifier, so `HRESULT DoIt()` and `x = FORCEINLINE ? …` stay. +/// Matched on the source as upstream does, but never inside a literal +/// (KEEP-RUST: upstream only spares raw strings, and a token blanked inside an +/// ordinary string changes its text); the walk-time name recovery stays for +/// macros not on the list. +pub(crate) fn blank_cpp_inline_macros(source: &str) -> String { + if !CPP_INLINE_MACROS + .iter() + .any(|macro_name| source.contains(macro_name)) + { + return source.to_string(); + } + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + let mut names = CPP_INLINE_MACROS.to_vec(); + names.sort_by_key(|name| std::cmp::Reverse(name.len())); + Regex::new(&format!(r"\b(?:{})\b", names.join("|"))).expect("inline macros") + }); + let bytes = source.as_bytes(); + let classes = cpp_code_classes(source); + let spans: Vec<(usize, usize)> = re + .find_iter(source) + .filter(|found| { + let after = found.end(); + let first = skip_space(bytes, after); + classes[found.start()] != LITERAL + && first > after + && bytes.get(first).copied().is_some_and(is_ident_start) + }) + .map(|found| (found.start(), found.end())) + .collect(); + blank_spans(source, &spans) +} + +// --------------------------------------------------------------------------- +// #2449: attribute macros around declarators +// --------------------------------------------------------------------------- + +/// The alternative operator spellings, which never name a declaration. +const CPP_OPERATOR_WORDS: [&str; 11] = [ + "and", "and_eq", "bitand", "bitor", "compl", "not", "not_eq", "or", "or_eq", "xor", "xor_eq", +]; + +/// Blank attribute macros between a pointer or reference and the name it +/// declares (upstream #2449 `blankCppPointerAnnotationMacros`): protobuf's +/// `const Descriptor* PROTOBUF_NONNULL descriptor()`, `Any* PROTOBUF_RESTRICT +/// PROTOBUF_NONNULL other`. tree-sitter takes the macro for the declared name. +/// The macro is ALL-CAPS with an underscore, right after `*` or `&`, and +/// followed by the name (or, after `*`, another `*`): a product (`a * MAX_LEN +/// + 1`) or a bit test (`flags & FOO_BIT)`) is followed by an operator or a +/// closer instead, and a `*` after `)`, `]` or a number multiplies. +pub(crate) fn blank_cpp_pointer_annotation_macros(source: &str) -> String { + static GATE: OnceLock = OnceLock::new(); + let gate = GATE.get_or_init(|| Regex::new(r"[*&]\s*[A-Z][A-Z0-9]*_").expect("gate")); + if !gate.is_match(source) { + return source.to_string(); + } + let code = masked_code(source); + let mut spans = Vec::new(); + let mut at = 0usize; + while at < code.len() { + if !matches!(code[at], b'*' | b'&') { + at += 1; + continue; + } + let operator = code[at]; + let Some((run_start, run_end)) = pointer_annotation_run(&code, at) else { + at += 1; + continue; + }; + let next_at = run_end; + // After `&`, only a name: `flags & FOO_BIT\n && ok` is an expression. + let reference_then_star = operator == b'&' && code.get(run_end) == Some(&b'*'); + if !reference_then_star && !multiplies(&code, at) { + push_run_tokens(&mut spans, &code, run_start, run_end); + } + at = next_at; + } + blank_spans(source, &spans) +} + +/// `([*&])\s*((?:[A-Z][A-Z0-9]*_[A-Z0-9_]*(?:\s+|(?=\*)))+)` followed by +/// `(?=\*|::|(?!\b)[A-Za-z_~])` at `code[at]`: the run's span. +/// When what follows the greedy run is none of those, the regex gives back +/// the last macro, after which an uppercase letter always is. +fn pointer_annotation_run(code: &[u8], at: usize) -> Option<(usize, usize)> { + let start = skip_space(code, at + 1); + let mut ends: Vec = Vec::new(); + let mut cursor = start; + while code.get(cursor).is_some_and(u8::is_ascii_uppercase) { + let end = macro_run_end(code, cursor); + if !is_underscored_macro(code, cursor, end) { + break; + } + let after = skip_space(code, end); + if after == end && code.get(end) != Some(&b'*') { + break; + } + ends.push(after); + cursor = after; + } + let follows = |position: usize| -> bool { + match code.get(position) { + Some(b'*') => true, + Some(b':') => code.get(position + 1) == Some(&b':'), + Some(&byte) if is_ident_start(byte) || byte == b'~' => !CPP_OPERATOR_WORDS + .iter() + .any(|word| word_at(code, position, word)), + _ => false, + } + }; + let last = *ends.last()?; + if follows(last) { + return Some((start, last)); + } + (ends.len() >= 2).then(|| (start, ends[ends.len() - 2])) +} + +/// Whether the `*` at `code[at]` multiplies: a closing bracket or a number +/// stands before it (`(?:[)\]]|\b\d[\w.]*)\s*$` over the 40 bytes before). +fn multiplies(code: &[u8], at: usize) -> bool { + let window_start = at.saturating_sub(40); + let mut end = at; + while end > window_start && is_space(code[end - 1]) { + end -= 1; + } + if end == window_start { + return false; + } + let last = code[end - 1]; + if matches!(last, b')' | b']') { + return true; + } + // `\b\d[\w.]*` ending at `end`: walk back over `[\w.]`, then find a digit + // that starts a word. + let mut start = end; + while start > window_start && (is_word(code[start - 1]) || code[start - 1] == b'.') { + start -= 1; + } + (start..end).any(|digit| { + code[digit].is_ascii_digit() + && (digit == window_start || !is_word(code[digit - 1])) + && code[digit + 1..end] + .iter() + .all(|&byte| is_word(byte) || byte == b'.') + }) +} + +/// Keywords that take a parenthesized operand: a `)` closing one of these is +/// no parameter list (upstream `CPP_PAREN_KEYWORDS`). +const CPP_PAREN_KEYWORDS: [&str; 39] = [ + "if", + "while", + "for", + "switch", + "catch", + "return", + "sizeof", + "alignof", + "alignas", + "decltype", + "typeid", + "noexcept", + "throw", + "static_assert", + "new", + "delete", + "case", + "requires", + "co_await", + "co_yield", + "co_return", + "asm", + "__asm__", + "__attribute__", + "__attribute", + "__declspec", + "_Pragma", + "else", + "do", + "constexpr", + "consteval", + "explicit", + "typeof", + "__typeof__", + "__typeof", + "_Alignof", + "__alignof__", + "volatile", + "__volatile__", +]; + +fn is_paren_keyword(word: &str) -> bool { + CPP_PAREN_KEYWORDS.contains(&word) +} + +/// Type keywords that may stand right before a declared name. +const CPP_TYPE_KEYWORDS: [&str; 15] = [ + "void", "bool", "char", "char8_t", "char16_t", "char32_t", "wchar_t", "short", "int", "long", + "float", "double", "signed", "unsigned", "auto", +]; + +/// Every keyword a declared name can never be (upstream `CPP_KEYWORDS`). +fn is_cpp_keyword(word: &str) -> bool { + const OTHERS: [&str; 46] = [ + "const", + "volatile", + "static", + "extern", + "inline", + "virtual", + "explicit", + "constexpr", + "consteval", + "constinit", + "friend", + "mutable", + "thread_local", + "register", + "typedef", + "using", + "template", + "typename", + "class", + "struct", + "union", + "enum", + "namespace", + "operator", + "goto", + "break", + "continue", + "default", + "public", + "private", + "protected", + "this", + "true", + "false", + "nullptr", + "and", + "or", + "not", + "xor", + "bitand", + "bitor", + "compl", + "and_eq", + "or_eq", + "xor_eq", + "not_eq", + ]; + CPP_TYPE_KEYWORDS.contains(&word) || is_paren_keyword(word) || OTHERS.contains(&word) +} + +/// Blank attribute macros after a function's parameter list (upstream #2449 +/// `blankCppTrailingAttributeMacros`): protobuf's `unknown_fields() const\n +/// ABSL_ATTRIBUTE_LIFETIME_BOUND {`, `~Any() PROTOBUF_FINAL;`, the +/// thread-safety annotations of leveldb, rocksdb and abseil (`void Wait() +/// LOCKS_EXCLUDED(mu_) {`). tree-sitter reads the macro as the start of a new +/// declaration, so the method loses its `;` or its body. The `)` must close a +/// parameter list (its `(` follows a function name, not a keyword, a cast or +/// a macro call); between it and the macro only `const`, `volatile`, `&`, +/// `&&`, `noexcept`, `override` or `final` may stand, and no blank line; the +/// macro (ALL-CAPS, arguments allowed, several may stack) is followed by what +/// may end a declarator. +pub(crate) fn blank_cpp_trailing_attribute_macros(source: &str) -> String { + static GATE: OnceLock = OnceLock::new(); + let gate = GATE.get_or_init(|| { + Regex::new(r"\)[\s\w&]*?\b[A-Z][A-Z0-9_]{2,}\b").expect("trailing-attribute gate") + }); + if !gate.is_match(source) { + return source.to_string(); + } + let code = masked_code(source); + let mut spans = Vec::new(); + let mut at = 0usize; + while at < code.len() { + if code[at] != b')' { + at += 1; + continue; + } + let close = at; + let macro_start = trailing_qualifiers_end(&code, close + 1); + at = close + 1; + if !is_long_macro_word(&code, macro_start) { + continue; + } + if has_blank_line(&code[close..macro_start]) { + continue; + } + let run_end = trailing_macro_run_end(&code, macro_start); + if !ends_declarator(&code, run_end) || !closes_parameter_list(&code, close) { + continue; + } + push_run_tokens(&mut spans, &code, macro_start, run_end); + at = run_end; + } + blank_spans(source, &spans) +} + +/// `((?:\s*(?:const|volatile|noexcept|override|final|&&|&)(?!\w))*)\s*` from +/// `at`: where the macro would start. +fn trailing_qualifiers_end(code: &[u8], mut at: usize) -> usize { + loop { + let word_start = skip_space(code, at); + let qualifier = ["const", "volatile", "noexcept", "override", "final"] + .iter() + .find(|word| word_at(code, word_start, word)) + .map(|word| word.len()) + .or_else(|| { + if code[word_start..].starts_with(b"&&") { + Some(2) + } else if code.get(word_start) == Some(&b'&') { + Some(1) + } else { + None + } + }); + match qualifier { + Some(length) if !code.get(word_start + length).copied().is_some_and(is_word) => { + at = word_start + length; + } + _ => return skip_space(code, at), + } + } +} + +/// `[A-Z][A-Z0-9_]{2,}\b` at `at`. +fn is_long_macro_word(code: &[u8], at: usize) -> bool { + if !code.get(at).is_some_and(u8::is_ascii_uppercase) { + return false; + } + let end = macro_run_end(code, at); + end - at >= 3 && !code.get(end).copied().is_some_and(is_word) +} + +/// `\n[ \t]*\r?\n` in `text`. +fn has_blank_line(text: &[u8]) -> bool { + text.iter().enumerate().any(|(at, &byte)| { + if byte != b'\n' { + return false; + } + let mut next = at + 1; + while next < text.len() && matches!(text[next], b' ' | b'\t') { + next += 1; + } + if text.get(next) == Some(&b'\r') { + next += 1; + } + text.get(next) == Some(&b'\n') + }) +} + +/// `(?:[A-Z][A-Z0-9_]{2,}\b(?:\s*\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))?\s*)+` +/// from `at`, which [`is_long_macro_word`]: the run's end, trailing space +/// included. +fn trailing_macro_run_end(code: &[u8], at: usize) -> usize { + let mut end = at; + while is_long_macro_word(code, end) { + let name_end = macro_run_end(code, end); + let open = skip_space(code, name_end); + let token_end = balanced_args_end(code, open, 3).unwrap_or(name_end); + end = skip_space(code, token_end); + } + end +} + +/// `(?:\{|;|=(?!=)|:(?!:)|->|(?:override|final|const|noexcept)\b)` at `at`. +fn ends_declarator(code: &[u8], at: usize) -> bool { + match code.get(at) { + Some(b'{' | b';') => true, + Some(b'=') => code.get(at + 1) != Some(&b'='), + Some(b':') => code.get(at + 1) != Some(&b':'), + Some(b'-') => code.get(at + 1) == Some(&b'>'), + _ => ["override", "final", "const", "noexcept"] + .iter() + .any(|word| word_at(code, at, word)), + } +} + +/// Whether the `)` at `code[close]` closes a function declarator's parameter +/// list (upstream `closesParameterList`): its `(` follows `operator…` or a +/// function name that is no keyword and no ALL-CAPS macro. +fn closes_parameter_list(code: &[u8], close: usize) -> bool { + let mut depth = 0i32; + let mut open = None; + let mut at = close as isize; + while at >= 0 && close - at as usize <= 4000 { + match code[at as usize] { + b')' => depth += 1, + b'(' => { + depth -= 1; + if depth == 0 { + open = Some(at as usize); + break; + } + } + b';' | b'{' | b'}' => return false, + _ => {} + } + at -= 1; + } + let Some(open) = open else { + return false; + }; + let before = &code[open.saturating_sub(200)..open]; + if operator_before(before) { + return true; + } + let mut end = before.len(); + while end > 0 && is_space(before[end - 1]) { + end -= 1; + } + let mut start = end; + while start > 0 && is_word(before[start - 1]) { + start -= 1; + } + // `([A-Za-z_]\w*)\s*$`: the identifier must start with a letter or `_`. + while start < end && before[start].is_ascii_digit() { + start += 1; + } + if start == end { + return false; + } + let name = std::str::from_utf8(&before[start..end]).unwrap_or(""); + // `^[A-Z][A-Z0-9_]*$`: a macro call, not a function. + let macro_call = name.as_bytes()[0].is_ascii_uppercase() && name.bytes().all(is_macro_char); + !is_paren_keyword(name) && !macro_call +} + +/// `\boperator\s*(?:\(\s*\)|\[\s*\]|[^\s\w()[\]]{1,3}|\s+[A-Za-z_][\w\s:<>*&]*)\s*$` +/// over `before`. +fn operator_before(before: &[u8]) -> bool { + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + regex::bytes::Regex::new( + r"(?-u)\boperator\s*(?:\(\s*\)|\[\s*\]|[^\s\w()\[\]]{1,3}|\s+[A-Za-z_][\w\s:<>*&]*)\s*$", + ) + .expect("operator before") + }); + re.is_match(before) +} + +/// Blank attribute macros after the name a declaration declares, or after its +/// array brackets (upstream #2449 `blankCppDeclaratorAttributeMacros`): +/// leveldb's `port::CondVar cv GUARDED_BY(mu);`, abseil's `ABSL_GUARDED_BY`, +/// `const absl::Cord* cord ABSL_ATTRIBUTE_LIFETIME_BOUND)`, protobuf's +/// `Any_globals_ PROTOBUF_MESSAGE_GLOBALS_SECTION(.data.rel.ro);` and +/// `offsets[] ABSL_ATTRIBUTE_SECTION_VARIABLE(protodesc_cold) = {`. The macro +/// is ALL-CAPS with an underscore, arguments allowed, followed by `;`, `=`, +/// `,` or `)`; before it stands `]` or a name with a lowercase letter that +/// itself follows a type. `Foo DEFAULT_OPTIONS;`, `typedef Foo BAR_T;` and +/// `return x FOO;` stay. +pub(crate) fn blank_cpp_declarator_attribute_macros(source: &str) -> String { + static GATE: OnceLock = OnceLock::new(); + let gate = GATE.get_or_init(|| { + Regex::new(r"[\w\]]\s+[A-Z][A-Z0-9]*_[A-Z0-9_]*\s*[(;=,)]").expect("declarator gate") + }); + if !gate.is_match(source) { + return source.to_string(); + } + let code = masked_code(source); + let mut spans = Vec::new(); + let mut at = 0usize; + while at < code.len() { + // `(? 0 && (is_word(code[at - 1]) || code[at - 1] == b'$')) + { + at += 1; + continue; + } + let macro_start = at; + let token_end = macro_run_end(&code, at); + at = token_end; + if !is_underscored_macro(&code, macro_start, token_end) + || code.get(token_end).copied().is_some_and(is_word) + { + continue; + } + let mut last = macro_start; + while last > 0 && is_space(code[last - 1]) { + last -= 1; + } + if last == macro_start || last == 0 { + continue; + } + let last = last - 1; + if !(is_word(code[last]) || code[last] == b']') { + continue; + } + let run_end = declarator_macro_run_end(&code, macro_start); + let ends = match code.get(run_end) { + Some(b';' | b',' | b')') => true, + Some(b'=') => code.get(run_end + 1) != Some(&b'='), + _ => false, + }; + if !ends { + continue; + } + if code[last] != b']' && !follows_declared_name(&code, last) { + continue; + } + push_run_tokens(&mut spans, &code, macro_start, run_end); + at = run_end; + } + blank_spans(source, &spans) +} + +/// `(?:[A-Z][A-Z0-9]*_[A-Z0-9_]*\b(?:\s*\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))?\s*)+` +/// from `at`: the run's end, trailing space included. +fn declarator_macro_run_end(code: &[u8], at: usize) -> usize { + let mut end = at; + loop { + if !code.get(end).is_some_and(u8::is_ascii_uppercase) { + return end; + } + let name_end = macro_run_end(code, end); + if !is_underscored_macro(code, end, name_end) + || code.get(name_end).copied().is_some_and(is_word) + { + return end; + } + let open = skip_space(code, name_end); + let token_end = balanced_args_end(code, open, 3).unwrap_or(name_end); + end = skip_space(code, token_end); + } +} + +/// Whether the identifier ending at `code[last]` is a name a declaration +/// declares (upstream `followsDeclaredName`). +fn follows_declared_name(code: &[u8], last: usize) -> bool { + let mut start = last; + while start > 0 && is_word(code[start - 1]) { + start -= 1; + } + let name = &code[start..=last]; + if !name.first().copied().is_some_and(is_ident_start) + || !name.iter().any(u8::is_ascii_lowercase) + || is_cpp_keyword(std::str::from_utf8(name).unwrap_or("")) + { + return false; + } + let mut at = start as isize - 1; + while at >= 0 && is_space(code[at as usize]) { + at -= 1; + } + if at < 0 { + return false; + } + let at = at as usize; + match code[at] { + b'*' | b'&' => return true, + b'>' => return at == 0 || code[at - 1] != b'-', + byte if !is_word(byte) => return false, + _ => {} + } + let mut word_start = at; + while word_start > 0 && is_word(code[word_start - 1]) { + word_start -= 1; + } + let before = std::str::from_utf8(&code[word_start..=at]).unwrap_or(""); + if before == "const" || before == "volatile" { + let mut k = word_start as isize - 1; + while k >= 0 && is_space(code[k as usize]) { + k -= 1; + } + return k >= 0 && matches!(code[k as usize], b'*' | b'&'); + } + if CPP_TYPE_KEYWORDS.contains(&before) { + return true; + } + before + .as_bytes() + .first() + .copied() + .is_some_and(is_ident_start) + && !is_cpp_keyword(before) +} + +/// Blank attribute macros that open a declaration, at the start of a line or +/// after a `[[…]]` attribute (upstream #2449 `blankCppLeadingAttributeMacros`): +/// protobuf's `PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() +/// const {`, `ABSL_ATTRIBUTE_REINITIALIZES void Clear()`, `[[nodiscard]] +/// PROTOBUF_NDEBUG_INLINE Ptr Make(…)`, googletest's `GTEST_API_ +/// std::string JoinAsTuple(…)`, {fmt}'s `FMT_EXPORT struct …`. tree-sitter +/// takes the macro for the type, which swallows the members after it. The +/// macro (ALL-CAPS with an underscore; several may stack) must be followed by +/// what cannot follow a type name: a declaration keyword, `[[`, an +/// out-of-line constructor or destructor, or a type and then the declared +/// name. A type is followed by the name alone (`DWORD_PTR value = 0;`, +/// `HANDLE_T Open(…)`), so it never matches. +pub(crate) fn blank_cpp_leading_attribute_macros(source: &str) -> String { + static GATE: OnceLock = OnceLock::new(); + let gate = GATE.get_or_init(|| { + Regex::new(r"(?m)(?:^|\]\])[ \t]*[A-Z][A-Z0-9]*_[A-Z0-9_]*\s").expect("leading gate") + }); + if !gate.is_match(source) { + return source.to_string(); + } + let code = masked_code(source); + let skip_blanks = |mut at: usize| { + while at < code.len() && matches!(code[at], b' ' | b'\t') { + at += 1; + } + at + }; + let mut spans = Vec::new(); + let mut at = 0usize; + while at < code.len() { + // `(^[ \t]*|\]\][ \t]*)`: the first alternative, then the second. + let mut matched = None; + if at == 0 || matches!(code[at - 1], b'\n' | b'\r') { + let run_start = skip_blanks(at); + matched = leading_attribute_run(&code, run_start).map(|end| (run_start, end)); + } + if matched.is_none() && code[at..].starts_with(b"]]") { + let run_start = skip_blanks(at + 2); + matched = leading_attribute_run(&code, run_start).map(|end| (run_start, end)); + } + match matched { + Some((run_start, run_end)) => { + push_run_tokens(&mut spans, &code, run_start, run_end); + at = run_end; + } + None => at += 1, + } + } + blank_spans(source, &spans) +} + +/// The macro run `((?:[A-Z][A-Z0-9]*_[A-Z0-9_]*\s+)+)` at `start` and the +/// look-ahead after it: the run's end. The run is greedy and gives back its +/// last macros until the look-ahead holds, as the regex backtracks. +fn leading_attribute_run(code: &[u8], start: usize) -> Option { + let mut ends = Vec::new(); + let mut at = start; + while code.get(at).is_some_and(u8::is_ascii_uppercase) { + let end = macro_run_end(code, at); + if !is_underscored_macro(code, at, end) || !code.get(end).copied().is_some_and(is_space) { + break; + } + at = skip_space(code, end); + ends.push(at); + } + ends.into_iter() + .rev() + .find(|&end| opens_declaration(code, end)) +} + +/// The declaration keywords after which a macro run opens a declaration. +const LEADING_KEYWORDS: [&str; 36] = [ + "static", + "inline", + "virtual", + "explicit", + "constexpr", + "consteval", + "constinit", + "friend", + "extern", + "thread_local", + "mutable", + "typedef", + "using", + "template", + "typename", + "class", + "struct", + "union", + "enum", + "void", + "bool", + "char", + "char8_t", + "char16_t", + "char32_t", + "wchar_t", + "short", + "int", + "long", + "float", + "double", + "signed", + "unsigned", + "auto", + "decltype", + "return", +]; + +/// The look-ahead of upstream's `CPP_LEADING_ATTR_RE` at `at`: a declaration +/// keyword, `[[`, an out-of-line constructor or destructor (`Any::Impl_::Impl_(`, +/// `Any::~Any(`), or a type and then the name it declares. +fn opens_declaration(code: &[u8], at: usize) -> bool { + LEADING_KEYWORDS + .iter() + .any(|keyword| word_at(code, at, keyword)) + || code[at..].starts_with(b"[[") + || out_of_line_constructor(code, at) + || type_then_declared_name(code, at) +} + +/// `(?:[A-Za-z_]\w*\s*::\s*)*([A-Za-z_]\w*)\s*::\s*~?\3\s*\(` at `at`. +fn out_of_line_constructor(code: &[u8], at: usize) -> bool { + // The `::`-separated segments, each with whether a `~` led it. + let mut segments: Vec<(&[u8], bool)> = Vec::new(); + let mut cursor = at; + loop { + let tilde = !segments.is_empty() && code.get(cursor) == Some(&b'~'); + let name_start = if tilde { cursor + 1 } else { cursor }; + let Some(name_end) = ident_end(code, name_start) else { + break; + }; + segments.push((&code[name_start..name_end], tilde)); + let colons = skip_space(code, name_end); + if !code[colons..].starts_with(b"::") { + cursor = name_end; + break; + } + cursor = skip_space(code, colons + 2); + } + if segments.len() < 2 + || segments[..segments.len() - 1] + .iter() + .any(|(_, tilde)| *tilde) + { + return false; + } + let (last, _) = segments[segments.len() - 1]; + let (class, _) = segments[segments.len() - 2]; + last == class && code.get(skip_space(code, cursor)) == Some(&b'(') +} + +/// `<(?:[^<>;{}]|<(?:[^<>;{}]|<[^<>;{}]*>)*>)*>` at `at`: the end of template +/// arguments nesting at most three levels. +fn template_args_end(code: &[u8], at: usize) -> Option { + if code.get(at) != Some(&b'<') { + return None; + } + let mut depth = 0usize; + for (index, &byte) in code.iter().enumerate().skip(at) { + match byte { + b'<' => { + depth += 1; + if depth > 3 { + return None; + } + } + b'>' => { + depth -= 1; + if depth == 0 { + return Some(index + 1); + } + } + b';' | b'{' | b'}' => return None, + _ => {} + } + } + None +} + +/// The ends a `CPP_TYPE_NAME` starting at `at` can have, longest first: +/// `(?:::\s*)?(?!(?:const|volatile|operator|return)\b)[A-Za-z_]\w*(?:\s*<…>)?` +/// then `(?:\s*::\s*[A-Za-z_]\w*(?:\s*<…>)?)*`. +fn type_name_ends(code: &[u8], at: usize) -> Vec { + let mut start = at; + if code[start..].starts_with(b"::") { + start = skip_space(code, start + 2); + } + if ["const", "volatile", "operator", "return"] + .iter() + .any(|word| word_at(code, start, word)) + { + return Vec::new(); + } + let Some(mut end) = ident_end(code, start) else { + return Vec::new(); + }; + let with_args = |end: usize| -> usize { + let open = skip_space(code, end); + template_args_end(code, open).unwrap_or(end) + }; + end = with_args(end); + let mut ends = vec![end]; + loop { + let colons = skip_space(code, end); + if !code[colons..].starts_with(b"::") { + break; + } + let Some(segment_end) = ident_end(code, skip_space(code, colons + 2)) else { + break; + }; + end = with_args(segment_end); + ends.push(end); + } + ends.reverse(); + ends +} + +/// `CPP_DECLARED_NAME` at `at`: +/// `(?:(?:::\s*)?~?[A-Za-z_]\w*(?:\s*::\s*~?[A-Za-z_]\w*)*\s*[(;=[{]|operator\b)`. +fn declared_name(code: &[u8], at: usize) -> bool { + if word_at(code, at, "operator") { + return true; + } + let mut cursor = at; + if code[cursor..].starts_with(b"::") { + cursor = skip_space(code, cursor + 2); + } + if code.get(cursor) == Some(&b'~') { + cursor += 1; + } + let Some(mut end) = ident_end(code, cursor) else { + return false; + }; + loop { + let colons = skip_space(code, end); + if !code[colons..].starts_with(b"::") { + break; + } + let mut segment = skip_space(code, colons + 2); + if code.get(segment) == Some(&b'~') { + segment += 1; + } + match ident_end(code, segment) { + Some(segment_end) => end = segment_end, + None => break, + } + } + matches!( + code.get(skip_space(code, end)), + Some(b'(' | b';' | b'=' | b'[' | b'{') + ) +} + +/// `const` or `volatile` with a word boundary at `at`: its length. +fn cv_word(code: &[u8], at: usize) -> Option { + ["const", "volatile"] + .iter() + .find(|word| word_at(code, at, word)) + .map(|word| word.len()) +} + +/// The fourth look-ahead alternative: +/// `(?:(?:const|volatile)\s+)*TYPE(?:\s*(?:const|volatile)\b)?(?:\s*[*&]+\s*|\s+)(?:(?:const|volatile)\s+)?NAME`. +fn type_then_declared_name(code: &[u8], at: usize) -> bool { + // Leading cv-qualifiers, each followed by space. A type never starts with + // one, so taking fewer of them cannot help. + let mut start = at; + while let Some(length) = cv_word(code, start) { + let after = start + length; + if !code.get(after).copied().is_some_and(is_space) { + break; + } + start = skip_space(code, after); + } + for type_end in type_name_ends(code, start) { + // `(?:\s*(?:const|volatile)\b)?`, with and without. + let mut after_types = vec![]; + let qualifier = skip_space(code, type_end); + if let Some(length) = cv_word(code, qualifier) { + after_types.push(qualifier + length); + } + after_types.push(type_end); + for after_type in after_types { + // `(?:\s*[*&]+\s*|\s+)` + let mut separators = vec![]; + let stars = skip_space(code, after_type); + if matches!(code.get(stars), Some(b'*' | b'&')) { + let mut end = stars; + while matches!(code.get(end), Some(b'*' | b'&')) { + end += 1; + } + separators.push(skip_space(code, end)); + } + if code.get(after_type).copied().is_some_and(is_space) { + separators.push(skip_space(code, after_type)); + } + for name_at in separators { + // `(?:(?:const|volatile)\s+)?`, with and without. + if let Some(length) = cv_word(code, name_at) + && code.get(name_at + length).copied().is_some_and(is_space) + && declared_name(code, skip_space(code, name_at + length)) + { + return true; + } + if declared_name(code, name_at) { + return true; + } + } + } + } + false +} + +// --------------------------------------------------------------------------- +// #1346: lone macro lines and directive lines +// --------------------------------------------------------------------------- + +/// Blank a macro that is the only token on its line, no parentheses, no +/// semicolon (upstream #1346 `blankLoneMacroLines`): {fmt}'s +/// `FMT_BEGIN_NAMESPACE`, Qt's `Q_OBJECT`. A bare identifier is no statement +/// or declaration, so tree-sitter drops into error recovery at each one. The +/// line holds one ALL-CAPS token (four characters or more, with an +/// underscore) and at most a comment; the previous line of code does not end +/// in a continuation (`=`, an operator, `,`, `(`, `?`, `:`, a `\`), a template +/// head aside; the next line of code starts like a declaration or scope +/// (letter, `_`, `#`, `{`, `}`, `~`) or the file ends. C++ only here: the C +/// pre-parse stays as it was (KEEP-RUST). +pub(crate) fn blank_lone_macro_lines(source: &str) -> String { + static GATE: OnceLock = OnceLock::new(); + static LINE: OnceLock = OnceLock::new(); + static CONTINUES: OnceLock = OnceLock::new(); + static TEMPLATE: OnceLock = OnceLock::new(); + static PREVIOUS_COMMENTS: OnceLock = OnceLock::new(); + let gate = GATE.get_or_init(|| { + Regex::new(r"(?m)^[ \t]*[A-Z][A-Z0-9_]{3,}[ \t]*\r?$").expect("lone macro gate") + }); + if !gate.is_match(source) { + return source.to_string(); + } + let line_re = LINE.get_or_init(|| { + Regex::new(r"^[ \t]*([A-Z][A-Z0-9_]{3,})[ \t]*(?://[^\n\r]*|/\*[^\n\r]*\*/[ \t]*)?\r?$") + .expect("lone macro line") + }); + let continues = + CONTINUES.get_or_init(|| Regex::new(r"[=+\-*/%&|^<>?:,(\\]$").expect("continuation end")); + let template = TEMPLATE.get_or_init(|| Regex::new(r"^template\s*<").expect("template head")); + let previous_comments = PREVIOUS_COMMENTS + .get_or_init(|| Regex::new(r"/\*.*?\*/|//.*$").expect("previous comments")); + let classes = cpp_code_classes(source); + let mut lines: Vec = source.split('\n').map(str::to_string).collect(); + let mut offsets = Vec::with_capacity(lines.len()); + let mut offset = 0usize; + for line in &lines { + offsets.push(offset); + offset += line.len() + 1; + } + let content = |line: &str| line.strip_suffix('\r').unwrap_or(line).trim().to_string(); + let mut changed = false; + for index in 0..lines.len() { + let Some(captures) = line_re.captures(&lines[index]) else { + continue; + }; + let token = captures.get(1).expect("token group"); + // A raw string's line is no code, as upstream masks raw strings. + if !token.as_str().contains('_') + || classes.get(offsets[index] + token.start()) == Some(&LITERAL) + { + continue; + } + let mut previous = index as isize - 1; + while previous >= 0 && content(&lines[previous as usize]).is_empty() { + previous -= 1; + } + let previous_text = if previous >= 0 { + content(&lines[previous as usize]) + } else { + String::new() + }; + let previous_code = if previous_text.starts_with("//") + || previous_text.starts_with("/*") + || previous_text.starts_with('*') + { + String::new() + } else { + previous_comments + .replace_all(&previous_text, "") + .trim() + .to_string() + }; + if !previous_code.is_empty() + && !template.is_match(&previous_code) + && continues.is_match(&previous_code) + { + continue; + } + if let Some(first) = first_code_char_after(&lines, index + 1) + && !(first.is_ascii_alphabetic() || matches!(first, '_' | '#' | '{' | '}' | '~')) + { + continue; + } + let (start, end) = (token.start(), token.end()); + let line = &mut lines[index]; + line.replace_range(start..end, &" ".repeat(end - start)); + changed = true; + } + if !changed { + return source.to_string(); + } + lines.join("\n") +} + +/// The first character of code at or after line `from`, past blank lines +/// and comments (upstream `firstCodeCharAfter`). +fn first_code_char_after(lines: &[String], from: usize) -> Option { + let mut in_block = false; + for line in lines.iter().skip(from) { + let mut text = line.trim().to_string(); + loop { + if in_block { + match text.find("*/") { + None => { + text.clear(); + break; + } + Some(end) => { + text = text[end + 2..].trim().to_string(); + in_block = false; + } + } + } + if text.starts_with("//") { + text.clear(); + } else if let Some(rest) = text.strip_prefix("/*") { + in_block = true; + text = rest.to_string(); + continue; + } + break; + } + if let Some(first) = text.chars().next() { + return Some(first); + } + } + None +} + +/// Copy the directive lines of `original` (and their `\` continuations) back +/// over `blanked` (upstream #1346 `restoreDirectiveLines`): tree-sitter keeps +/// a `#define` body as raw text it does not parse, so blanking there only +/// breaks the directive. A raw string's line is no directive. +pub(crate) fn restore_directive_lines(original: &str, blanked: &str) -> String { + if blanked == original || !original.contains('#') { + return blanked.to_string(); + } + static DIRECTIVE: OnceLock = OnceLock::new(); + static CONTINUED: OnceLock = OnceLock::new(); + let directive = DIRECTIVE.get_or_init(|| Regex::new(r"^[ \t]*#").expect("directive line")); + let continued = CONTINUED.get_or_init(|| Regex::new(r"\\\s*$").expect("continuation")); + let classes = cpp_code_classes(original); + let original_lines: Vec<&str> = original.split('\n').collect(); + let mut lines: Vec = blanked.split('\n').map(str::to_string).collect(); + let mut changed = false; + let mut continuation = false; + let mut offset = 0usize; + for (index, line) in original_lines.iter().enumerate() { + if index >= lines.len() { + break; + } + let hash = line.find('#').map(|at| offset + at); + let is_directive = continuation + || (directive.is_match(line) && hash.and_then(|at| classes.get(at)) != Some(&LITERAL)); + if is_directive && lines[index] != *line { + lines[index] = (*line).to_string(); + changed = true; + } + continuation = is_directive && continued.is_match(line.strip_suffix('\r').unwrap_or(line)); + offset += line.len() + 1; + } + if !changed { + return blanked.to_string(); + } + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `blank` removes exactly `macros` from `input` and nothing else, every + /// offset kept (upstream `expectBlanked`). + #[track_caller] + fn expect_blanked(blank: fn(&str) -> String, input: &str, macros: &[&str]) { + let out = blank(input); + assert_eq!(out.len(), input.len(), "{input:?}"); + let mut expected = input.to_string(); + for macro_text in macros { + let at = expected + .find(macro_text) + .unwrap_or_else(|| panic!("{macro_text:?} in {input:?}")); + let blanked: String = macro_text + .chars() + .map(|c| if c == '\n' || c == '\r' { c } else { ' ' }) + .collect(); + expected.replace_range(at..at + macro_text.len(), &blanked); + } + assert_eq!(out, expected, "{input:?}"); + } + + #[track_caller] + fn expect_untouched(blank: fn(&str) -> String, inputs: &[&str]) { + for input in inputs { + assert_eq!(blank(input), *input, "{input:?}"); + } + } + + #[test] + fn blanks_every_macro_between_class_and_the_name_and_before_a_specialization() { + expect_blanked( + blank_cpp_export_macros, + "class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final : public ::google::protobuf::Message {};", + &["PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED"], + ); + expect_blanked( + blank_cpp_export_macros, + "template \nclass ABSL_ATTRIBUTE_WARN_UNUSED PROTOBUF_DECLSPEC_EMPTY_BASES\n RepeatedField final\n : private internal::RepeatedFieldBase {};", + &[ + "ABSL_ATTRIBUTE_WARN_UNUSED", + "PROTOBUF_DECLSPEC_EMPTY_BASES", + ], + ); + expect_blanked( + blank_cpp_export_macros, + "template \nclass PROTOBUF_DECLSPEC_EMPTY_BASES RepeatedFieldProxyWithSet<\n T, kOrProxy,\n std::enable_if_t>> {};", + &["PROTOBUF_DECLSPEC_EMPTY_BASES"], + ); + expect_blanked( + blank_cpp_export_macros, + "struct ALIGN_AS(64U) HandleImpl : public ClockHandle {};", + &["ALIGN_AS(64U)"], + ); + // An all-caps class name after a macro is the name, `final` included. + expect_blanked( + blank_cpp_export_macros, + "class FOO_API BAR_IMPL final : public X {};", + &["FOO_API"], + ); + } + + #[test] + fn leaves_class_heads_alone_when_nothing_is_defined_there() { + expect_untouched( + blank_cpp_export_macros, + &[ + "class FOO_API Name;", + "class FOO_API Name var;", + "struct STAT_T st = {0};", + "class GTEST_1_TUPLE_(T) {", + "// class PROTOBUF_EXPORT Any final : public Message {", + "const char* s = \"class FOO Bar {\";", + ], + ); + } + + #[test] + fn blanks_annotations_between_a_pointer_or_reference_and_the_declared_name() { + expect_blanked( + blank_cpp_pointer_annotation_macros, + "static const Descriptor* PROTOBUF_NONNULL descriptor() { return d; }", + &["PROTOBUF_NONNULL"], + ); + expect_blanked( + blank_cpp_pointer_annotation_macros, + "void Any::InternalSwap(Any* PROTOBUF_RESTRICT PROTOBUF_NONNULL other) {}", + &["PROTOBUF_RESTRICT PROTOBUF_NONNULL"], + ); + expect_blanked( + blank_cpp_pointer_annotation_macros, + "void f(const FieldDescriptor * PROTOBUF_NULLABLE *\n PROTOBUF_NONNULL field, const T& ABSL_ATTRIBUTE_LIFETIME_BOUND v);", + &[ + "PROTOBUF_NULLABLE", + "PROTOBUF_NONNULL", + "ABSL_ATTRIBUTE_LIFETIME_BOUND", + ], + ); + expect_blanked( + blank_cpp_pointer_annotation_macros, + "static constexpr const EnumDescriptor* PROTOBUF_NONNULL* PROTOBUF_NULLABLE\n file_level_enum_descriptors = nullptr;", + &["PROTOBUF_NONNULL", "PROTOBUF_NULLABLE"], + ); + } + + #[test] + fn leaves_products_bit_tests_unnamed_parameters_and_comments_alone() { + expect_untouched( + blank_cpp_pointer_annotation_macros, + &[ + "int n = count * MAX_LEN + 1;", + "bool b = (flags & FOO_BIT) != 0;", + "bool b = flags & FOO_BIT\n && enabled;", + "bool b = flags & FOO_BIT and enabled;", + "void f(Foo* PROTOBUF_NONNULL);", + "/**\n * NOTE_THIS applies\n * TODO_LATER fix it\n */", + "const char* s = \"a * PROTOBUF_NONNULL b\";", + "int digits10 = int(sizeof(int) * CHAR_BIT * 3 / 10);", + "int n = 2 * PAGE_SIZE * pages;", + "#define PTR_T int *\nFOO_BAR name;", + ], + ); + } + + #[test] + fn blanks_attribute_macros_after_a_parameter_list() { + expect_blanked( + blank_cpp_trailing_attribute_macros, + "const UnknownFieldSet& unknown_fields() const\n ABSL_ATTRIBUTE_LIFETIME_BOUND {\n return f;\n}", + &["ABSL_ATTRIBUTE_LIFETIME_BOUND"], + ); + expect_blanked( + blank_cpp_trailing_attribute_macros, + "~Any() PROTOBUF_FINAL;", + &["PROTOBUF_FINAL"], + ); + expect_blanked( + blank_cpp_trailing_attribute_macros, + "void Lock() EXCLUSIVE_LOCK_FUNCTION() { mu_.lock(); }\nvoid Wait() LOCKS_EXCLUDED(mu_);", + &["EXCLUSIVE_LOCK_FUNCTION()", "LOCKS_EXCLUDED(mu_)"], + ); + expect_blanked( + blank_cpp_trailing_attribute_macros, + "explicit MutexLock(port::Mutex* mu) EXCLUSIVE_LOCK_FUNCTION(mu) : mu_(mu) {}", + &["EXCLUSIVE_LOCK_FUNCTION(mu)"], + ); + expect_blanked( + blank_cpp_trailing_attribute_macros, + "bool operator==(const A& a) const ABSL_MUST_USE_RESULT;", + &["ABSL_MUST_USE_RESULT"], + ); + } + + #[test] + fn leaves_statements_casts_and_macro_calls_followed_by_macros_alone() { + expect_untouched( + blank_cpp_trailing_attribute_macros, + &[ + "if (x) RETURN_FALSE;", + "while (busy) SPIN_PAUSE;", + "int v = (int) MAX_VALUE;", + "FMT_PRAGMA_CLANG(diagnostic ignored \"-Wbit-int-extension\")\n\nTEST(std_test, bitint) {\n}", + "DECLARE_FOO(a) DECLARE_BAR(b);", + "void f()\n\nBAR_BAZ;", + "bool b = f(x) == FOO_BAR;", + // A directive's `)` closes no parameter list. + "# if FMT_USE_FCNTL && !defined(__MINGW32__)\nTEST(file_test, open_windows_file) {\n}", + "if constexpr (POS + 1 == str.size())\n FMT_THROW(format_error(\"unmatched brace\"));", + ], + ); + } + + #[test] + fn blanks_attribute_macros_after_a_declared_name_or_its_brackets() { + expect_blanked( + blank_cpp_declarator_attribute_macros, + "port::CondVar cv GUARDED_BY(mu);", + &["GUARDED_BY(mu)"], + ); + expect_blanked( + blank_cpp_declarator_attribute_macros, + "int refs_ GUARDED_BY(refs_mutex_);", + &["GUARDED_BY(refs_mutex_)"], + ); + expect_blanked( + blank_cpp_declarator_attribute_macros, + "Version* const version GUARDED_BY(mu);", + &["GUARDED_BY(mu)"], + ); + expect_blanked( + blank_cpp_declarator_attribute_macros, + "std::map cache_ ABSL_GUARDED_BY(mutex_) = {};", + &["ABSL_GUARDED_BY(mutex_)"], + ); + expect_blanked( + blank_cpp_declarator_attribute_macros, + "const AnyGlobalsTypeInternal Any_globals_\n PROTOBUF_MESSAGE_GLOBALS_SECTION(.data.rel.ro);", + &["PROTOBUF_MESSAGE_GLOBALS_SECTION(.data.rel.ro)"], + ); + expect_blanked( + blank_cpp_declarator_attribute_macros, + "const ::uint32_t Table::offsets[] ABSL_ATTRIBUTE_SECTION_VARIABLE(\n protodesc_cold) = {1, 2};", + &["ABSL_ATTRIBUTE_SECTION_VARIABLE(\n protodesc_cold)"], + ); + expect_blanked( + blank_cpp_declarator_attribute_macros, + "explicit CordInputStream(const absl::Cord* cord ABSL_ATTRIBUTE_LIFETIME_BOUND);", + &["ABSL_ATTRIBUTE_LIFETIME_BOUND"], + ); + } + + #[test] + fn leaves_variables_named_in_capitals_and_names_without_a_type_before_them_alone() { + expect_untouched( + blank_cpp_declarator_attribute_macros, + &[ + "Foo DEFAULT_OPTIONS;", + "static Foo DEFAULT_OPTIONS;", + "const Foo DEFAULT_OPTIONS = Make();", + "ns::Foo DEFAULT_OPTIONS;", + "typedef Foo BAR_T;", + "template T MAX_VALUE;", + "return x FOO_SUFFIX;", + "f(a, b FOO_FLAG);", + "x = rate FOO_UNITS;", + "EXPECT_EQ(2 SECS_X, d);", + ], + ); + } + + #[test] + fn blanks_attribute_macros_that_open_a_declaration() { + expect_blanked( + blank_cpp_leading_attribute_macros, + " PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const { return n; }", + &["PROTOBUF_FUTURE_ADD_EARLY_NODISCARD"], + ); + expect_blanked( + blank_cpp_leading_attribute_macros, + " PROTOBUF_FUTURE_ADD_EARLY_NODISCARD static constexpr size_t\n kMax = 3;", + &["PROTOBUF_FUTURE_ADD_EARLY_NODISCARD"], + ); + expect_blanked( + blank_cpp_leading_attribute_macros, + " ABSL_ATTRIBUTE_REINITIALIZES void Clear() final;", + &["ABSL_ATTRIBUTE_REINITIALIZES"], + ); + expect_blanked( + blank_cpp_leading_attribute_macros, + " [[nodiscard]] PROTOBUF_NDEBUG_INLINE Ptr Make(Args&&... args) {}", + &["PROTOBUF_NDEBUG_INLINE"], + ); + expect_blanked( + blank_cpp_leading_attribute_macros, + "GTEST_API_ std::string JoinAsTuple(const Strings& fields);", + &["GTEST_API_"], + ); + expect_blanked( + blank_cpp_leading_attribute_macros, + "FMT_EXPORT struct as_identifiers_t {};", + &["FMT_EXPORT"], + ); + expect_blanked( + blank_cpp_leading_attribute_macros, + "PROTOBUF_NDEBUG_INLINE Any::Impl_::Impl_(Arena* arena) : x_(arena) {}", + &["PROTOBUF_NDEBUG_INLINE"], + ); + expect_blanked( + blank_cpp_leading_attribute_macros, + "PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT\n PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 const AnyGlobals Any_globals_;", + &[ + "PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT\n PROTOBUF_ATTRIBUTE_INIT_PRIORITY1", + ], + ); + } + + #[test] + fn leaves_a_type_in_capitals_alone() { + expect_untouched( + blank_cpp_leading_attribute_macros, + &[ + "DWORD_PTR value = 0;", + "SIZE_T const n = 3;", + "UINT_PTR const* p = q;", + "HANDLE_T Open(int flags);", + "UINT_PTR Foo::Bar() { return 0; }", + "RESULT_T operator==(const A& a);", + "RESULT_T\nGnuStyle(int x) {}", + "int x = a |\n SOME_CONST\n | OTHER;", + "// PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const;", + // A macro inside a string concatenation, with a declaration after it. + "static const char kHelp[] =\n \"using \" GTEST_NAME_\n \". You can use the\\n\"\n \" --\" GTEST_FLAG_PREFIX_\n \"list_tests\\n\";\nstatic bool g_help = false;", + ], + ); + } + + #[test] + fn blanks_only_the_macros_of_a_run_not_a_comment_between_them() { + expect_blanked( + blank_cpp_leading_attribute_macros, + "PROTOBUF_ATTRIBUTE_NO_DESTROY /* keep */ PROTOBUF_CONSTINIT const Foo kFoo;", + &["PROTOBUF_ATTRIBUTE_NO_DESTROY", "PROTOBUF_CONSTINIT"], + ); + } + + #[test] + fn blanks_a_lone_macro_line_with_comments_under_it_but_not_an_operand() { + let fmt = "FMT_BEGIN_EXPORT\n\n// A generic formatting context with custom output iterator and character\n/* (code unit) support. */\ntemplate class generic_context {};\n"; + expect_blanked(blank_lone_macro_lines, fmt, &["FMT_BEGIN_EXPORT"]); + // A string literal continues the expression, comment or not. + let operand = "const char* s =\n \"x\"\n FOO_PREFIX\n // comment\n \"y\";\n"; + assert_eq!(blank_lone_macro_lines(operand), operand); + // An enum member after `A, /* comment */` is still an enum member. + let enumeration = "typedef enum {\n CURLE_OK,\n CURLE_TOO_LARGE, /* 100 */\n CURL_LAST /* never use! */\n} CURLcode;\n"; + assert_eq!(blank_lone_macro_lines(enumeration), enumeration); + } + + #[test] + fn keeps_the_first_branch_of_a_mid_declaration_conditional_offsets_intact() { + let source = "A() noexcept\n#ifdef X\n : a(1)\n#else\n : a(2)\n#endif\n{}\n"; + let out = flatten_mid_statement_conditionals(source); + assert_eq!(out.len(), source.len()); + assert!(out.contains(" : a(1)"), "{out:?}"); + assert!(!out.contains("a(2)"), "{out:?}"); + assert!(!out.contains('#'), "{out:?}"); + // Between whole declarations a conditional stays. + let top = "int a;\n#ifdef X\nint b;\n#endif\n"; + assert_eq!(flatten_mid_statement_conditionals(top), top); + } + + #[test] + fn rewrites_an_access_specifier_spelled_as_a_macro() { + let source = "class C {\n JSON_PRIVATE_UNLESS_TESTED:\n int x;\n Q_SIGNALS: // signals\n void f();\n FOO_BAR:\n};\n"; + let out = rewrite_cpp_access_macros(source); + assert_eq!(out.len(), source.len()); + assert!(out.contains(" private :\n"), "{out:?}"); + assert!(out.contains(" public : // signals\n"), "{out:?}"); + // A macro naming no access level, and `A::B` scope, stay. + assert!(out.contains(" FOO_BAR:\n"), "{out:?}"); + assert_eq!( + rewrite_cpp_access_macros("x = FOO_PUBLIC::y;\n"), + "x = FOO_PUBLIC::y;\n" + ); + } + + #[test] + fn restores_directive_lines_and_their_continuations() { + let original = "#define FOO_API \\\n __attribute__((x))\nFOO_API int f();\n"; + let blanked = "#define \\\n __attribute__((x))\n int f();\n"; + assert_eq!( + restore_directive_lines(original, blanked), + "#define FOO_API \\\n __attribute__((x))\n int f();\n" + ); + // A raw string's line is no directive. + let raw = "auto s = R\"(\n#define X\n)\";\n"; + assert_eq!( + restore_directive_lines(raw, "auto s = R\"(\n \n)\";\n"), + "auto s = R\"(\n \n)\";\n" + ); + } + + #[test] + fn blanks_known_inline_macros_in_specifier_position_only() { + expect_blanked( + blank_cpp_inline_macros, + "FORCEINLINE FString GetName() const;", + &["FORCEINLINE"], + ); + expect_untouched( + blank_cpp_inline_macros, + &[ + "x = FORCEINLINE ? 1 : 0;", + "FORCEINLINE_SOMETHINGELSE f();", + "const char* s = \"FORCEINLINE f\";", + ], + ); + } + + #[test] + fn masked_code_turns_comments_to_spaces_and_literals_to_tokens() { + let source = "a /* b */ \"c\" // d\n#if X\nR\"(e)\" f"; + let code = masked_code(source); + assert_eq!(code.len(), source.len()); + // The shared lexer starts a raw string at its quote, so the `R` + // prefix stays: one uppercase letter, which no macro scan takes. + assert_eq!( + String::from_utf8_lossy(&code), + "a \u{1}\u{1}\u{1} \n \nR\u{1}\u{1}\u{1}\u{1}\u{1} f" + ); + } +} diff --git a/crates/codegraph-extract/src/lang/csharp.rs b/crates/codegraph-extract/src/lang/csharp.rs index 936437e4..69d9166d 100644 --- a/crates/codegraph-extract/src/lang/csharp.rs +++ b/crates/codegraph-extract/src/lang/csharp.rs @@ -56,8 +56,11 @@ impl LanguageSpec for CSharpSpec { fn property_types(&self) -> &'static [&'static str] { &["property_declaration"] } + /// A file-scoped `namespace Foo;` covers the whole file; a block + /// `namespace Foo { … }` only its body, which the walker scopes + /// (upstream #2220). fn package_types(&self) -> &'static [&'static str] { - &["namespace_declaration", "file_scoped_namespace_declaration"] + &["file_scoped_namespace_declaration"] } fn name_field(&self) -> &'static str { "name" @@ -94,11 +97,36 @@ impl LanguageSpec for CSharpSpec { } } } - Some("private".to_string()) + // No modifier: an interface's members are public, a type declared in + // a namespace (or the file) is internal, and a class or struct member + // is private (upstream #2171). + let container = match node.parent() { + Some(parent) if parent.kind() == "declaration_list" => parent.parent(), + other => other, + }; + Some( + match container.map(|container| container.kind()) { + Some("interface_declaration") => "public", + None + | Some( + "compilation_unit" + | "namespace_declaration" + | "file_scoped_namespace_declaration", + ) => "internal", + _ => "private", + } + .to_string(), + ) } fn is_static(&self, node: Node<'_>, _source: &str) -> bool { has_modifier(node, "static") } + /// A `const` or `static readonly` field is a constant (upstream #897); an + /// instance `readonly` or a plain `static` field stays a field. + fn is_const(&self, node: Node<'_>) -> bool { + has_modifier(node, "const") + || (has_modifier(node, "static") && has_modifier(node, "readonly")) + } fn is_async(&self, node: Node<'_>) -> bool { has_modifier(node, "async") } @@ -142,6 +170,35 @@ fn blank_csharp_preprocessor_directives(source: &str) -> String { .join("\n") } +/// The name a C# `base_list` entry binds by: an identifier, a generic name +/// without its type arguments, or a qualified name built from those, with the +/// `global::` alias (the root namespace) dropped. A built-in or composite type +/// (`byte`, a tuple, an array) names no supertype. +pub(crate) fn csharp_base_type_name(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "identifier" => Some(node_text(node, source)), + "generic_name" => node + .named_children(&mut node.walk()) + .find(|child| child.kind() == "identifier") + .map(|id| node_text(id, source)), + "qualified_name" => { + let qualifier = csharp_base_type_name(child_by_field(node, "qualifier")?, source)?; + let name = csharp_base_type_name(child_by_field(node, "name")?, source)?; + Some(format!("{qualifier}.{name}")) + } + "alias_qualified_name" => { + let alias = node_text(child_by_field(node, "alias")?, source); + let name = csharp_base_type_name(child_by_field(node, "name")?, source)?; + Some(if alias == "global" { + name + } else { + format!("{alias}::{name}") + }) + } + _ => None, + } +} + fn has_modifier(node: Node<'_>, modifier: &str) -> bool { node.children(&mut node.walk()).any(|child| { child.kind() == "modifier" && child.child(0).is_some_and(|inner| inner.kind() == modifier) diff --git a/crates/codegraph-extract/src/lang/dart.rs b/crates/codegraph-extract/src/lang/dart.rs index cd4c7127..0f8de689 100644 --- a/crates/codegraph-extract/src/lang/dart.rs +++ b/crates/codegraph-extract/src/lang/dart.rs @@ -30,10 +30,16 @@ impl LanguageSpec for DartSpec { } fn method_types(&self) -> &'static [&'static str] { + // `const` constructors and redirecting factories have signature kinds + // of their own: flutter_bloc's `const BlocProvider.value(…)` is one, + // and without its node a `BlocProvider.value(…)` call went to another + // class's `value` (upstream #2380). &[ "method_signature", "method_declaration", "constructor_signature", + "constant_constructor_signature", + "redirecting_factory_constructor_signature", ] } @@ -58,7 +64,12 @@ impl LanguageSpec for DartSpec { } fn import_types(&self) -> &'static [&'static str] { - &["import_or_export"] + // `part 'x.g.dart';` is a library naming one of its own files — a + // generated part, often. Its URI resolves as an import's does, so the + // library links the part's file the way it links a file it imports, + // and a change to the part reaches whatever depends on the library. + // `part of` adds nothing to that edge, and stays out (upstream #2391). + &["import_or_export", "part_directive"] } fn call_types(&self) -> &'static [&'static str] { @@ -115,6 +126,19 @@ impl LanguageSpec for DartSpec { return Some(class_name); } let sig = dart_inner_signature(node); + // KEEP-RUST: this grammar wraps the return type in the signature's + // `return_type` field, a `type` whose own `type_identifier`s are the + // name and any import prefix before it (`p.Foo?` holds `p`, `Foo` + // and the type arguments). The last of them is the type upstream + // names; a function, record or `void` type names none. + if let Some(written) = child_by_field(sig, "return_type") { + let last = written + .named_children(&mut written.walk()) + .filter(|child| child.kind() == "type_identifier") + .last()?; + let name = node_text(last, source); + return is_ident(&name).then_some(name); + } let ret_type = sig .named_children(&mut sig.walk()) .find(|child| child.kind() == "type_identifier")?; @@ -128,6 +152,9 @@ impl LanguageSpec for DartSpec { let params = sig .named_children(&mut sig.walk()) .find(|child| child.kind() == "formal_parameter_list"); + // KEEP-RUST: upstream skips a redirecting factory's target here (`= + // _Impl` is no return type); this grammar writes the target as a + // `type`, never a `type_identifier` of the signature (#2380). let ret_type = sig .named_children(&mut sig.walk()) .find(|child| matches!(child.kind(), "type_identifier" | "void_type")); @@ -160,11 +187,33 @@ impl LanguageSpec for DartSpec { } fn is_static(&self, node: Node<'_>, _source: &str) -> bool { - node.kind() == "method_signature" - && (0..node.child_count()).any(|i| node.child(i).is_some_and(|c| c.kind() == "static")) + // KEEP-RUST: a member with a body is a `method_declaration` here, + // its `static` in the `method_signature` it holds; upstream's grammar + // makes that signature the member itself. + let signature = if node.kind() == "method_declaration" { + child_by_field(node, "signature").unwrap_or(node) + } else { + node + }; + signature.kind() == "method_signature" + && (0..signature.child_count()) + .any(|i| signature.child(i).is_some_and(|c| c.kind() == "static")) } fn resolve_name(&self, node: Node<'_>, _source: &str) -> Option { + // #2145 — `class A = B with C;`, a mixin application, names its class + // inside the `mixin_application_class`, not in a `name` field. + if node.kind() == "class_declaration" { + let application = node + .named_children(&mut node.walk()) + .find(|child| child.kind() == "mixin_application_class"); + if let Some(application) = application { + return application + .named_children(&mut application.walk()) + .find(|child| child.kind() == "identifier") + .map(|id| node_text(id, _source)); + } + } if let Some((class_name, ctor_name)) = dart_ctor_info(node, _source) { if ctor_name != class_name { return Some(ctor_name); @@ -185,15 +234,27 @@ impl LanguageSpec for DartSpec { } fn is_misparsed_function(&self, _name: &str, node: Node<'_>, source: &str) -> bool { - dart_ctor_info(node, source).is_some_and(|(class_name, ctor_name)| class_name == ctor_name) + // A `const` constructor or redirecting factory is nothing else, so one + // that names no enclosing type is error recovery's, and skipped + // (upstream #2380). + match dart_ctor_info(node, source) { + Some((class_name, ctor_name)) => class_name == ctor_name, + None => is_dart_bodiless_constructor(node.kind()), + } } fn extract_import(&self, node: Node<'_>, source: &str) -> Option { let signature = node_text(node, source).trim().to_string(); - let module_name = node - .named_children(&mut node.walk()) - .find(|child| matches!(child.kind(), "library_import" | "library_export")) - .and_then(|lib| first_descendant_kind(lib, "string_literal")) + // A part holds its `uri` directly; an import or export inside its + // `library_import` / `library_export`. + let holder = if node.kind() == "part_directive" { + node.child_by_field_name("uri") + } else { + node.named_children(&mut node.walk()) + .find(|child| matches!(child.kind(), "library_import" | "library_export")) + }; + let module_name = holder + .and_then(|holder| first_descendant_kind(holder, "string_literal")) .map(|lit| node_text(lit, source).replace(['\'', '"'], "")); module_name .filter(|m| !m.is_empty()) @@ -205,60 +266,211 @@ impl LanguageSpec for DartSpec { } fn extract_bare_call(&self, node: Node<'_>, source: &str) -> Option { - if node.kind() == "selector" { - let has_arg_part = node - .named_children(&mut node.walk()) - .any(|child| child.kind() == "argument_part"); - if !has_arg_part { - return None; + if matches!(node.kind(), "new_expression" | "const_object_expression") { + return dart_constructed_callee(node, source); + } + None + } +} + +/// The class, and the named constructor, a `new` or `const` constructor call +/// names (`EdgeInsets.all` for `const EdgeInsets.all(8)`). KEEP-RUST: this +/// grammar writes them in the call's `type` child, a named constructor as a +/// second `type_identifier` there (`Foo.named`), so `p.Foo` (an import +/// prefix) and `Foo.named` read alike; Dart writes types UpperCamel and +/// prefixes and constructors not, which tells them apart. Behind a prefix, +/// the constructor is the call's `constructor` field (`const p.Foo.named()`). +pub(crate) fn dart_constructed_callee(node: Node<'_>, source: &str) -> Option { + let ty = node.child_by_field_name("type")?; + let ids: Vec = ty + .named_children(&mut ty.walk()) + .filter(|child| child.kind() == "type_identifier") + .map(|child| node_text(child, source)) + .collect(); + let constructor = node + .child_by_field_name("constructor") + .map(|name| node_text(name, source)); + let (class, named) = match (ids.as_slice(), constructor) { + ([.., class], Some(named)) => (class.clone(), Some(named)), + ([class], None) => (class.clone(), None), + ([prefix_or_class, second], None) => { + if dart_writes_a_type(second) { + (second.clone(), None) + } else { + (prefix_or_class.clone(), Some(second.clone())) } - let prev = node.prev_named_sibling()?; - if prev.kind() == "identifier" { - return Some(node_text(prev, source)); + } + ([_, class, named, ..], None) => (class.clone(), Some(named.clone())), + ([], _) => return None, + }; + Some(match named { + Some(named) => format!("{class}.{named}"), + None => class, + }) +} + +/// Whether a Dart name is written as a type is: UpperCamel, past any leading +/// `_` or `$` (`^[_$]*[A-Z]`). +pub(crate) fn dart_writes_a_type(name: &str) -> bool { + name.trim_start_matches(['_', '$']) + .chars() + .next() + .is_some_and(|c| c.is_ascii_uppercase()) +} + +/// The expression an arrow function's body ends with, when this grammar ended +/// the arrow in front of a call or member access on that expression +/// (KEEP-RUST, the crate grammar's misparse; upstream #2375 reads a receiver +/// past the arrow the same way): `(ref) => createDio()` parses as a call of +/// `(ref) => createDio`, and `(ref) => ref.read(p)` as `.read(p)` on +/// `(ref) => ref`. Dart writes a call or member access on a function literal +/// itself in parentheses, so a function expression in that place is never +/// the literal. Any other node is itself. +pub(crate) fn dart_arrow_tail(node: Node<'_>) -> Node<'_> { + if node.kind() != "function_expression" { + return node; + } + let Some(body) = node.child_by_field_name("body") else { + return node; + }; + if body.kind() != "function_expression_body" { + return node; + } + match body.named_children(&mut body.walk()).last() { + Some(tail) if tail.kind() != "block" => tail, + _ => node, + } +} + +/// Where a Dart call whose callee runs through a misparsed arrow function +/// starts in the code as written (see [`dart_arrow_tail`]): the start of the +/// arrow's tail expression, `createDio` in `(ref) => createDio()` and `ref` +/// in `(ref) => ref.watch(p).select(s)`. `None` for any other call. +pub(crate) fn dart_misparsed_call_start(call: Node<'_>) -> Option> { + let mut current = call; + loop { + let next = match current.kind() { + "call_expression" | "instantiation_expression" => { + current.child_by_field_name("function") } - if prev.kind() == "selector" { - let accessor = prev.named_children(&mut prev.walk()).find(|child| { - matches!( - child.kind(), - "unconditional_assignable_selector" | "conditional_assignable_selector" - ) - })?; - let method_id = accessor - .named_children(&mut accessor.walk()) - .find(|child| child.kind() == "identifier")?; - return Some(node_text(method_id, source)); + "member_expression" | "null_aware_member_expression" => { + current.child_by_field_name("object") } - if matches!( - prev.kind(), - "unconditional_assignable_selector" | "conditional_assignable_selector" - ) { - let method_id = prev - .named_children(&mut prev.walk()) - .find(|child| child.kind() == "identifier")?; - return Some(node_text(method_id, source)); + "function_expression" => { + let tail = dart_arrow_tail(current); + return (tail.id() != current.id()).then_some(tail); } + _ => None, + }; + current = next?; + } +} + +/// The bodies a Dart field `declaration` sits in: a class's or mixin's, an +/// extension's, an enum's (upstream `DART_MEMBER_BODIES`). +fn is_dart_member_body(kind: &str) -> bool { + matches!(kind, "class_body" | "extension_body" | "enum_body") +} + +/// Whether a Dart `declaration` declares fields of the type around it +/// (`final Report report;`, `static const zero = Score(0);`): it sits in a +/// member body, inside the `class_member` this grammar wraps it in, and +/// names variables rather than a constructor or an abstract method. +pub(crate) fn is_dart_field_declaration(node: Node<'_>) -> bool { + if node.kind() != "declaration" { + return false; + } + let body = match node.parent() { + Some(parent) if parent.kind() == "class_member" => parent.parent(), + parent => parent, + }; + body.is_some_and(|body| is_dart_member_body(body.kind())) + && node.named_children(&mut node.walk()).any(|child| { + matches!( + child.kind(), + "initialized_identifier_list" | "static_final_declaration_list" + ) + }) +} + +/// Whether an `initialized_identifier` declares a field or a top-level +/// variable (`var cache = load();`, `final _ctl = TextEditingController();` +/// in a class), rather than the second variable of a local declaration +/// (`for (var i = 0, j = n(); …)`), which a function body's walk covers +/// (upstream #2367 `isDartFieldOrTopLevelEntry`). +pub(crate) fn is_dart_field_or_top_level_entry(node: Node<'_>) -> bool { + let Some(list) = node.parent() else { + return false; + }; + if list.kind() != "initialized_identifier_list" { + return false; + } + list.parent().is_some_and(|owner| { + owner.kind() == "top_level_variable_declaration" || is_dart_field_declaration(owner) + }) +} + +/// The wrappers tree-sitter-dart puts around a member, innermost first: the +/// `declaration` a member with no body opens (`Foo._();`, `void m();`, +/// `external int f();`), the `external_function_declaration` of a top-level +/// `external` function, and the `class_member` every class, mixin and +/// extension member sits in. The member's annotations are written inside +/// them, before it, and its dartdoc before the outermost one (#2382). A +/// wrapper counts only while nothing but annotations, comments and +/// `external` comes before the node inside it. +pub(crate) fn dart_member_wrappers(node: Node<'_>) -> Vec> { + let mut wrappers = Vec::new(); + let mut inner = node; + while let Some(parent) = inner.parent() { + let wraps = match parent.kind() { + "declaration" | "external_function_declaration" => wrappers.is_empty(), + "class_member" => true, + _ => false, + }; + if !wraps + || !dart_prefix(parent, inner) + .iter() + .all(|prefix| dart_is_member_prefix(prefix.kind())) + { + break; } - if matches!(node.kind(), "new_expression" | "const_object_expression") { - if let Some(type_id) = node - .named_children(&mut node.walk()) - .find(|child| child.kind() == "type_identifier") - { - if node.kind() == "const_object_expression" { - if let Some(name_id) = node - .named_children(&mut node.walk()) - .find(|child| child.kind() == "identifier") - { - return Some(format!( - "{}.{}", - node_text(type_id, source), - node_text(name_id, source) - )); - } - } - return Some(node_text(type_id, source)); - } + wrappers.push(parent); + if parent.kind() == "class_member" { + break; } - None + inner = parent; + } + wrappers +} + +/// The named children of `wrapper` written before `inner`. +pub(crate) fn dart_prefix<'tree>(wrapper: Node<'tree>, inner: Node<'tree>) -> Vec> { + wrapper + .named_children(&mut wrapper.walk()) + .take_while(|child| child.start_byte() < inner.start_byte()) + .collect() +} + +/// What may stand before a member inside its wrapper. +fn dart_is_member_prefix(kind: &str) -> bool { + matches!( + kind, + "annotation" | "comment" | "documentation_comment" | "external" + ) +} + +/// The name of a supertype written in a Dart heritage list (G12): a `type` +/// holding the `type_identifier` it names, the last one past an import +/// prefix (`p.Base`). Its type arguments are `type` siblings that open with +/// `<` and hold no `type_identifier` of their own, so they name nothing. +pub(crate) fn dart_supertype_name(node: Node<'_>) -> Option> { + match node.kind() { + "type_identifier" => Some(node), + "type" => node + .named_children(&mut node.walk()) + .filter(|child| child.kind() == "type_identifier") + .last(), + _ => None, } } @@ -284,11 +496,23 @@ fn dart_inner_signature(node: Node<'_>) -> Node<'_> { node } +/// The constructors that never have a body — `const Foo.bar();` and a +/// redirecting factory `const factory Foo.bar() = _Bar;` — each parse as a +/// signature kind of their own, inside a `declaration` (upstream #2380 +/// `DART_BODILESS_CTORS`). +pub(crate) fn is_dart_bodiless_constructor(kind: &str) -> bool { + matches!( + kind, + "constant_constructor_signature" | "redirecting_factory_constructor_signature" + ) +} + fn dart_constructor_signature(node: Node<'_>) -> Option> { if matches!( node.kind(), "factory_constructor_signature" | "constructor_signature" - ) { + ) || is_dart_bodiless_constructor(node.kind()) + { return Some(node); } if node.kind() == "method_signature" { @@ -329,8 +553,11 @@ fn dart_enclosing_type_name(node: Node<'_>, source: &str) -> Option { fn dart_ctor_info(node: Node<'_>, source: &str) -> Option<(String, String)> { let ctor = dart_constructor_signature(node)?; + // The names before the parameters: a redirecting factory names its target + // after them (`factory Foo() = _Impl.named;`, upstream #2380). let ids = ctor .named_children(&mut ctor.walk()) + .take_while(|child| child.kind() != "formal_parameter_list") .filter(|child| child.kind() == "identifier") .collect::>(); let class_name = dart_enclosing_type_name(node, source)?; diff --git a/crates/codegraph-extract/src/lang/go.rs b/crates/codegraph-extract/src/lang/go.rs index c70b0127..176bfac4 100644 --- a/crates/codegraph-extract/src/lang/go.rs +++ b/crates/codegraph-extract/src/lang/go.rs @@ -37,7 +37,8 @@ impl LanguageSpec for GoSpec { &[] } fn type_alias_types(&self) -> &'static [&'static str] { - &["type_spec"] + // `type A B` and `type A = B` (upstream #2417). + &["type_spec", "type_alias"] } fn import_types(&self) -> &'static [&'static str] { &["import_declaration"] @@ -111,6 +112,32 @@ impl LanguageSpec for GoSpec { is_identifier(&text).then_some(text) } + /// A type declared on its own, `type Foo struct{…}`, is a + /// `type_declaration` holding one spec, and its doc comment comes before + /// the declaration, outside the spec (upstream #2438). A member of a + /// `type ( … )` group has its comment beside it in the parentheses, and + /// the comment above the group is the group's. A group of one is that + /// type's declaration, as go doc reads it, unless its member has a + /// comment of its own. + fn declaration_wrapper<'tree>(&self, node: Node<'tree>) -> Option> { + let parent = node.parent()?; + if parent.kind() != "type_declaration" || parent.named_child(0) != Some(node) { + return None; + } + let specs = parent + .named_children(&mut parent.walk()) + .filter(|child| matches!(child.kind(), "type_spec" | "type_alias")) + .count(); + (specs == 1).then_some(parent) + } + + /// A comment after code on its line (`const sides = 4 // sides of a + /// square.`) is that line's, never the doc of the declaration below it + /// (upstream #2438). + fn docstring_skips_trailing_comments(&self) -> bool { + true + } + fn resolve_type_alias_kind(&self, node: Node<'_>, _source: &str) -> Option { let type_child = child_by_field(node, "type")?; match type_child.kind() { diff --git a/crates/codegraph-extract/src/lang/java.rs b/crates/codegraph-extract/src/lang/java.rs index 6ef368c1..9c26178c 100644 --- a/crates/codegraph-extract/src/lang/java.rs +++ b/crates/codegraph-extract/src/lang/java.rs @@ -84,16 +84,7 @@ impl LanguageSpec for JavaSpec { } fn get_return_type(&self, node: Node<'_>, source: &str) -> Option { - let type_node = child_by_field(node, "type")?; - if matches!( - type_node.kind(), - "void_type" | "integral_type" | "floating_point_type" | "boolean_type" | "array_type" - ) { - return None; - } - let raw = strip_angle_args(&node_text(type_node, source)); - let last = raw.split('.').next_back()?.trim(); - valid_ident(last).then(|| last.to_string()) + normalize_java_type(child_by_field(node, "type")?, source) } fn get_visibility(&self, node: Node<'_>) -> Option { @@ -114,6 +105,13 @@ impl LanguageSpec for JavaSpec { modifier_kinds(node).is_some_and(|kinds| kinds.iter().any(|kind| *kind == "static")) } + /// A `static final` field is a constant (upstream #897); an instance or + /// `final`-only field stays a field. + fn is_const(&self, node: Node<'_>) -> bool { + modifier_kinds(node) + .is_some_and(|kinds| kinds.contains(&"static") && kinds.contains(&"final")) + } + fn extract_package(&self, node: Node<'_>, source: &str) -> Option { node.named_children(&mut node.walk()) .find(|child| matches!(child.kind(), "scoped_identifier" | "identifier")) @@ -133,6 +131,124 @@ impl LanguageSpec for JavaSpec { } } +/// A Java type's class name as a receiver type (upstream +/// `normalizeJavaType`): `List` of `java.util.List`; none for a +/// primitive, `void` or an array. +pub(crate) fn normalize_java_type(type_node: Node<'_>, source: &str) -> Option { + if matches!( + type_node.kind(), + "void_type" | "integral_type" | "floating_point_type" | "boolean_type" | "array_type" + ) { + return None; + } + let raw = strip_angle_args(&node_text(type_node, source)); + let last = raw.split('.').next_back()?.trim(); + valid_ident(last).then(|| last.to_string()) +} + +/// The Lombok logging annotations; each generates a field named `log` +/// (upstream #912 `LOMBOK_LOG_ANNOTATIONS`). +pub(crate) const LOMBOK_LOG_ANNOTATIONS: [&str; 9] = [ + "Slf4j", + "Log4j", + "Log4j2", + "Log", + "CommonsLog", + "JBossLog", + "Flogger", + "XSlf4j", + "CustomLog", +]; + +/// The simple names of the annotations in a declaration's `modifiers`, in +/// source order and each once (`@lombok.Getter` is `Getter`; upstream #912 +/// `lombokAnnotationNames`). +pub(crate) fn lombok_annotation_names(node: Node<'_>, source: &str) -> Vec { + let mut names: Vec = Vec::new(); + let Some(modifiers) = node + .named_children(&mut node.walk()) + .find(|child| child.kind() == "modifiers") + else { + return names; + }; + for child in modifiers.named_children(&mut modifiers.walk()) { + if !matches!(child.kind(), "marker_annotation" | "annotation") { + continue; + } + let Some(name) = child_by_field(child, "name") else { + continue; + }; + let text = node_text(name, source); + let simple = text.trim().rsplit('.').next().unwrap_or_default(); + if !simple.is_empty() && !names.iter().any(|seen| seen == simple) { + names.push(simple.to_string()); + } + } + names +} + +/// The text of a declaration's `modifiers`, annotations included (upstream +/// `modifierTextOf`). +pub(crate) fn modifier_text(node: Node<'_>, source: &str) -> String { + node.named_children(&mut node.walk()) + .find(|child| child.kind() == "modifiers") + .map(|modifiers| node_text(modifiers, source)) + .unwrap_or_default() +} + +/// Whether `word` appears in `text` between non-word characters (a regex +/// `\b\b`). +pub(crate) fn has_word(text: &str, word: &str) -> bool { + let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_'; + text.match_indices(word).any(|(at, _)| { + !text[..at].chars().next_back().is_some_and(is_word) + && !text[at + word.len()..].chars().next().is_some_and(is_word) + }) +} + +/// `name` with its first character upper-cased as JavaScript's +/// `charAt(0).toUpperCase()` does (a character outside the BMP is a lone +/// surrogate there, and stays). +fn capitalize_java(name: &str) -> String { + let mut chars = name.chars(); + match chars.next() { + Some(first) if first.len_utf16() == 1 => first.to_uppercase().chain(chars).collect(), + _ => name.to_string(), + } +} + +/// Whether `name` starts `is` and an ASCII capital (`/^is[A-Z]/`). +fn is_prefixed_boolean_name(name: &str) -> bool { + name.strip_prefix("is") + .and_then(|rest| rest.chars().next()) + .is_some_and(|c| c.is_ascii_uppercase()) +} + +/// Lombok's getter name: `getX`, or `isX` for a primitive boolean, which keeps +/// a field name already `isFoo` (upstream `lombokGetterName`). +pub(crate) fn lombok_getter_name(field: &str, boolean_primitive: bool) -> String { + if boolean_primitive { + if is_prefixed_boolean_name(field) { + field.to_string() + } else { + format!("is{}", capitalize_java(field)) + } + } else { + format!("get{}", capitalize_java(field)) + } +} + +/// Lombok's setter name: `setX`; a primitive boolean `isFoo` sets through +/// `setFoo` (upstream `lombokSetterName`). +pub(crate) fn lombok_setter_name(field: &str, boolean_primitive: bool) -> String { + let base = if boolean_primitive && is_prefixed_boolean_name(field) { + &field[2..] + } else { + field + }; + format!("set{}", capitalize_java(base)) +} + fn modifier_kinds(node: Node<'_>) -> Option> { let modifiers = node .children(&mut node.walk()) @@ -166,3 +282,35 @@ fn valid_ident(text: &str) -> bool { .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) && chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lombok_accessor_names_follow_lombok() { + assert_eq!(lombok_getter_name("name", false), "getName"); + assert_eq!(lombok_getter_name("active", true), "isActive"); + // A primitive boolean already named `isFoo` keeps its name, and sets + // through `setFoo`; a boxed one or another type does not. + assert_eq!(lombok_getter_name("isRunning", true), "isRunning"); + assert_eq!(lombok_setter_name("isRunning", true), "setRunning"); + assert_eq!(lombok_getter_name("isRunning", false), "getIsRunning"); + assert_eq!(lombok_setter_name("isRunning", false), "setIsRunning"); + assert_eq!(lombok_getter_name("island", true), "isIsland"); + assert_eq!(lombok_setter_name("island", true), "setIsland"); + assert_eq!(lombok_getter_name("x", false), "getX"); + assert_eq!(lombok_getter_name("_id", false), "get_id"); + assert_eq!(lombok_getter_name("état", false), "getÉtat"); + } + + #[test] + fn has_word_matches_whole_words_only() { + assert!(has_word("private static final", "static")); + assert!(has_word("@Getter final", "final")); + assert!(!has_word("private finalize", "final")); + assert!(!has_word("staticky", "static")); + assert!(!has_word("non_static", "static")); + assert!(has_word("@Value(\"static\")", "static")); + } +} diff --git a/crates/codegraph-extract/src/lang/kotlin.rs b/crates/codegraph-extract/src/lang/kotlin.rs index 3bd40a60..566bca66 100644 --- a/crates/codegraph-extract/src/lang/kotlin.rs +++ b/crates/codegraph-extract/src/lang/kotlin.rs @@ -90,6 +90,10 @@ impl LanguageSpec for KotlinSpec { "type" } + fn pre_parse(&self, source: &str, _file_path: &str) -> String { + rewrite_newer_kotlin_syntax(source) + } + fn resolve_body<'tree>(&self, node: Node<'tree>, _body_field: &str) -> Option> { // Upstream kotlin.ts:174-196 resolves Kotlin bodies by child type rather // than field name because the upstream grammar historically had no fields. @@ -322,6 +326,138 @@ fn has_descendant_kind(node: Node<'_>, kind: &str) -> bool { false } +/// Kotlin syntax newer than tree-sitter-kotlin-ng 1.1.0, rewritten to an +/// older equivalent of the same length so every offset survives (upstream +/// #2182 `rewriteNewerKotlinSyntax`); either one otherwise made the file +/// contribute nothing: +/// +/// - a `when` guard (Kotlin 2.1), `is H2Dialect if dialect.mode == X ->`, +/// becomes a second condition, `is H2Dialect, dialect.mode == X ->`, so +/// the guard's references are kept; +/// - a multi-dollar string (2.1), `$$"?(@.a == $x)"`, loses its prefix. +/// +/// The grammar parses an open-ended range (`1..?.() -> …`) itself, so upstream's +/// other two rewrites have nothing to do here. +pub(crate) fn rewrite_newer_kotlin_syntax(source: &str) -> String { + let mut out = source.as_bytes().to_vec(); + // Upstream's guard: a file with no ` if` is left alone, even when a + // guard is written after a tab. + if source.contains(" if") { + rewrite_when_guards(&mut out); + } + if source.contains("$$") { + blank_multi_dollar_prefixes(&mut out); + } + // Only ASCII bytes were replaced, by ASCII bytes. + String::from_utf8(out).unwrap_or_else(|_| source.to_string()) +} + +/// Upstream's `WHEN_GUARD`, +/// `^([ \t]*!?(?:is|in)[ \t]+(?:(?!->)[^\n])*?[\w>?)\]'"])([ \t]+)if(?=[ \t(])` +/// (global, multiline), replaced by the condition, a comma and the gap and +/// `if` as spaces. The condition stops short of `->`: `is X -> if (…)` is a +/// branch whose body is an `if`. +fn rewrite_when_guards(bytes: &mut [u8]) { + let mut resume = 0; + for start in js_line_starts(bytes) { + if start < resume { + continue; + } + if let Some((comma, end)) = when_guard_at(bytes, start) { + bytes[comma] = b','; + bytes[comma + 1..end].fill(b' '); + resume = end; + } + } +} + +/// The positions JavaScript's multiline `^` matches at: the start and after +/// every line terminator (`\n`, `\r`, U+2028, U+2029). +fn js_line_starts(bytes: &[u8]) -> Vec { + let mut starts = vec![0]; + for (at, &byte) in bytes.iter().enumerate() { + if byte == b'\n' || byte == b'\r' { + starts.push(at + 1); + } else if byte == 0xE2 + && bytes.get(at + 1) == Some(&0x80) + && matches!(bytes.get(at + 2), Some(0xA8 | 0xA9)) + { + starts.push(at + 3); + } + } + starts +} + +/// A `when` guard starting at line start `start`: the position of the gap +/// after the condition and the end of `if`. +fn when_guard_at(bytes: &[u8], start: usize) -> Option<(usize, usize)> { + let blank = |at: usize| matches!(bytes.get(at), Some(b' ' | b'\t')); + let mut at = start; + while blank(at) { + at += 1; + } + if bytes.get(at) == Some(&b'!') { + at += 1; + } + if !matches!(bytes.get(at..at + 2), Some(b"is" | b"in")) { + return None; + } + at += 2; + if !blank(at) { + return None; + } + while blank(at) { + at += 1; + } + loop { + let byte = *bytes.get(at)?; + if byte.is_ascii_alphanumeric() + || matches!(byte, b'_' | b'>' | b'?' | b')' | b']' | b'\'' | b'"') + { + let mut gap_end = at + 1; + while blank(gap_end) { + gap_end += 1; + } + if gap_end > at + 1 + && bytes.get(gap_end..gap_end + 2) == Some(b"if") + && matches!(bytes.get(gap_end + 2), Some(b' ' | b'\t' | b'(')) + { + return Some((at + 1, gap_end + 2)); + } + } + // The condition goes on through any character but a line feed and + // the start of `->`. + if byte == b'\n' || (byte == b'-' && bytes.get(at + 1) == Some(&b'>')) { + return None; + } + at += 1; + } +} + +/// Upstream's `MULTI_DOLLAR_STRING`, `(? 0 + && (bytes[at - 1].is_ascii_alphanumeric() + || matches!(bytes[at - 1], b'_' | b'$' | b'"')); + if !after_word && end - at >= 2 && bytes.get(end) == Some(&b'"') { + bytes[at..end].fill(b' '); + } + at = end; + } +} + fn is_ident(text: &str) -> bool { let mut chars = text.chars(); chars @@ -517,4 +653,81 @@ fun String.decorate(prefix: String): String = prefix + this let info = KOTLIN_SPEC.extract_import(import, src).unwrap(); assert!(info.module_name.contains("Helper") || !info.module_name.is_empty()); } + + /// Upstream #2182's fixture: a `when` guard and a multi-dollar string + /// are rewritten with every offset intact, and the result parses; the + /// grammar's own `..<` and nullable function-type receiver stay. + #[test] + fn newer_syntax_is_rewritten_with_every_offset_intact() { + let src = "package app\n\nclass Dialect(private val mode: Int) {\n fun sql(value: Any?): String = when (value) {\n is String if mode == 2 -> \"text\"\n is Int if (\n mode > 0\n ) -> \"int\"\n is Long -> if (mode == 1) \"long\" else \"big\"\n else -> \"other\"\n }\n\n fun ranges(n: Int) {\n for (i in 0..?.() -> Op): Dialect = this\n\n fun after(): Int = 1\n}\n\nclass Op\n"; + assert!(parse(src).root_node().has_error()); + let out = rewrite_newer_kotlin_syntax(src); + assert_eq!(out.len(), src.len()); + assert!(out.contains("is String, mode == 2 ->")); + assert!(out.contains("is Int, (\n")); + assert!(out.contains("is Long -> if (mode == 1)")); + assert!(out.contains("0..?.() -> Op")); + assert!(!parse(&out).root_node().has_error()); + } + + #[test] + fn when_guards_follow_upstreams_pattern() { + let rewrite = |src: &str| rewrite_newer_kotlin_syntax(src); + // `!is`, `in`, a type argument and a closing bracket end a condition. + assert_eq!( + rewrite(" !in 1..3 if ok -> 1\n is Map<*, *> if (x) -> 2\n is A? if y -> 3\n"), + " !in 1..3, ok -> 1\n is Map<*, *>, (x) -> 2\n is A?, y -> 3\n" + ); + // Not a guard: `if` after `->`, not at a line's condition, not + // followed by a blank or `(`, not after a blank. + for src in [ + " is Long -> if (a) 1 else 2\n", + " x is Long if (a) -> 1\n", + " is Long ifx -> 1\n", + " is Long iff (a) -> 1\n", + " isLong if (a) -> 1\n", + " is Long,if (a) -> 1\n", + ] { + assert_eq!(rewrite(src), src); + } + // The first ` if` on the line is the guard's. + assert_eq!( + rewrite(" is A if b if c -> 1\n"), + " is A, b if c -> 1\n" + ); + // A tab gap counts, but only in a file with a ` if` somewhere. + assert_eq!(rewrite(" is A\tif b -> 1\n"), " is A\tif b -> 1\n"); + assert_eq!( + rewrite(" is A\tif b -> 1\n // if\n"), + " is A, b -> 1\n // if\n" + ); + // CRLF lines and a guard on the line after a lone carriage return. + assert_eq!( + rewrite(" is A if b -> 1\r\n is B if c -> 2\r is C if d -> 3"), + " is A, b -> 1\r\n is B, c -> 2\r is C, d -> 3" + ); + // Non-ASCII text survives. + assert_eq!(rewrite(" is Ä if é -> \"ü\"\n"), " is Ä if é -> \"ü\"\n"); + assert_eq!( + rewrite(" is Aä if é -> \"ü\"\n"), + " is Aä if é -> \"ü\"\n" + ); + assert_eq!(rewrite(" is A if é -> \"ü\"\n"), " is A, é -> \"ü\"\n"); + } + + #[test] + fn multi_dollar_prefixes_follow_upstreams_pattern() { + let rewrite = |src: &str| rewrite_newer_kotlin_syntax(src); + assert_eq!(rewrite("val a = $$\"x $y\""), "val a = \"x $y\""); + assert_eq!(rewrite("val a = $$$\"x\""), "val a = \"x\""); + assert_eq!(rewrite("f($$\"x\")"), "f( \"x\")"); + // After a word character, a `$` or a quote, or not before a quote. + for src in ["x$$\"y\"", "\"$$\"", "a $$ b", "a $\"b\""] { + assert_eq!(rewrite(src), src); + } + // Upstream blanks a run closing a plain string, too. + assert_eq!(rewrite("\"cost $$\""), "\"cost \""); + } } diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs index 9d4eb3e6..94a7e5e8 100644 --- a/crates/codegraph-extract/src/lang/mod.rs +++ b/crates/codegraph-extract/src/lang/mod.rs @@ -5,8 +5,11 @@ mod arkts; mod c; +mod c_preparse; mod cfml; mod cpp; +pub(crate) mod cpp_brace_scopes; +mod cpp_preparse; mod csharp; mod dart; mod erlang; @@ -39,9 +42,11 @@ use crate::spec::LanguageSpec; pub use arkts::ARKTS_SPEC; pub use c::C_SPEC; +pub(crate) use c::{c_declarator_identifier, is_class_like_definition}; pub use cfml::CFML_SPEC; pub(crate) use cfml::{ - cfml_component_name_from_path, cfml_string_attr_value, cfml_tag_attr, is_bare_script_cfml, + cfml_component_attr, cfml_component_name_from_path, cfml_start_tag, cfml_tag_attr, + cfml_tag_attr_value_node, cfml_tag_name, is_bare_script_cfml, }; pub use cpp::{ CPP_CONSTRUCTOR_REFERENCE_PREFIX, CPP_SPEC, CppConstructorShape, @@ -53,7 +58,13 @@ pub(crate) use cpp::{ recover_explicit_operator_call, }; pub use csharp::CSHARP_SPEC; +pub(crate) use csharp::csharp_base_type_name; pub use dart::DART_SPEC; +pub(crate) use dart::{ + dart_arrow_tail, dart_member_wrappers, dart_misparsed_call_start, dart_prefix, + dart_supertype_name, dart_writes_a_type, is_dart_field_declaration, + is_dart_field_or_top_level_entry, +}; pub use erlang::ERLANG_SPEC; pub(crate) use erlang::{ erlang_atom_text, erlang_call_ref_name, erlang_clause_header, erlang_clause_name, @@ -64,6 +75,10 @@ pub(crate) use erlang::{ pub use gdscript::GDSCRIPT_SPEC; pub use go::GO_SPEC; pub use java::JAVA_SPEC; +pub(crate) use java::{ + LOMBOK_LOG_ANNOTATIONS, has_word, lombok_annotation_names, lombok_getter_name, + lombok_setter_name, modifier_text, normalize_java_type, +}; pub use javascript::JAVASCRIPT_SPEC; pub use jsx::JSX_SPEC; pub use kotlin::KOTLIN_SPEC; diff --git a/crates/codegraph-extract/src/lang/swift.rs b/crates/codegraph-extract/src/lang/swift.rs index 90b7af65..b3f11bb3 100644 --- a/crates/codegraph-extract/src/lang/swift.rs +++ b/crates/codegraph-extract/src/lang/swift.rs @@ -5,7 +5,10 @@ //! the upstream WASM build; docs/grammar-manifest.md tier-a PASS against core 0.26, //! so no vendored `cc` build is required). +use std::sync::OnceLock; + use codegraph_core::types::{Language, NodeKind}; +use regex::Regex; use tree_sitter::{Language as TsLanguage, Node}; use crate::spec::{ImportInfo, LanguageSpec}; @@ -20,6 +23,10 @@ impl LanguageSpec for SwiftSpec { Language::Swift } + fn pre_parse(&self, source: &str, _file_path: &str) -> String { + join_swift_composition_continuations(source) + } + fn tree_sitter_language(&self) -> TsLanguage { tree_sitter_swift::LANGUAGE.into() } @@ -366,3 +373,116 @@ mod tests { assert!(SWIFT_SPEC.get_return_type(func, src).is_none()); } } + +/// Move a protocol composition's line-leading `&` onto the line before it +/// (upstream #2108): +/// +/// ```swift +/// typealias EditorClient = AutocompleteService.Client +/// & MediaUploadService.Client +/// ``` +/// +/// is valid Swift, but inside a type's body tree-sitter-swift ends the +/// declaration at the newline and parses the `&` line as an error, which loses +/// the types it names. With the `&` trailing the previous line it parses. Same +/// length and line count; only the continuation line's tokens sit one column +/// left. Applied to an indented `typealias`/`let`/`var` statement's `&` lines +/// only, never to `&&`, `&+` or an inout `&x`. +pub(crate) fn join_swift_composition_continuations(source: &str) -> String { + static HEAD: OnceLock = OnceLock::new(); + let head_re = HEAD.get_or_init(|| { + Regex::new( + r"^[ \t]+(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:[a-z]+(?:\([a-z]+\))?\s+)*(?:typealias|let|var)\s", + ) + .expect("Swift composition head regex") + }); + if !source.contains('&') || !source.lines().any(|line| continuation(line).is_some()) { + return source.to_string(); + } + let original: Vec<&str> = source.split('\n').collect(); + let mut lines: Vec = original.iter().map(|line| (*line).to_string()).collect(); + let mut changed = false; + for i in 1..lines.len() { + let Some((indent, gap)) = continuation(&lines[i]) else { + continue; + }; + let mut head = i - 1; + while head > 0 && continuation(original[head]).is_some() { + head -= 1; + } + if !head_re.is_match(original[head]) { + continue; + } + let prev = lines[i - 1].clone(); + let (body, cr) = match prev.strip_suffix('\r') { + Some(body) => (body, "\r"), + None => (prev.as_str(), ""), + }; + // The previous line must end on a type; a trailing comment would + // swallow the `&`. + let ends_on_type = body.chars().next_back().is_some_and(|c| { + c.is_ascii_alphanumeric() || matches!(c, '_' | '>' | ')' | ']' | '?' | '!') + }); + if !ends_on_type || body.contains("//") { + continue; + } + let rest = lines[i][indent + 1 + gap..].to_string(); + lines[i - 1] = format!("{body}&{cr}"); + lines[i] = format!( + "{}{}{rest}", + &lines[i][..indent], + &lines[i][indent + 1..indent + 1 + gap] + ); + changed = true; + } + if changed { + lines.join("\n") + } else { + source.to_string() + } +} + +/// `(indent, gap)` of a composition continuation line: indentation, an `&`, +/// then `gap` spaces or tabs before the next token. +fn continuation(line: &str) -> Option<(usize, usize)> { + let indent = line.len() - line.trim_start_matches([' ', '\t']).len(); + let after = line[indent..].strip_prefix('&')?; + let gap = after.len() - after.trim_start_matches([' ', '\t']).len(); + let next = after[gap..].chars().next()?; + (gap > 0 && !next.is_whitespace()).then_some((indent, gap)) +} + +#[cfg(test)] +mod composition_tests { + use super::join_swift_composition_continuations as join; + + #[test] + fn a_body_composition_moves_its_ampersand_up() { + let source = "class S {\n typealias C = A.Client\n & B.Client\n & D\n}\n"; + let joined = join(source); + assert_eq!( + joined, + "class S {\n typealias C = A.Client&\n B.Client&\n D\n}\n" + ); + assert_eq!(joined.len(), source.len()); + assert_eq!(joined.lines().count(), source.lines().count()); + } + + #[test] + fn other_ampersand_lines_are_left_alone() { + for source in [ + // A top-level declaration parses as written. + "typealias C = A\n & B\n", + // Not a typealias/let/var statement. + " if ready\n && done {}\n", + // `&&`, `&+` and an inout argument are other operators. + " let x = a\n &&b\n", + " let x = a\n &+ b\n", + " let x = f(\n &y)\n", + // A trailing comment would swallow the moved `&`. + " let c: any A // client\n & B\n", + ] { + assert_eq!(join(source), source, "{source:?}"); + } + } +} diff --git a/crates/codegraph-extract/src/lang/tsx.rs b/crates/codegraph-extract/src/lang/tsx.rs index 757b9010..75423c3c 100644 --- a/crates/codegraph-extract/src/lang/tsx.rs +++ b/crates/codegraph-extract/src/lang/tsx.rs @@ -73,6 +73,9 @@ impl LanguageSpec for TsxSpec { fn return_field(&self) -> &'static str { TYPESCRIPT_SPEC.return_field() } + fn pre_parse(&self, source: &str, file_path: &str) -> String { + TYPESCRIPT_SPEC.pre_parse(source, file_path) + } fn resolve_body<'tree>(&self, node: Node<'tree>, body_field: &str) -> Option> { TYPESCRIPT_SPEC.resolve_body(node, body_field) } diff --git a/crates/codegraph-extract/src/lang/typescript.rs b/crates/codegraph-extract/src/lang/typescript.rs index ffb27a2a..ed4c6a61 100644 --- a/crates/codegraph-extract/src/lang/typescript.rs +++ b/crates/codegraph-extract/src/lang/typescript.rs @@ -2,7 +2,10 @@ //! //! Direct port of `upstream extraction/languages/typescript.ts:4-118`. +use std::sync::OnceLock; + use codegraph_core::types::Language; +use regex::Regex; use tree_sitter::{Language as TsLanguage, Node}; use crate::spec::{ImportInfo, LanguageSpec}; @@ -99,6 +102,10 @@ impl LanguageSpec for TypeScriptSpec { "return_type" } + fn pre_parse(&self, source: &str, file_path: &str) -> String { + blank_flow_syntax(source, file_path) + } + fn resolve_body<'tree>(&self, node: Node<'tree>, body_field: &str) -> Option> { if node.kind() != "public_field_definition" { return None; @@ -170,7 +177,7 @@ impl LanguageSpec for TypeScriptSpec { } current = parent.parent(); } - false + is_ambient_export(node) } fn is_async(&self, node: Node<'_>) -> bool { @@ -199,6 +206,125 @@ impl LanguageSpec for TypeScriptSpec { } } +/// Whether `node` is declared in a `declare module 'x' { … }` or `declare +/// global { … }` body, which exports it without an `export` keyword (upstream +/// #2418 `isAmbientExport`). +/// +/// This is TypeScript's own rule: the binder makes an ambient module body an +/// export context unless the body holds an export declaration (`export {…}`, +/// `export * from`, `export =`, `export default x`), and a namespace nested in +/// one is ambient too. What such a body declares belongs to that module or to +/// the global scope, not to this file (`interface Window`, chart.js's +/// `PluginOptionsByType`): other files reach it through them, and so does the +/// library whose type it extends. `declare namespace X` is not one: in a +/// module file, X belongs to that file. +pub(crate) fn is_ambient_export(node: Node<'_>) -> bool { + let mut current = node.parent(); + while let Some(parent) = current { + if parent.kind() == "statement_block" && has_export_declaration(parent) { + return false; + } + if parent.kind() == "ambient_declaration" { + // `declare global` is the keyword and a bare block, with no field + // names. + if (0..parent.child_count()).any(|i| { + parent + .child(i) + .is_some_and(|child| child.kind() == "global") + }) { + return true; + } + if parent.named_child(0).is_some_and(|declared| { + declared.kind() == "module" + && child_by_field(declared, "name").is_some_and(|name| name.kind() == "string") + }) { + return true; + } + } + current = parent.parent(); + } + false +} + +/// A statement in `block` that exports by name or by assignment, not by +/// declaring. +fn has_export_declaration(block: Node<'_>) -> bool { + (0..block.named_child_count()).any(|i| { + block.named_child(i as u32).is_some_and(|statement| { + statement.kind() == "export_statement" + && child_by_field(statement, "declaration").is_none() + }) + }) +} + +/// JavaScript's `\s` as a regex class: whitespace and line terminators, +/// `U+FEFF` included. +const JS_SPACE: &str = r"[\t\n\x0B\x0C\r \x{A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]"; + +/// Flow's own syntax blanked to spaces, so a Flow-typed file reads as TSX +/// (upstream #2161 `blankFlowSyntax`): exact object types `{| a: T |}`, a +/// maybe type's `?` (`x: ?string`, `Array`), an inexact object's bare +/// `...`, `import typeof` and `opaque type`. Only a `.js`, `.jsx`, `.mjs` or +/// `.cjs` file with the `@flow` pragma changes, the files detection reads as +/// TSX for it. Every byte offset survives, and so does every line: a line +/// break inside `import typeof` stays, where upstream's blanks it too. +pub(crate) fn blank_flow_syntax(source: &str, file_path: &str) -> String { + static IMPORT_TYPEOF: OnceLock = OnceLock::new(); + static OPAQUE: OnceLock = OnceLock::new(); + static INEXACT: OnceLock = OnceLock::new(); + let script = [".js", ".jsx", ".mjs", ".cjs"] + .iter() + .any(|extension| file_path.ends_with(extension)); + if !script || !crate::engine::has_flow_pragma(source) { + return source.to_string(); + } + let code = source.replace("{|", "{ ").replace("|}", " }"); + // A maybe type's `?` after `:`, `<` or `,`, before what it qualifies. + let mut bytes = code.clone().into_bytes(); + for (at, punctuation) in code.char_indices() { + if !matches!(punctuation, ':' | '<' | ',') { + continue; + } + let rest = &code[at + 1..]; + let mark = rest.trim_start_matches(codegraph_core::generated_header::is_js_whitespace); + let qualifies = mark + .strip_prefix('?') + .and_then(|after| after.chars().next()); + if qualifies.is_some_and(|c| c.is_ascii_alphanumeric() || "_$({[".contains(c)) { + bytes[at + 1 + rest.len() - mark.len()] = b' '; + } + } + let code = String::from_utf8(bytes).expect("a `?` blanked to a space keeps UTF-8"); + let code = IMPORT_TYPEOF + .get_or_init(|| { + Regex::new(&format!(r"(?-u:\b)import{JS_SPACE}+typeof(?-u:\b)")).expect("import typeof") + }) + .replace_all(&code, |found: ®ex::Captures<'_>| { + let typeof_clause = &found[0]["import".len()..]; + let blank: String = typeof_clause + .bytes() + .map(|byte| { + if matches!(byte, b'\n' | b'\r') { + byte as char + } else { + ' ' + } + }) + .collect(); + format!("import{blank}") + }); + let code = OPAQUE + .get_or_init(|| { + Regex::new(&format!(r"(?-u:\b)opaque({JS_SPACE}+type(?-u:\b))")).expect("opaque type") + }) + .replace_all(&code, " ${1}"); + // An inexact object type's bare `...` (`{a: T, ...}`), never a spread. + INEXACT + .get_or_init(|| Regex::new(&format!(r"\.\.\.({JS_SPACE}*[}},])")).expect("inexact object")) + .replace_all(&code, " ${1}") + .into_owned() +} + fn has_direct_child_kind(node: Node<'_>, kind: &str) -> bool { (0..node.child_count()).any(|i| node.child(i).is_some_and(|child| child.kind() == kind)) } @@ -234,3 +360,181 @@ pub(crate) fn class_field_is_callable(node: Node<'_>, field_kind: &str) -> bool } false } + +#[cfg(test)] +mod tests { + use codegraph_core::types::{Language, NodeKind}; + + use super::blank_flow_syntax; + + fn blank(code: &str) -> String { + let source = format!("// @flow\n{code}"); + let out = blank_flow_syntax(&source, "a.js"); + assert_eq!(out.len(), source.len(), "{code:?}"); + out.strip_prefix("// @flow\n") + .expect("pragma line") + .to_string() + } + + #[test] + fn flow_only_syntax_becomes_spaces() { + assert_eq!(blank("type A = {| a: T |};"), "type A = { a: T };"); + assert_eq!( + blank("let x: ?string = f(y, ?z);"), + "let x: string = f(y, z);" + ); + assert_eq!(blank("type L = Array;"), "type L = Array< T>;"); + assert_eq!(blank("f: ?(e: E) => void"), "f: (e: E) => void"); + assert_eq!(blank("type O = {a: T, ...};"), "type O = {a: T, };"); + assert_eq!( + blank("import typeof * as R from 'r';"), + "import * as R from 'r';" + ); + assert_eq!( + blank("export opaque type T = string;"), + "export type T = string;" + ); + } + + #[test] + fn javascript_that_only_looks_like_flow_stays() { + for code in [ + "const b = a ? c : d;", + "const v = a?.b ?? c;", + "const o = {...rest};", + "f(...args, [...xs]);", + "const opaque = 1;", + "opaque.type = 2;", + "importtypeof(x);", + "import typeofx from 'y';", + ] { + assert_eq!(blank(code), code); + } + } + + #[test] + fn blanking_keeps_every_byte_offset_and_line() { + // A no-break space is two bytes; it becomes two spaces. + assert_eq!( + blank("import\u{a0}typeof R from 'r';"), + "import R from 'r';" + ); + assert_eq!( + blank("import\ntypeof R from 'r';"), + "import\n R from 'r';" + ); + assert_eq!(blank("x:\n ?T"), "x:\n T"); + } + + #[test] + fn only_flow_typed_javascript_paths_are_blanked() { + let flow = "// @flow\ntype A = {| a: ?T |};"; + for path in ["a.js", "a.jsx", "a.mjs", "a.cjs"] { + assert_ne!(blank_flow_syntax(flow, path), flow, "{path}"); + } + for path in ["a.ts", "a.tsx", "a.JS", "a.vue"] { + assert_eq!(blank_flow_syntax(flow, path), flow, "{path}"); + } + let plain = "type A = {| a: ?T |};"; + assert_eq!(blank_flow_syntax(plain, "a.js"), plain); + } + + /// Upstream #2418's module file: augmentations, a global block, and the + /// namespaces that are neither (`__tests__/ts-ambient-exports.test.ts`). + const MODULE_SOURCE: &str = "import type { ChartType } from 'chart.js';\n\ndeclare module 'chart.js' {\n interface PluginOptionsByType {\n verticalHoverLine: TType;\n }\n export interface WrittenOut {}\n namespace Plugins {\n interface Nested {}\n }\n type Alias = string;\n enum Position { Top }\n class Positioner {}\n const defaults: Alias;\n}\n\ndeclare global {\n interface Window {\n __conduit_debug__?: unknown;\n }\n namespace NodeJS {\n interface ProcessEnv {\n API_URL?: string;\n }\n }\n var bitwardenContainerService: unknown;\n}\n\ndeclare namespace Settings {\n interface OwnNamespace {}\n}\n\nnamespace Plain {\n interface Hidden {}\n export interface Shown {}\n}\n\ninterface FileLocal {}\n\nexport const appConfig = {};\n"; + + /// A script declaring modules whole, as an untyped library's typings do. + const SCRIPT_SOURCE: &str = "declare module 'legacy-lib' {\n interface Options {}\n const main: (options: Options) => void;\n export = main;\n}\n\ndeclare module 'listed-lib' {\n interface Unlisted {}\n export interface Listed {}\n export { Unlisted as Renamed };\n}\n\ndeclare module 'open-lib' {\n interface Open {}\n}\n"; + + /// `name exported` for every declaration a statement declares, sorted. + fn export_flags(path: &str, source: &str, language: Language) -> Vec<(String, bool)> { + let result = crate::extract_source(path, source, Some(language)); + let mut flags: Vec<(String, bool)> = result + .nodes + .iter() + .filter(|node| { + matches!( + node.kind, + NodeKind::Interface + | NodeKind::Class + | NodeKind::Enum + | NodeKind::TypeAlias + | NodeKind::Function + | NodeKind::Variable + | NodeKind::Constant + | NodeKind::Component + ) + }) + .map(|node| (node.name.clone(), node.is_exported)) + .collect(); + flags.sort(); + flags + } + + fn flags(pairs: &[(&str, bool)]) -> Vec<(String, bool)> { + let mut out: Vec<(String, bool)> = pairs + .iter() + .map(|(name, exported)| (name.to_string(), *exported)) + .collect(); + out.sort(); + out + } + + #[test] + fn declare_module_and_declare_global_bodies_export_their_declarations() { + for crlf in [false, true] { + let eol = |source: &str| { + if crlf { + source.replace('\n', "\r\n") + } else { + source.to_string() + } + }; + assert_eq!( + export_flags( + "src/chart.registry.ts", + &eol(MODULE_SOURCE), + Language::TypeScript + ), + flags(&[ + ("PluginOptionsByType", true), + ("WrittenOut", true), + ("Nested", true), + ("Alias", true), + ("Position", true), + ("Positioner", true), + ("defaults", true), + ("Window", true), + ("ProcessEnv", true), + ("bitwardenContainerService", true), + ("OwnNamespace", false), + ("Hidden", false), + ("Shown", true), + ("FileLocal", false), + ("appConfig", true), + ]), + "crlf {crlf}" + ); + // A body with an export declaration exports only what it names. + assert_eq!( + export_flags("types/legacy.ts", &eol(SCRIPT_SOURCE), Language::TypeScript), + flags(&[ + ("Options", false), + ("main", false), + ("Unlisted", false), + ("Listed", true), + ("Open", true), + ]), + "crlf {crlf}" + ); + let tsx = eol( + "declare module '@tanstack/react-router' {\n interface Register {\n router: unknown;\n }\n}\nexport const App = () =>
      ;\n", + ); + assert!( + export_flags("src/main.tsx", &tsx, Language::Tsx) + .contains(&("Register".to_string(), true)), + "crlf {crlf}" + ); + } + } +} diff --git a/crates/codegraph-extract/src/lib.rs b/crates/codegraph-extract/src/lib.rs index bcc2a9a0..95e4a025 100644 --- a/crates/codegraph-extract/src/lib.rs +++ b/crates/codegraph-extract/src/lib.rs @@ -8,14 +8,17 @@ clippy::manual_contains )] +mod commonjs_requires; pub mod embedded; pub mod engine; pub mod ext_config; pub mod function_ref; pub mod lang; mod links; +mod pascal_include; pub mod spec; pub mod syntax_tokens; +mod value_refs; pub mod walker; pub use codegraph_core::source_file::{ @@ -23,8 +26,9 @@ pub use codegraph_core::source_file::{ }; pub use engine::{ ExtractOptions, ExtractionStage, PARSE_COLLAPSE_WARNING, RootGitignore, detect_language, - detect_language_with, extract_file, extract_file_with_options, - extract_file_with_options_observer, extract_project, extract_source, extract_source_with, - extract_source_with_observer, include_exclude_pattern_matches, is_extraction_warning, + detect_language_for_source, detect_language_of, detect_language_with, extract_file, + extract_file_with_options, extract_file_with_options_observer, extract_project, extract_source, + extract_source_with, extract_source_with_observer, include_exclude_pattern_matches, + is_extraction_warning, }; pub use ext_config::ExtensionOverrides; diff --git a/crates/codegraph-extract/src/pascal_include.rs b/crates/codegraph-extract/src/pascal_include.rs new file mode 100644 index 00000000..0a1cc661 --- /dev/null +++ b/crates/codegraph-extract/src/pascal_include.rs @@ -0,0 +1,194 @@ +//! `.inc` is PHP's include extension (Drupal) and Pascal's too (`{$I +//! defs.inc}`: directive blocks, declaration fragments), so a file of it is +//! read by its content (upstream #2279, in the shape at `b635dd46`). +//! +//! A PHP include always opens a PHP tag somewhere, so a tag anywhere keeps +//! the file PHP. Without one, a line shape only Pascal writes (a compiler +//! directive, a `;`-closed routine header, `unit` / `uses`, a `const` / +//! `var` / `type` section, a `begin` … `end;` pair, Lazarus' `{%MainUnit}`) +//! makes it Pascal. Anything else keeps the PHP mapping, under which untagged +//! text is inline HTML with nothing to extract, rather than handing a C, +//! assembly, POV-Ray, SourcePawn or ASP include to the Pascal grammar. The +//! answer depends on the file's own bytes only, so a full index, a sync of +//! one edited include and a fresh index always agree. +//! +//! The shapes are upstream's patterns, read as JavaScript reads them: `\w` +//! is ASCII, and the negative lookahead after a `var` declaration's colon +//! (`:(?!:)`) is written as the class it stands for. + +use std::sync::OnceLock; + +use regex::{Regex, RegexSet}; + +/// A line start: indentation and the BOM Windows editors write. +const LINE: &str = r"^[ \t\x{FEFF}]*"; +/// A name, with type arguments. +const NAME: &str = r"[a-z_][0-9A-Za-z_]*(?:<[^>\n]*>)?"; +/// A dotted name, with type arguments. +const QNAME: &str = r"[a-z_][0-9A-Za-z_.]*(?:<[^>\n]*>)?"; +/// A comment closing a line: `// …` or `{ … }`, never a `{$directive}`. +const COMMENT: &str = r"(?://[^\n]*|\{[^$}\n][^}\n]*\}[ \t]*)?"; + +/// A PHP open tag: ` &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?i)<\?(?:php|=)").expect("PHP open tag regex")) +} + +/// The line shapes only Pascal writes, any one of which makes an untagged +/// `.inc` Pascal (upstream `PASCAL_INCLUDE_SIGNALS`). +fn pascal_signals() -> &'static RegexSet { + static SET: OnceLock = OnceLock::new(); + SET.get_or_init(|| { + // A section keyword's break runs to the end of its line and over + // blank or comment-only lines, up to the first declaration. + let section_break = format!(r"[ \t]*{COMMENT}(?:\r?\n[ \t]*{COMMENT})+"); + let shapes = [ + // A compiler directive: a name and an argument (`{$IFDEF X}`, + // `{$I file.inc}`), a bare `{$ELSE}` / `{$ENDIF}` / `{$IFEND}`, a + // switch (`{$R-}`, `{$A+,B-}`), or Lazarus' `{%MainUnit x.pp}`. + format!( + r"{LINE}\{{(?:\$(?:[a-z][0-9A-Za-z_]*[ \t]+[^\s}}]|(?:else|endif|ifend)[ \t]*\}}|[a-z][+-][,}}])|%MainUnit(?-u:\b))" + ), + // A routine header closed by `;`, a function's result type after + // its parameters (bare `function Bar;` is the implementation + // section's short form). + format!( + r"{LINE}(?:class[ \t]+)?(?:(?:procedure|constructor|destructor)[ \t]+{QNAME}[ \t]*(?:\([^()]*\)[ \t]*)?|function[ \t]+{QNAME}[ \t]*(?:(?:\([^()]*\)[ \t]*)?:[ \t]*[0-9A-Za-z_.]+(?:<[^>\n]*>)?[ \t]*)?);" + ), + // `unit Foo;` and a `uses A, B;` clause. + format!(r"{LINE}unit[ \t]+[a-z_][0-9A-Za-z_.]*[ \t]*;"), + format!( + r"{LINE}uses\s+[a-z_][0-9A-Za-z_.]*(?:\s*,\s*[a-z_][0-9A-Za-z_.]*)*\s*;" + ), + // A `const` section, then `X = …` / `X: T = …`; or a typed + // constant on one line. The type never holds a `:`. + format!( + r"{LINE}(?:const|resourcestring)(?:{section_break}[a-z_][0-9A-Za-z_]*[ \t]*(?::[^=;:\n]+)?=|[ \t]+[a-z_][0-9A-Za-z_]*[ \t]*:[^=;:\n]+=)" + ), + // A `var` section: `G, H: Integer;`, on the keyword's line or + // below it; the colon is no `::`. + format!( + r"{LINE}(?:var|threadvar)(?:{section_break}|[ \t]+)[a-z_][0-9A-Za-z_]*(?:[ \t]*,[ \t]*[a-z_][0-9A-Za-z_]*)*[ \t]*:(?:[^:;\n][^;\n]*)?;" + ), + // A `type` section, then `TFoo =`; or `type TFoo = class…` + // (record, interface, set of, array, procedure type) on one line. + format!( + r"{LINE}type(?:{section_break}{NAME}[ \t]*=|[ \t]+{NAME}[ \t]*=[ \t]*(?:packed[ \t]+)?(?:class|record|object|interface|dispinterface|set[ \t]+of|array|reference[ \t]+to|procedure|function)(?-u:\b))" + ), + ]; + RegexSet::new(shapes.iter().map(|shape| format!("(?im){shape}"))) + .expect("Pascal include signal regexes") + }) +} + +/// A `begin` … `end;` block: both halves needed, so neither alone flips a +/// file. +fn begin_end() -> &'static (Regex, Regex) { + static PAIR: OnceLock<(Regex, Regex)> = OnceLock::new(); + PAIR.get_or_init(|| { + ( + Regex::new(&format!(r"(?im){LINE}begin(?-u:\b)")).expect("Pascal begin regex"), + Regex::new(&format!(r"(?im){LINE}end[ \t]*[;.][ \t\r]*$")).expect("Pascal end regex"), + ) + }) +} + +/// Whether an `.inc` file's `source` is a Pascal include rather than a PHP +/// one (upstream `looksLikePascalInclude`). +pub(crate) fn looks_like_pascal_include(source: &str) -> bool { + if php_open_tag().is_match(source) { + return false; + } + if pascal_signals().is_match(source) { + return true; + } + let (begin, end) = begin_end(); + begin.is_match(source) && end.is_match(source) +} + +#[cfg(test)] +mod tests { + use super::looks_like_pascal_include as pascal; + + const DIRECTIVES: &str = "{ Compiler version switches }\n{$IFDEF VER150}\n {$DEFINE DELPHI7}\n{$ENDIF}\n{$IFNDEF FPC}\n {$WARN SYMBOL_PLATFORM OFF}\n{$ELSE}\n {$MODE DELPHI}{$H+}\n{$ENDIF}\n"; + const ROUTINES: &str = "procedure Foo;\nbegin\n WriteLn('foo');\nend;\n\nfunction Bar(A: Integer): Integer;\nbegin\n Result := A + 1;\nend;\n"; + + /// Upstream `pascal-inc-detection.test.ts`: `reads a directive-only + /// include as Pascal`, `skips a leading UTF-8 BOM and CRLF line endings`. + #[test] + fn pr2289_directives_bom_and_crlf_read_as_pascal() { + assert!(pascal(DIRECTIVES)); + assert!(pascal("{$I other.inc}\n")); + assert!(pascal("{$R-}\n{$Q-}\n")); + assert!(pascal("{$define FOO}\n")); + assert!(pascal("{%MainUnit ../controls.pp}\n{ comment }\n")); + assert!(pascal( + "\u{FEFF}{$IFDEF WIN32}\r\n{$DEFINE X}\r\n{$ENDIF}\r\n" + )); + assert!(pascal("\u{FEFF}procedure Foo;\r\n")); + } + + /// Upstream: `reads a declaration fragment as Pascal`. + #[test] + fn pr2289_declaration_fragments_read_as_pascal() { + for source in [ + ROUTINES, + "procedure Foo;\nfunction Bar(A: Integer): Integer;\n", + "procedure TForm1.Button1Click(Sender: TObject);\n", + "const\n X = 1;\n", + "const // ids\n{ Internal functions }\n fpc_in_lo = 1;\n", + "const Max: Integer = 10;\n", + "type\n TColor = (Red, Green);\n", + "type TFoo = class(TObject)\nend;\n", + "var\n G, H: Integer;\n", + "uses\n SysUtils, Classes;\n", + "unit Foo;\ninterface\nimplementation\nend.\n", + ] { + assert!(pascal(source), "{source:?}"); + } + } + + /// Upstream: `keeps an include with a PHP open tag as PHP`. + #[test] + fn pr2289_a_php_open_tag_keeps_php() { + for source in [ + "
      \n", + "

      \nprocedure Foo;\n

      \n\n", + ] { + assert!(!pascal(source), "{source:?}"); + } + } + + /// Upstream: `leaves non-Pascal, untagged includes on the PHP mapping`. + #[test] + fn pr2289_other_includes_stay_off_pascal() { + for source in [ + "#define X 1\n#define Y 2\nconst int z = 3;\n", + "OPCODE(ADD, 0x01)\nOPCODE(SUB, 0x02)\n", + "const file_t::value_type file_t::Invalid = nullptr;\n", + "typeset MenuHandler\n{\n\tfunction int (Menu menu, MenuAction action);\n\tfunction void (Menu menu);\n};\n", + "%define STACK 0x1000\n; comment\nfoo equ 1\nmov eax, 1\n", + "#declare R = 1;\n#macro M(a)\n#end\n", + "Const X = 1\nFunction Foo(a)\n Foo = a\nEnd Function\n", + "CC := gcc\nCFLAGS := -O2\n", + "\n{$title}\n", + "
    • \n Implementation\n
    • \n
    • \n Begin\n
    • \n", + "", + ] { + assert!(!pascal(source), "{source:?}"); + } + } + + /// The `var` shape's colon is no `::` (upstream's `(?!:)`), and a + /// `begin` needs its `end;`, a CRLF one included. + #[test] + fn keep_rust_the_lookahead_and_line_ends_read_as_upstream() { + assert!(pascal("var G:;\n")); + assert!(!pascal("var G::T;\n")); + assert!(pascal("begin\r\n X := 1;\r\nend;\r\n")); + assert!(!pascal("begin\n X := 1;\n")); + } +} diff --git a/crates/codegraph-extract/src/spec.rs b/crates/codegraph-extract/src/spec.rs index 77659854..9d767cc7 100644 --- a/crates/codegraph-extract/src/spec.rs +++ b/crates/codegraph-extract/src/spec.rs @@ -88,6 +88,21 @@ pub trait LanguageSpec: Sync { None } + /// The node a declaration is wrapped in, whose preceding comment documents + /// it (upstream `getDeclarationWrapper`): a C/C++ type defined in a + /// declaration's type, `struct Foo { … } foo;`, is documented by the + /// comment above the declaration. + fn declaration_wrapper<'tree>(&self, _node: Node<'tree>) -> Option> { + None + } + + /// Whether a docstring leaves out the comments that belong to the line + /// above it (upstream `docstringSkipsTrailingComments`): one written after + /// code on its line, and any that begins on the line such a comment ends. + fn docstring_skips_trailing_comments(&self) -> bool { + false + } + fn classify_class_node(&self, _node: Node<'_>) -> NodeKind { NodeKind::Class } diff --git a/crates/codegraph-extract/src/value_refs.rs b/crates/codegraph-extract/src/value_refs.rs new file mode 100644 index 00000000..52f5ae8a --- /dev/null +++ b/crates/codegraph-extract/src/value_refs.rs @@ -0,0 +1,305 @@ +//! Same-file value references (upstream #895, #897, `flushValueRefs`): a +//! `references` edge from a symbol to a constant or variable of its own file +//! that it reads, so impact analysis reaches the readers of a shared value +//! ("change this table, affect its readers"). The edge carries +//! `{"valueRef": true}`. +//! +//! Same-file only, so the target is never a guess. Only a distinctive name is +//! a target: three or more characters, one of them an uppercase letter or +//! `_`. A name the file binds again in an inner scope is no target at all, +//! since its nested readers read that binding. Always on (OD-8): upstream's +//! `CODEGRAPH_VALUE_REFS=0` switch is not ported. + +use std::collections::{HashMap, HashSet}; + +use codegraph_core::file_class::is_generated_file; +use codegraph_core::types::{Edge, EdgeKind, Language, NodeKind}; +use tree_sitter::Node as SyntaxNode; + +use crate::walker::{child_by_field, node_text}; + +/// How many syntax nodes one scan visits at most. +const MAX_VALUE_REF_NODES: usize = 20_000; + +/// The languages whose symbols get value references. +pub(crate) fn has_value_refs(language: Language) -> bool { + matches!( + language, + Language::TypeScript + | Language::JavaScript + | Language::Tsx + | Language::ArkTs + | Language::Go + | Language::Python + | Language::Php + | Language::Scala + | Language::Rust + | Language::Ruby + | Language::C + | Language::Pascal + | Language::Java + | Language::CSharp + | Language::Kotlin + | Language::Swift + | Language::Dart + ) +} + +/// A symbol whose syntax is scanned for the values it reads. +struct Reader<'tree> { + id: String, + name: String, + node: SyntaxNode<'tree>, +} + +/// What one file's walk records for its value references. +#[derive(Default)] +pub(crate) struct ValueRefs<'tree> { + /// Target name -> node id; a later declaration of the name wins. + targets: HashMap, + /// How many target nodes carry each name. + target_counts: HashMap, + readers: Vec>, +} + +impl<'tree> ValueRefs<'tree> { + /// Record a node as it is created: a constant or variable declared at file + /// scope, or in a class, module, struct or enum, with a distinctive name + /// is a target; a function, method, constant or variable is a reader. + /// `parent_id` is the scope the node is declared in, `None` for a value no + /// other scope names by this name. + pub(crate) fn capture( + &mut self, + language: Language, + kind: NodeKind, + name: &str, + id: &str, + node: SyntaxNode<'tree>, + parent_id: Option<&str>, + ) { + // Pascal's shared values are its `const`s: a Pascal `variable` is a + // routine's parameter or a class field, which would be noise. + let target_kind = if language == Language::Pascal { + kind == NodeKind::Constant + } else { + matches!(kind, NodeKind::Constant | NodeKind::Variable) + }; + if target_kind && is_distinctive(name) && parent_id.is_some_and(is_target_scope) { + self.targets.insert(name.to_string(), id.to_string()); + *self.target_counts.entry(name.to_string()).or_default() += 1; + } + if matches!( + kind, + NodeKind::Function | NodeKind::Method | NodeKind::Constant | NodeKind::Variable + ) { + self.readers.push(Reader { + id: id.to_string(), + name: name.to_string(), + node, + }); + } + } + + /// Scan `node` for the reads of the reader `id` instead of the syntax its + /// node was created from: a Go value's node is its name, while what it + /// reads is in its whole `const_spec` / `var_spec`, upstream's reader. + pub(crate) fn scan_reader_from(&mut self, id: &str, node: SyntaxNode<'tree>) { + if let Some(reader) = self.readers.iter_mut().rev().find(|reader| reader.id == id) { + reader.node = node; + } + } + + /// The value-reference edges of the file whose tree is `root`. + pub(crate) fn into_edges( + mut self, + language: Language, + file_path: &str, + root: SyntaxNode<'tree>, + source: &str, + ) -> Vec { + if !has_value_refs(language) + || self.targets.is_empty() + || self.readers.is_empty() + || is_generated_file(file_path) + { + return Vec::new(); + } + self.drop_shadowed_targets(root, source); + if self.targets.is_empty() { + return Vec::new(); + } + let mut edges = Vec::new(); + for reader in &self.readers { + let mut seen = HashSet::new(); + let mut stack = vec![reader.node]; + // A Pascal routine's body is a sibling of its header, the reader's + // node (`defProc` holds `declProc` and `block`). + if language == Language::Pascal + && let Some(body) = reader.node.next_named_sibling() + && body.kind() == "block" + { + stack.push(body); + } + let mut visited = 0; + while let Some(node) = stack.pop() { + if visited >= MAX_VALUE_REF_NODES { + break; + } + visited += 1; + if matches!( + node.kind(), + "identifier" | "constant" | "name" | "simple_identifier" + ) { + let name = node_text(node, source); + if let Some(target) = self.targets.get(&name) + && *target != reader.id + && name != reader.name + && seen.insert(target.clone()) + { + edges.push(value_ref_edge(&reader.id, target)); + } + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + } + } + edges + } + + /// A name the file declares more often than it has target nodes of that + /// name is bound again in an inner scope; it is no target. A conditional + /// definition at file scope (`try: X = a` / `except: X = b`) declares it + /// as often as it has nodes and stays. + fn drop_shadowed_targets(&mut self, root: SyntaxNode<'tree>, source: &str) { + let mut declared: HashMap = HashMap::new(); + let mut bump = |name_node: Option>| { + if let Some(name_node) = name_node + && matches!(name_node.kind(), "identifier" | "simple_identifier") + { + let name = node_text(name_node, source); + if self.targets.contains_key(&name) { + *declared.entry(name).or_default() += 1; + } + } + }; + let mut stack = vec![root]; + let mut visited = 0; + while let Some(node) = stack.pop() { + if visited >= MAX_VALUE_REF_NODES { + break; + } + visited += 1; + match node.kind() { + // TS/JS `const X = …`; Go `const X = …` / `var X = …`. + "variable_declarator" | "const_spec" | "var_spec" => bump(node.named_child(0)), + // Rust `const X: T = …` / `static X: T = …`; Pascal `const X = + // …` (the target and a routine's own) and `var X: T`. + "const_item" | "static_item" | "declConst" | "declVar" => { + bump(child_by_field(node, "name")); + } + // C `T X = …`: the file-scope value and a local that shadows it. + "init_declarator" => bump(crate::lang::c_declarator_identifier(Some(node))), + // Kotlin / Swift `val`/`let X = …`: an object's or a type's + // static value, and a local that shadows it. + "property_declaration" => bump(property_declarator_name(node)), + // Dart: a top-level or `static` constant, a field or `var`, and + // a local. + "static_final_declaration" + | "initialized_identifier" + | "initialized_variable_definition" => bump( + node.named_children(&mut node.walk()) + .find(|child| child.kind() == "identifier"), + ), + // Scala `val X = …` / `var X = …`. + "val_definition" | "var_definition" => { + bump(child_by_field(node, "pattern").filter(|p| p.kind() == "identifier")); + } + // Rust `let x = …`; Go `x, Y := …`; Python `X = …`, `A, B = …`. + "let_declaration" | "short_var_declaration" | "assignment" => { + let left = child_by_field(node, "left") + .or_else(|| child_by_field(node, "pattern")) + .or_else(|| node.named_child(0)); + match left { + Some(left) if left.kind() == "identifier" => bump(Some(left)), + Some(left) => { + for child in left.named_children(&mut left.walk()) { + bump(Some(child)); + } + } + None => {} + } + } + _ => {} + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + } + for (name, count) in declared { + if count > self.target_counts.get(&name).copied().unwrap_or(1) { + self.targets.remove(&name); + } + } + } +} + +/// The name a Kotlin or Swift `property_declaration` binds: kotlin-ng's +/// `variable_declaration` identifier, or the first `simple_identifier` of a +/// Swift pattern. +fn property_declarator_name(node: SyntaxNode<'_>) -> Option> { + if let Some(declaration) = node + .named_children(&mut node.walk()) + .find(|child| child.kind() == "variable_declaration") + { + return declaration + .named_children(&mut declaration.walk()) + .find(|child| child.kind() == "identifier"); + } + let pattern = child_by_field(node, "name").or_else(|| { + node.named_children(&mut node.walk()) + .find(|child| matches!(child.kind(), "value_binding_pattern" | "pattern")) + })?; + first_simple_identifier(pattern) +} + +/// The first `simple_identifier` at or below `node`, breadth first. +fn first_simple_identifier(node: SyntaxNode<'_>) -> Option> { + let mut queue = std::collections::VecDeque::from([node]); + let mut guard = 0; + while let Some(current) = queue.pop_front() { + guard += 1; + if guard > 40 { + break; + } + if current.kind() == "simple_identifier" { + return Some(current); + } + queue.extend(current.named_children(&mut current.walk())); + } + None +} + +/// Three or more UTF-16 code units, one of them an ASCII uppercase letter or +/// `_` (upstream `name.length >= 3 && /[A-Z_]/`). +fn is_distinctive(name: &str) -> bool { + name.encode_utf16().count() >= 3 && name.chars().any(|c| c.is_ascii_uppercase() || c == '_') +} + +/// File, class, module, struct and enum scope hold shared values. +fn is_target_scope(parent_id: &str) -> bool { + ["file:", "class:", "module:", "struct:", "enum:"] + .iter() + .any(|prefix| parent_id.starts_with(prefix)) +} + +fn value_ref_edge(source: &str, target: &str) -> Edge { + Edge { + id: None, + source: source.to_string(), + target: target.to_string(), + kind: EdgeKind::References, + metadata: Some(serde_json::json!({ "valueRef": true })), + line: None, + col: None, + provenance: None, + } +} diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 041dcc01..8a34b530 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -10,6 +10,7 @@ use codegraph_core::types::{ Edge, EdgeKind, ExtractionResult, Language, Node, NodeKind, ReferenceSubkind, UnresolvedRef, }; use regex::Regex; +use std::collections::{HashMap, HashSet}; use std::path::Path; use tree_sitter::Node as SyntaxNode; @@ -95,10 +96,63 @@ fn is_literal_receiver(node: SyntaxNode<'_>) -> bool { LITERAL_RECEIVER_KINDS.contains(&node.kind()) } +/// G4 (upstream `STATIC_MEMBER_LANGS`) — the languages whose static-member +/// and enum-value reads (`Enum.value`, `Type.CONST`, `Foo::BAR`) reference the +/// type they read through. VB.NET's reads are its own (W8). +fn has_static_member_reads(language: Language) -> bool { + matches!( + language, + Language::Java + | Language::CSharp + | Language::Kotlin + | Language::Swift + | Language::Scala + | Language::Dart + | Language::Php + | Language::Cpp + | Language::Rust + ) +} + +/// The member-access kinds a static-member read is written as (upstream +/// `MEMBER_ACCESS_TYPES`): Java `field_access`, C# `member_access_expression`, +/// Kotlin/Swift `navigation_expression`, Scala `field_expression`, PHP +/// `class_constant_access_expression` / `scoped_property_access_expression`, +/// C++ `qualified_identifier`. Dart and Rust have paths of their own. +const MEMBER_ACCESS_KINDS: [&str; 7] = [ + "field_access", + "member_access_expression", + "navigation_expression", + "field_expression", + "class_constant_access_expression", + "scoped_property_access_expression", + "qualified_identifier", +]; + +/// Parents of a Rust `scoped_identifier` that is not a member written as a +/// value or a pattern: the prefix of a longer path, or a `use` tree. +const RUST_NON_MEMBER_PATH_PARENTS: [&str; 7] = [ + "scoped_identifier", + "scoped_type_identifier", + "use_declaration", + "use_list", + "scoped_use_list", + "use_as_clause", + "use_wildcard", +]; + +/// A single capitalized name: what a type is written as. +fn is_capitalized_name(text: &str) -> bool { + let mut chars = text.chars(); + chars.next().is_some_and(|c| c.is_ascii_uppercase()) + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + /// Call-node kinds whose `function` child names a member on a receiver — the /// `obj.method` family across the grammars (`member_expression` for TS/JS/ArkTS, /// `attribute` for Python, `field_expression` for Go/Rust/C/C++, -/// `navigation_expression` for Swift/Kotlin, plus C++ `qualified_identifier`). +/// `navigation_expression` for Swift/Kotlin, plus C++ `qualified_identifier` +/// as a receiver, `ns::obj.m()`; a qualified C++ callee keeps its scope, G13). const MEMBER_SHAPED_CALLEE_KINDS: [&str; 6] = [ "member_expression", "attribute", @@ -108,10 +162,34 @@ const MEMBER_SHAPED_CALLEE_KINDS: [&str; 6] = [ "qualified_identifier", ]; +/// A Swift receiver that is a path of types, two segments or more, each +/// capitalized and not led by `Self.` (upstream #2118 +/// `SWIFT_TYPE_PATH_RECEIVER`): `API.PackageController.GetRoute`, its +/// whitespace dropped. +fn swift_type_path_receiver(text: &str) -> Option { + let path: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + if path.starts_with("Self.") { + return None; + } + let segments: Vec<&str> = path.split('.').collect(); + let capitalized = |segment: &str| { + let mut chars = segment.chars(); + chars.next().is_some_and(|c| c.is_ascii_uppercase()) + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + }; + (segments.len() >= 2 && segments.iter().all(|segment| capitalized(segment))).then_some(path) +} + fn is_member_shaped_callee(node: SyntaxNode<'_>) -> bool { MEMBER_SHAPED_CALLEE_KINDS.contains(&node.kind()) } +/// A Dart member access through `?.` (`x?.m`), whose `object` and +/// `property` fields are a `member_expression`'s. +fn is_dart_null_aware_member(node: SyntaxNode<'_>) -> bool { + node.kind() == "null_aware_member_expression" +} + /// The member segment of a member-shaped node: `property` (TS/JS), `field` /// (Go/Rust/C/C++), else the second named child (Python `attribute`, Swift /// `navigation_expression`, C++ `qualified_identifier`). @@ -230,6 +308,100 @@ fn ts_js_chain_root(node: SyntaxNode<'_>) -> Option> { current } +/// CFML tags whose own children include an expression (upstream +/// `TAG_EXPRESSION_PARENTS`). +const CFML_TAG_EXPRESSION_PARENTS: [&str; 5] = [ + "cf_set_tag", + "cf_if_tag", + "cf_elseif_tag", + "cf_return_tag", + "hash_expression", +]; + +/// The expression node types in CFML tag markup that can contain a call +/// (upstream `TAG_EXPRESSION_TYPES`): a positive list, so a tag's markup, its +/// `var` keyword or an ERROR is never taken for an expression. +const CFML_TAG_EXPRESSION_TYPES: [&str; 19] = [ + "call_expression", + "member_expression", + "subscript_expression", + "new_expression", + "assignment_expression", + "augmented_assignment_expression", + "binary_expression", + "unary_expression", + "update_expression", + "ternary_expression", + "elvis_expression", + "parenthesized_expression", + "sequence_expression", + "string", + "array", + "object", + "ordered_struct", + "function_expression", + "arrow_function", +]; + +/// The grammar a CFML walk reads with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CfmlDialect { + /// Tag-based markup (``, ``, HTML). + Tag, + /// Bare script (`component { … }`) and `` bodies. + Script, + /// A `` SQL body with its `#hash#` expressions. + Query, +} + +/// [`utf16_column`] of the byte at 1-based `line` and byte `column`. +fn utf16_column_at(source: &str, line: i64, column: i64) -> u32 { + let line_start = source + .split_inclusive('\n') + .take(usize::try_from(line - 1).unwrap_or(0)) + .map(str::len) + .sum::(); + let byte = (line_start + usize::try_from(column).unwrap_or(0)).min(source.len()); + utf16_column(source, byte) +} + +/// `/^(variables|this|local|arguments)\.[A-Za-z_][\w]*$/i`: a CFML member +/// reached through one of its file-local scopes. +fn is_cfml_scope_receiver(text: &str) -> bool { + let Some((scope, member)) = text.split_once('.') else { + return false; + }; + ["variables", "this", "local", "arguments"] + .iter() + .any(|known| scope.eq_ignore_ascii_case(known)) + && member + .chars() + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && member + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// What a snippet walk found (see `TreeSitterWalker::walk_cfml_snippet`). +struct CfmlSnippet { + nodes: Vec, + edges: Vec, + references: Vec, + errors: Vec, +} + +/// Node kinds that construct an object, each yielding an `instantiates` +/// reference to the constructed type (upstream `INSTANTIATION_KINDS`). +const INSTANTIATION_KINDS: [&str; 6] = [ + "new_expression", // TypeScript, JavaScript + "object_creation_expression", // Java, C#, PHP + "instance_creation_expression", // some grammars + "composite_literal", // Go `Widget{...}`, `pkga.Widget{...}` + "struct_expression", // Rust `Widget { n: 1 }`, `m::Widget { .. }` + "instance_expression", // Scala `new Monoid[Int] { ... }` +]; + /// Whether a TS/JS receiver still collapses to the bare method name: `this` / /// `super` (resolution reads the owner off the enclosing class), a member /// chain rooted at either or at `window` (the project-global escape of @@ -323,6 +495,9 @@ pub struct TreeSitterWalker<'a, 'tree> { /// Same-line, same-name declarations keep distinct identities (#1349). node_ids: NodeIdAllocator, fn_ref_candidates: Vec<(crate::function_ref::FnRefCandidate, String)>, + /// Same-file value references (#895, #897): targets and readers recorded + /// as nodes are created, turned into edges once the walk is done. + value_refs: crate::value_refs::ValueRefs<'tree>, /// C++ enclosing `namespace ns { … }` names, prefixed onto contained /// symbols' `qualified_name`. Prefix-only (no namespace node) to avoid the /// #1093 crowd-out; empty outside C++. @@ -334,11 +509,10 @@ pub struct TreeSitterWalker<'a, 'tree> { erlang_last_fn_name: Option, erlang_last_fn_arity: Option, erlang_last_fn_id: Option, - /// CFML tag-dialect state: the byte offset up to which a - /// `cf_component_open_tag`'s following siblings (the implicit-end-tag - /// component body) have already been consumed, so the top-level `program` - /// walk skips them instead of re-visiting. 0 outside CFML tag files. - cfml_consumed_until: usize, + /// The CFML dialect a snippet walk reads (a `` or `` + /// body, upstream #2296); `None` for a file's own walk, whose dialect the + /// first token decides. + cfml_dialect: Option, /// Current named-node recursion depth. Root is depth 1. walk_depth: usize, /// Latched once a child would exceed [`MAX_WALK_DEPTH`]. All remaining @@ -347,6 +521,30 @@ pub struct TreeSitterWalker<'a, 'tree> { /// Set while an exported store's initializer is walked for the store's /// own calls; see [`StoreWalk`]. store_walk: Option, + /// An object literal hung on a path (`window.App = {…}`, `ns.mod = {…}`) + /// is named by the path's last link but qualified by the whole path, and + /// its members are qualified under that path (upstream #2363): owner node + /// id → path. + object_path_owners: HashMap, + /// Whether this file's named object literals own their function members; + /// see [`Self::owns_object_literals`]. + owns_objects: Option, + /// A C++ file whose tree has errors is walked in the scopes its braces + /// open (see [`Self::visit_in_cpp_brace_scope`], upstream #2426): the + /// scan of them, `None` for every other file, and the class-like nodes + /// extracted so far by the braces of their bodies. + cpp_brace_scopes: Option, + cpp_class_scopes: crate::lang::cpp_brace_scopes::NestedIntervals, + cpp_class_scope_ids: HashSet, + /// The source as written, before the language's pre-parse rewrote it (the + /// rewrite keeps every byte offset); `source` when there was none. + raw_source: &'a str, + /// C/C++: whether a class, struct, union or enum defined in the type of a + /// declaration outside any body is walked (upstream #2446). Not in a C++ + /// file whose tree has errors and whose braces do not balance: error + /// recovery can close a scope at the wrong `}`, and with no brace scopes + /// to correct it the walked class would land in the wrong one. + walk_declared_types: bool, } /// The parts of a store initializer that its walk must not treat as the @@ -378,15 +576,33 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { node_stack: Vec::new(), node_ids: NodeIdAllocator::default(), fn_ref_candidates: Vec::new(), + value_refs: crate::value_refs::ValueRefs::default(), namespace_prefix: Vec::new(), erlang_last_fn_name: None, erlang_last_fn_arity: None, erlang_last_fn_id: None, - cfml_consumed_until: 0, + cfml_dialect: None, walk_depth: 0, walk_aborted: false, store_walk: None, + object_path_owners: HashMap::new(), + owns_objects: None, + cpp_brace_scopes: None, + cpp_class_scopes: crate::lang::cpp_brace_scopes::NestedIntervals::default(), + cpp_class_scope_ids: HashSet::new(), + walk_declared_types: false, + raw_source: source, + } + } + + /// The source as written, when the pre-parse rewrote it in place; a text + /// of another length is ignored. + #[must_use] + pub fn with_raw_source(mut self, raw_source: &'a str) -> Self { + if raw_source.len() == self.source.len() { + self.raw_source = raw_source; } + self } pub fn extract(mut self, duration_ms: i64) -> ExtractionResult { @@ -394,6 +610,15 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { return self.depth_error_result(duration_ms); } + if self.spec.language() == Language::Cpp && self.root.has_error() { + self.cpp_brace_scopes = + crate::lang::cpp_brace_scopes::scan_cpp_brace_scopes(self.source); + } + self.walk_declared_types = match self.spec.language() { + Language::C => true, + Language::Cpp => !self.root.has_error() || self.cpp_brace_scopes.is_some(), + _ => false, + }; let file_node = self.create_file_node(); let file_id = file_node.id.clone(); self.nodes.push(file_node); @@ -413,6 +638,13 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } self.flush_fn_ref_candidates(); + let value_refs = std::mem::take(&mut self.value_refs); + self.edges.extend(value_refs.into_edges( + self.spec.language(), + self.file_path, + self.root, + self.source, + )); ExtractionResult { nodes: self.nodes, @@ -447,15 +679,110 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } self.walk_depth += 1; - self.visit_node_inner(node); + if self.cpp_brace_scopes.is_some() { + self.visit_in_cpp_brace_scope(node); + } else { + self.visit_node_inner(node); + } self.walk_depth -= 1; } + /// Walk a node of a C++ file whose tree has errors in the namespaces and + /// classes its source braces put it in (upstream #2426 + /// `visitInCppBraceScope`). That is where the tree put it unless error + /// recovery closed a scope at the wrong `}` or ran one past its own: the + /// rest of a class then parses as declarations of the namespace around + /// it (rocksdb's `struct Opts`), and the rest of each namespace one level + /// out (protobuf's `FieldDescriptor` at the top level). + /// + /// The namespaces apply everywhere. The enclosing classes are set only at + /// declaration level, where the stack above the file node holds nothing + /// but class scopes; inside a function (a local class's members) the + /// walk's own stack stands. + fn visit_in_cpp_brace_scope(&mut self, node: SyntaxNode<'tree>) { + let at = node.start_byte(); + let Some(scopes) = self.cpp_brace_scopes.as_ref() else { + self.visit_node_inner(node); + return; + }; + let namespaces = std::mem::replace(&mut self.namespace_prefix, scopes.namespaces_at(at)); + let mut base = self.node_stack.len(); + while base > 1 + && self + .cpp_class_scope_ids + .contains(&self.node_stack[base - 1]) + { + base -= 1; + } + let mut walked: Option> = None; + if base == 1 { + let classes: Vec = self.cpp_class_scopes.at(at).into_iter().cloned().collect(); + if self.node_stack[base..] != classes[..] { + walked = Some(self.node_stack.split_off(base)); + self.node_stack.extend(classes); + } + } + self.visit_node_inner(node); + if let Some(walked) = walked { + self.node_stack.truncate(base); + self.node_stack.extend(walked); + } + self.namespace_prefix = namespaces; + } + + /// Where a C++ class-like node ends while the file is walked in brace + /// scopes: the `}` that closes its body, or `None` to keep the tree's. + fn cpp_body_end(&self, body: Option>) -> Option<(i64, i64)> { + let scopes = self.cpp_brace_scopes.as_ref()?; + let close = scopes.close_of(body?.start_byte())?; + Some(scopes.position_of(close + 1)) + } + + /// Open a class-like node's body as a scope for + /// [`Self::visit_in_cpp_brace_scope`]. + fn open_cpp_class_scope(&mut self, id: &str, body: Option>) { + let Some(body) = body else { + return; + }; + let Some(close) = self + .cpp_brace_scopes + .as_ref() + .and_then(|scopes| scopes.close_of(body.start_byte())) + else { + return; + }; + if self + .cpp_class_scopes + .add(body.start_byte(), close, id.to_string()) + { + self.cpp_class_scope_ids.insert(id.to_string()); + } + } + fn visit_node_inner(&mut self, node: SyntaxNode<'tree>) { let node_type = node.kind(); let mut skip_children = false; - self.maybe_capture_fn_refs(node, node_type); + if self.spec.language() == Language::CSharp + && node_type == "namespace_declaration" + && self.visit_csharp_block_namespace(node) + { + return; + } + + // A Dart constant's declaration is walked as its constant's body, + // which captures its function values once, for the constant + // (upstream #2367). + if !(self.spec.language() == Language::Dart && node_type == "static_final_declaration") { + self.maybe_capture_fn_refs(node, node_type); + } + + // `window.App = {…}` / `App.utils = {…}`: the object's functions are + // the path's members (upstream #2363). Its whole subtree is handled + // there. + if node_type == "assignment_expression" && self.extract_assigned_object_owner(node, true) { + return; + } if self.extract_c_function_macro(node) { return; @@ -513,12 +840,43 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } else if has_type(self.spec.type_alias_types(), node_type) { self.extract_type_alias(node); skip_children = true; + } else if self.spec.language() == Language::Cfml + && self.cfml_dialect == Some(CfmlDialect::Script) + && has_type(self.spec.variable_types(), node_type) + { + // A `var x = …` at the top of a `` body is a local of + // the scope around the tag (the enclosing function, or the + // component's pseudo-constructor), so its initializer's calls are + // that scope's (upstream #2296 redirects them there; KEEP-RUST: + // upstream also keeps the local as a variable node, here it mints + // none, as a `var` in a script function's body does not). + self.visit_function_body(node); + skip_children = true; } else if has_type(self.spec.variable_types(), node_type) - && !self.is_inside_class_like_node() + && (!self.is_inside_class_like_node() || self.is_type_scope_constant(node)) { + // C/C++: a class, struct, union or enum defined in the + // declaration's type (`struct Foo { … } foo;`, `static struct { … } + // SPT;`) is a definition like one written on its own, and the + // variables keep their nodes beside it (upstream #2446). So is a + // class the tree reads as a declaration's type in a file walked in + // brace scopes (`class X {…}` glued to the tokens after it by error + // recovery, #2426). In a class or function body the children walk + // reaches it; here, this branch skips that walk. + let defined_type = if self.walk_declared_types { + child_by_field(node, "type") + .filter(|declared| crate::lang::is_class_like_definition(*declared)) + } else { + None + }; + if let Some(defined) = defined_type { + self.visit_node(defined); + } self.maybe_cpp_construction(node); self.extract_variable(node); - self.scan_fn_ref_subtree(node, 0); + // A type walked above captured its own function values. + let walked = defined_type.map(|defined| defined.id()); + self.scan_fn_ref_subtree(node, 0, walked.as_slice()); skip_children = true; } else if self.spec.language() == Language::Swift && node_type == "protocol_property_declaration" @@ -529,6 +887,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { && node_type == "property_declaration" && self.is_inside_class_like_node() { + self.walk_swift_attribute_arguments(node); if let Some(computed) = swift_computed_property_body(node) { // A computed property is its own node; its getter is consumed by // the function-body walk, so skip the generic child descent that @@ -536,10 +895,29 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { self.extract_swift_computed_property(node, computed); skip_children = true; } else if let Some(owner_id) = self.node_stack.last().cloned() { - // tree-sitter.ts:453-487 — Swift stored properties inside a type - // are not their own nodes; their property-wrapper attributes and - // declared type attach to the enclosing type. Children stay - // visited so initializer calls are captured. + // #897 — a stored property inside a type is a node: a `static + // let`/`static var` a shared constant/variable, an instance one + // a field. Its property-wrapper attributes and declared type + // attach to the enclosing type (tree-sitter.ts:453-487), and + // children stay visited so initializer calls are captured. + if let Some((name, is_let)) = swift_stored_property(node, self.source) { + let is_static = self.spec.is_static(node, self.source); + let kind = match (is_static, is_let) { + (true, true) => NodeKind::Constant, + (true, false) => NodeKind::Variable, + (false, _) => NodeKind::Field, + }; + self.create_node( + kind, + &name, + node, + NodeExtra { + visibility: self.spec.get_visibility(node), + is_static, + ..NodeExtra::default() + }, + ); + } self.extract_decorators_for(node, &owner_id); self.extract_variable_type_annotation(node, &owner_id); } @@ -548,23 +926,52 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } else if has_type(self.spec.property_types(), node_type) && self.is_inside_class_like_node() { - self.extract_property(node); - self.scan_fn_ref_subtree(node, 0); + let prop_id = self.extract_property(node); + // The code a property runs is the property's: its calls, + // instantiations and reads attribute to it, as a method's do + // (upstream #2345, #2348). + let bodies = if prop_id.is_some() { + self.property_bodies(node) + } else { + Vec::new() + }; + if let Some(prop_id) = prop_id.filter(|_| !bodies.is_empty()) { + self.node_stack.push(prop_id); + let declared_type = child_by_field(node, "type"); + for body in &bodies { + self.extract_target_typed_new(Some(*body), declared_type); + self.visit_function_body(*body); + } + self.node_stack.pop(); + } + // Whatever the body walk didn't cover (a C# property's + // attributes, any other language's whole declaration) is scanned + // for function-as-value candidates (#756); the bodies captured + // their own. + let walked = bodies.iter().map(|body| body.id()).collect::>(); + self.scan_fn_ref_subtree(node, 0, &walked); skip_children = true; } else if has_type(self.spec.field_types(), node_type) && self.is_inside_class_like_node() { - self.extract_field(node); - self.scan_fn_ref_subtree(node, 0); + let walked = self.extract_field(node); + // Scan the declaration for function-as-value candidates (#756): + // Java `List table = List.of(Main::cb)`. A C# + // declarator `extract_field` walked captured its own (`List< + // Action> table = new() { TargetCb }` is the field's), so + // the scan skips it. + self.scan_fn_ref_subtree(node, 0, &walked); skip_children = true; } else if is_rust_impl_item(self.spec.language(), node_type) { self.extract_rust_impl_item(node); } else if has_type(self.spec.call_types(), node_type) { self.extract_call(node); + } else if self.spec.language() == Language::Kotlin && node_type == "infix_expression" { + self.extract_kotlin_infix_call(node); } else if let Some(callee_name) = self.spec.extract_bare_call(node, self.source) { if let Some(caller_id) = self.node_stack.last().cloned() { self.push_ref(&caller_id, &callee_name, EdgeKind::Calls, node); } - } else if node_type == "new_expression" { - self.extract_instantiation(node); + } else if INSTANTIATION_KINDS.contains(&node_type) { + skip_children = self.extract_construction(node); } else if (node_type == "property_signature" || node_type == "method_signature") && self.is_inside_class_like_node() { @@ -578,6 +985,43 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } } + /// A C# block namespace scopes only its own body (upstream #2220): + /// serilog's Guard.cs opens `namespace JetBrains.Annotations { … }` and + /// then declares `static class Guard` at the top level, and a file's + /// second namespace is its own. A namespace written inside another is + /// `Outer.Inner`, the dotted name a type's qualified name leads with, so + /// it takes the outer's place on the scope while its body is walked. + fn visit_csharp_block_namespace(&mut self, node: SyntaxNode<'tree>) -> bool { + let Some(name) = self.spec.extract_package(node, self.source) else { + return false; + }; + let outer = self + .node_stack + .last() + .and_then(|id| self.nodes.iter().find(|n| &n.id == id)) + .filter(|n| n.kind == NodeKind::Namespace) + .map(|n| (n.id.clone(), n.name.clone())); + if outer.is_some() { + self.node_stack.pop(); + } + let full = outer.as_ref().map_or_else( + || name.clone(), + |(_, outer_name)| format!("{outer_name}.{name}"), + ); + let namespace = self.create_node(NodeKind::Namespace, &full, node, NodeExtra::default()); + if let Some(namespace) = &namespace { + self.node_stack.push(namespace.id.clone()); + } + self.visit_named_children(node); + if namespace.is_some() { + self.node_stack.pop(); + } + if let Some((id, _)) = outer { + self.node_stack.push(id); + } + true + } + fn visit_named_children(&mut self, node: SyntaxNode<'tree>) { for i in 0..node.named_child_count() { if let Some(child) = node.named_child(i as u32) { @@ -589,6 +1033,8 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { fn visit_language_specific(&mut self, node: SyntaxNode<'tree>) -> bool { match self.spec.language() { Language::Scala => self.visit_scala_node(node), + Language::Kotlin => self.visit_kotlin_node(node), + Language::Dart => self.visit_dart_node(node), Language::Lua | Language::Luau => self.visit_lua_node(node), Language::ObjC => self.visit_objc_node(node), Language::Ruby => self.visit_ruby_node(node), @@ -625,41 +1071,48 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { /// `call` children, so `-spec f(integer()) -> integer().` mints no bogus /// `integer` call ref. Static gen_server and spawn/apply MFA dispatch is /// lifted here; behaviour and resource-tuple bridges remain deferred. - /// CFML extraction (upstream `CfmlExtractor`, `cfml-extractor.ts`, #1153 — - /// scope-B extraction slice). A first-token sniff (`is_bare_script_cfml`) - /// selects the dialect. Bare-script: the cfscript type-set config drives the - /// generic dispatch, so this visitor owns only the unnamed script - /// `component`/`interface` (file-name rename + script-style `extends`). Tag: - /// owns `cf_component_open_tag`/`cf_function_tag`; `cf_script_tag`/ - /// `cf_query_tag` inner-body delegation is DEFERRED. `Language::Cfml`-guarded. + /// CFML extraction (upstream `CfmlExtractor`, `cfml-extractor.ts`). A + /// first-token sniff (`is_bare_script_cfml`) selects the dialect. + /// Bare-script: the cfscript type-set config drives the generic dispatch, + /// so this visitor owns only the unnamed script `component`/`interface` + /// (file-name rename + script-style `extends`). Tag: the `program` is + /// walked tag by tag ([`Self::walk_cfml_program`]), each `` and + /// `` body read with its own grammar over the file's bytes + /// (upstream #2296, the deferred second in-tree parse). + /// `Language::Cfml`-guarded. fn visit_cfml_node(&mut self, node: SyntaxNode<'tree>) -> bool { - if crate::lang::is_bare_script_cfml(self.source) { - match node.kind() { + match self.cfml_dialect() { + CfmlDialect::Script => match node.kind() { "component" | "interface" => { self.emit_cfml_script_component(node); true } _ => false, - } - } else { - if self.cfml_consumed_until > 0 && node.end_byte() <= self.cfml_consumed_until { - return true; - } - match node.kind() { - "cf_component_open_tag" => { - self.emit_cfml_tag_component(node); - true - } - "cf_function_tag" => { - self.emit_cfml_tag_function(node, false); + }, + CfmlDialect::Query => false, + CfmlDialect::Tag => { + if node.kind() == "program" { + self.walk_cfml_program(node); true + } else { + false } - "cf_script_tag" | "cf_query_tag" => true, - _ => false, } } } + /// The dialect this walk reads: the one a snippet walk was started in, + /// else the file's. + fn cfml_dialect(&self) -> CfmlDialect { + self.cfml_dialect.unwrap_or_else(|| { + if crate::lang::is_bare_script_cfml(self.source) { + CfmlDialect::Script + } else { + CfmlDialect::Tag + } + }) + } + /// Ports `extractBareScript`: the unnamed script `component`/`interface` is /// named from the FILE, qualified `{file}::{name}`; script-style `extends` /// becomes an `Extends` ref; the body is walked for the generic dispatch. @@ -682,73 +1135,278 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { ) else { return; }; - if let Some(extends) = crate::lang::cfml_string_attr_value(node, "extends", source) { - self.push_ref(&created.id, &extends, EdgeKind::Extends, node); + // `component extends="Base" implements="IFoo,IBar"` (also `interface + // extends="IBase"`): generic name=value attributes, each name a ref + // at its value (upstream's component_attribute inheritance; the + // implements list since W4-CF-02, upstream #1152's dotted-implements + // case). + if let Some((extends, value)) = crate::lang::cfml_component_attr(node, "extends", source) { + let extends = extends.trim(); + if !extends.is_empty() { + self.push_ref(&created.id, extends, EdgeKind::Extends, value); + } + } + if let Some((implements, value)) = + crate::lang::cfml_component_attr(node, "implements", source) + { + for iface in implements + .split(',') + .map(str::trim) + .filter(|i| !i.is_empty()) + { + self.push_ref(&created.id, iface, EdgeKind::Implements, value); + } } self.node_stack.push(created.id); self.visit_named_children(node); self.node_stack.pop(); } + /// Ports `walkProgram`: the `program`'s children with one forward cursor, + /// since a `` consumes the run of FOLLOWING siblings that is + /// its body (the grammar's implicit end tag). Template code outside a + /// component is the file's. + fn walk_cfml_program(&mut self, root: SyntaxNode<'tree>) { + let mut child = root.named_child(0); + while let Some(current) = child { + if current.kind() == "cf_component_open_tag" { + child = self + .extract_cfml_tag_component(current) + .next_named_sibling(); + continue; + } + self.visit_cfml_tag(current, root.kind(), None, true); + child = current.next_named_sibling(); + } + } + /// Ports `extractComponent`: Class from the `name` tag-attr else file; - /// `extends`/`implements` tag-attr refs; the implicit-end-tag body is the - /// open tag's FOLLOWING SIBLINGS, walked to `cf_component_close_tag` with - /// each `cf_function_tag` a Method. `cfml_consumed_until` marks the consumed - /// range so the top-level walk skips it. - fn emit_cfml_tag_component(&mut self, node: SyntaxNode<'tree>) { + /// `extends`/`implements` tag-attr refs; the open tag's following siblings + /// up to `cf_component_close_tag` are the component's scope, and the class + /// spans to that close tag. Returns the last node consumed. + fn extract_cfml_tag_component(&mut self, open_tag: SyntaxNode<'tree>) -> SyntaxNode<'tree> { let source = self.source; - let name = crate::lang::cfml_tag_attr(node, "name", source) + let name = crate::lang::cfml_tag_attr(open_tag, "name", source) .unwrap_or_else(|| crate::lang::cfml_component_name_from_path(self.file_path)); let qualified_name = format!("{}::{}", self.file_path, name); - let extends = crate::lang::cfml_tag_attr(node, "extends", source); - let implements = crate::lang::cfml_tag_attr(node, "implements", source); + let extends = crate::lang::cfml_tag_attr(open_tag, "extends", source); + let implements = crate::lang::cfml_tag_attr(open_tag, "implements", source); let Some(created) = self.create_node( NodeKind::Class, &name, - node, + open_tag, NodeExtra { is_exported: true, qualified_name: Some(qualified_name), ..NodeExtra::default() }, ) else { - return; + return open_tag; }; if let Some(extends) = extends { let extends = extends.trim(); if !extends.is_empty() { - self.push_ref(&created.id, extends, EdgeKind::Extends, node); + self.push_ref(&created.id, extends, EdgeKind::Extends, open_tag); } } if let Some(implements) = implements { for iface in implements.split(',') { let iface = iface.trim(); if !iface.is_empty() { - self.push_ref(&created.id, iface, EdgeKind::Implements, node); + self.push_ref(&created.id, iface, EdgeKind::Implements, open_tag); } } } - self.node_stack.push(created.id); - let mut consumed_end = node.end_byte(); - let mut sibling = node.next_named_sibling(); + let parent_kind = open_tag.parent().map_or("", |parent| parent.kind()); + self.node_stack.push(created.id.clone()); + let mut last = open_tag; + let mut sibling = open_tag.next_named_sibling(); while let Some(current) = sibling { + last = current; if current.kind() == "cf_component_close_tag" { - consumed_end = current.end_byte(); break; } - if current.kind() == "cf_function_tag" { - self.emit_cfml_tag_function(current, true); - } - consumed_end = current.end_byte(); + // Component scope: cffunctions are its methods, pseudo-constructor + // code's calls (a component-level ``) are its own. + self.visit_cfml_tag(current, parent_kind, Some(&name), true); sibling = current.next_named_sibling(); } self.node_stack.pop(); - self.cfml_consumed_until = consumed_end; + let end_line = last.end_position().row as i64 + 1; + if let Some(class) = self.nodes.iter_mut().find(|node| node.id == created.id) { + class.end_line = end_line; + } + last + } + + /// Ports `visitTag`: one node of tag markup in the scope on top of the + /// node stack (the function whose body it is, else the component, else + /// the file), which owns the calls it makes. `decl_scope` is true outside + /// function bodies, where a `` declares a function of the + /// scope; `class_name` is set at component or interface scope, where + /// those functions are methods. + /// + /// - `` / `` bodies are read with their own grammars. + /// - A `` reached at declaration scope is extracted at any + /// depth (`` around Application.cfc's handlers, + /// `` around a template's helpers), never through an ERROR + /// node (a `` the parser lost would misfile its methods). + /// Inside a function body it is skipped (CFML rejects it). + /// - `` is an interface; `` is an + /// expression written as an attribute's text. + /// - An expression in tag position (the value of ``, the condition + /// of ``/``, the operand of ``, a `#hash#` + /// anywhere in markup) is walked as its scope's code. + fn visit_cfml_tag( + &mut self, + node: SyntaxNode<'tree>, + parent_kind: &str, + class_name: Option<&str>, + decl_scope: bool, + ) { + if self.walk_aborted { + return; + } + if self.walk_depth >= MAX_WALK_DEPTH { + self.walk_aborted = true; + return; + } + self.walk_depth += 1; + self.visit_cfml_tag_inner(node, parent_kind, class_name, decl_scope); + self.walk_depth -= 1; + } + + fn visit_cfml_tag_inner( + &mut self, + node: SyntaxNode<'tree>, + parent_kind: &str, + class_name: Option<&str>, + decl_scope: bool, + ) { + let kind = node.kind(); + match kind { + "cf_script_tag" => { + self.delegate_cfml_script_tag(node, class_name); + return; + } + // The SQL body is opaque to this grammar; the walk below reaches + // only the tag's attributes (`datasource="#dsn()#"`). + "cf_query_tag" => self.delegate_cfml_query_tag(node), + "cf_function_tag" => { + if decl_scope { + self.emit_cfml_tag_function(node, class_name.is_some()); + } + return; + } + "cf_tag" => { + let tag_name = crate::lang::cfml_tag_name(node, self.source); + if tag_name.as_deref() == Some("interface") && decl_scope && class_name.is_none() { + self.extract_cfml_interface_tag(node); + return; + } + // ``: the one common tag + // attribute whose plain text is a CFML expression. + if tag_name.as_deref() == Some("loop") { + let start = crate::lang::cfml_start_tag(node).unwrap_or(node); + if let Some(Some(condition)) = + crate::lang::cfml_tag_attr_value_node(start, "condition", self.source) + { + self.read_cfml_snippet_refs(condition, CfmlDialect::Script); + } + } + } + _ => { + if CFML_TAG_EXPRESSION_PARENTS.contains(&parent_kind) + && CFML_TAG_EXPRESSION_TYPES.contains(&kind) + { + self.visit_cfml_tag_expression(node); + return; + } + } + } + let decl_scope = decl_scope && kind != "ERROR"; + for i in 0..node.named_child_count() { + if let Some(child) = node.named_child(i as u32) { + self.visit_cfml_tag(child, kind, class_name, decl_scope); + } + } + } + + /// An expression in tag markup, walked as the code of the scope on top of + /// the node stack. It declares nothing (upstream keeps only an + /// expression's references): a closure written in it is its scope's code, + /// so whatever node its walk minted is dropped and that node's references + /// go to the scope. + fn visit_cfml_tag_expression(&mut self, node: SyntaxNode<'tree>) { + let Some(scope) = self.node_stack.last().cloned() else { + return; + }; + let nodes_before = self.nodes.len(); + let edges_before = self.edges.len(); + let refs_before = self.unresolved_references.len(); + self.visit_function_body(node); + if self.nodes.len() == nodes_before { + return; + } + let minted: HashSet = self + .nodes + .drain(nodes_before..) + .map(|minted| minted.id) + .collect(); + let edges = self.edges.split_off(edges_before); + self.edges.extend( + edges + .into_iter() + .filter(|edge| !minted.contains(&edge.source) && !minted.contains(&edge.target)), + ); + for reference in &mut self.unresolved_references[refs_before..] { + if minted.contains(&reference.from_node_id) { + reference.from_node_id = scope.clone(); + } + } + } + + /// Ports `extractInterfaceTag`: ``, a + /// generic `cf_tag` whose body (the `` signatures) is ordinary + /// nested children. Named like a component (the file, unless a `name` + /// attribute says otherwise), with an `extends` per comma-separated + /// parent, and its functions as methods. + fn extract_cfml_interface_tag(&mut self, tag: SyntaxNode<'tree>) { + let source = self.source; + let start = crate::lang::cfml_start_tag(tag).unwrap_or(tag); + let name = crate::lang::cfml_tag_attr(start, "name", source) + .unwrap_or_else(|| crate::lang::cfml_component_name_from_path(self.file_path)); + let qualified_name = format!("{}::{}", self.file_path, name); + let Some(created) = self.create_node( + NodeKind::Interface, + &name, + tag, + NodeExtra { + is_exported: true, + qualified_name: Some(qualified_name), + ..NodeExtra::default() + }, + ) else { + return; + }; + if let Some(list) = crate::lang::cfml_tag_attr(start, "extends", source) { + for parent in list.split(',').map(str::trim).filter(|p| !p.is_empty()) { + self.push_ref(&created.id, parent, EdgeKind::Extends, start); + } + } + self.node_stack.push(created.id); + for i in 0..tag.named_child_count() { + if let Some(child) = tag.named_child(i as u32) { + self.visit_cfml_tag(child, tag.kind(), Some(&name), true); + } + } + self.node_stack.pop(); } - /// Ports `extractFunctionTag`: Method (in a component) or Function - /// (top-level) from the `name` tag-attr (skip if absent), visibility from - /// `access`, return type from `returntype`; the body is walked for calls. + /// Ports `extractFunctionTag`: Method (in a component or interface) or + /// Function (top-level) from the `name` tag-attr (skip if absent), + /// visibility from `access`, return type from `returntype`; the body is + /// walked as tag markup of this function's scope. fn emit_cfml_tag_function(&mut self, node: SyntaxNode<'tree>, is_method: bool) { let source = self.source; let Some(name) = crate::lang::cfml_tag_attr(node, "name", source) else { @@ -783,27 +1441,191 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { return; }; self.node_stack.push(created.id); - self.visit_named_children(node); + for i in 0..node.named_child_count() { + if let Some(child) = node.named_child(i as u32) { + self.visit_cfml_tag(child, node.kind(), None, false); + } + } self.node_stack.pop(); } - fn visit_erlang_node(&mut self, node: SyntaxNode<'tree>) -> bool { - match node.kind() { - "fun_decl" => { - self.visit_erlang_fun_decl(node); - true - } - "record_decl" => { - self.visit_erlang_record_decl(node); - true - } - "type_alias" | "opaque" => { - // Type-position bodies are `call` nodes; consume without descent - // so no bogus type call refs are minted. - if let Some(name) = crate::lang::erlang_type_alias_name(node, self.source) { - let signature: String = - crate::lang::erlang_collapse_ws(&node_text(node, self.source)) - .chars() + /// Ports `delegateScriptTag`: a `` body read with the cfscript + /// grammar over this file's bytes, so every position is the file's. At + /// component scope (`class_name`), the functions declared at the script's + /// top level are the component's methods (the ColdBox ModuleConfig shape) + /// and every symbol's qualified name is scoped under the component; a + /// closure inside another function stays a function. A `var x = …` at the + /// script's top level is a local of the enclosing scope (see the + /// variable branch of `visit_node_inner`). + fn delegate_cfml_script_tag( + &mut self, + script_tag: SyntaxNode<'tree>, + class_name: Option<&str>, + ) { + let Some(content) = script_tag + .named_children(&mut script_tag.walk()) + .find(|child| child.kind() == "cf_script_content") + else { + return; + }; + let Some(scope) = self.node_stack.last().cloned() else { + return; + }; + let Some(mut snippet) = self.walk_cfml_snippet(content, CfmlDialect::Script) else { + return; + }; + let top_level: HashSet = snippet + .edges + .iter() + .filter(|edge| edge.kind == EdgeKind::Contains && edge.source == scope) + .map(|edge| edge.target.clone()) + .collect(); + if let Some(class_name) = class_name { + // A re-kinded method gets the id of a method (the id formula + // carries the kind; KEEP-RUST: upstream keeps the function's id). + let mut renamed: HashMap = HashMap::new(); + for node in &mut snippet.nodes { + if node.kind == NodeKind::Function && top_level.contains(&node.id) { + node.kind = NodeKind::Method; + let id = self.node_ids.generate( + self.file_path, + NodeKind::Method, + &node.name, + node.start_line as u32, + utf16_column_at(self.source, node.start_line, node.start_column), + ); + renamed.insert(std::mem::replace(&mut node.id, id.clone()), id); + } + node.qualified_name = format!("{class_name}::{}", node.qualified_name); + } + if !renamed.is_empty() { + for edge in &mut snippet.edges { + if let Some(id) = renamed.get(&edge.source) { + edge.source = id.clone(); + } + if let Some(id) = renamed.get(&edge.target) { + edge.target = id.clone(); + } + } + for reference in &mut snippet.references { + if let Some(id) = renamed.get(&reference.from_node_id) { + reference.from_node_id = id.clone(); + } + } + } + } + self.nodes.extend(snippet.nodes); + self.edges.extend(snippet.edges); + self.unresolved_references.extend(snippet.references); + self.errors.extend(snippet.errors); + } + + /// Ports `delegateQueryTag`: the `#hash#` expressions of a `` + /// SQL body (`#getCurrentUser().getId()#` in a WHERE clause) read with the + /// cfquery grammar; only their references are kept. + fn delegate_cfml_query_tag(&mut self, query_tag: SyntaxNode<'tree>) { + let Some(content) = query_tag + .named_children(&mut query_tag.walk()) + .find(|child| child.kind() == "cf_query_content") + else { + return; + }; + self.read_cfml_snippet_refs(content, CfmlDialect::Query); + } + + /// The references of the CFML read from `range`'s bytes with the grammar + /// of `dialect`, all of them the scope's on top of the node stack: an + /// expression or a SQL body declares nothing. + fn read_cfml_snippet_refs(&mut self, range: SyntaxNode<'tree>, dialect: CfmlDialect) { + let Some(scope) = self.node_stack.last().cloned() else { + return; + }; + let Some(snippet) = self.walk_cfml_snippet(range, dialect) else { + return; + }; + for mut reference in snippet.references { + reference.from_node_id = scope.clone(); + self.unresolved_references.push(reference); + } + self.errors.extend(snippet.errors); + } + + /// Parse the bytes of `range` (and nothing else) of this file with the + /// cfscript or cfquery grammar, so every position in the tree is the + /// file's, and walk the tree under the scope on top of the node stack, + /// sharing this walk's id allocator (upstream re-parses the text on its + /// own and shifts its lines; KEEP-RUST: the ids and columns are the + /// file's). + fn walk_cfml_snippet( + &mut self, + range: SyntaxNode<'tree>, + dialect: CfmlDialect, + ) -> Option { + let scope = self.node_stack.last().cloned()?; + let language: tree_sitter::Language = match dialect { + CfmlDialect::Script => tree_sitter_cfml::LANGUAGE_CFSCRIPT.into(), + CfmlDialect::Query => tree_sitter_cfml::LANGUAGE_CFQUERY.into(), + CfmlDialect::Tag => return None, + }; + if range.end_byte() <= range.start_byte() { + return None; + } + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&language).ok()?; + parser + .set_included_ranges(&[tree_sitter::Range { + start_byte: range.start_byte(), + end_byte: range.end_byte(), + start_point: range.start_position(), + end_point: range.end_position(), + }]) + .ok()?; + let tree = parser.parse(self.source, None)?; + let root = tree.root_node(); + // A snippet nested past the limit fails its whole file, as a deep + // tag tree does: no partial graph without the snippet. + if ast_exceeds_safe_depth(root, self.spec.language()) { + self.walk_aborted = true; + return None; + } + let mut walker = TreeSitterWalker::new(self.file_path, self.source, self.spec, root); + walker.raw_source = self.raw_source; + walker.cfml_dialect = Some(dialect); + walker.node_ids = std::mem::take(&mut self.node_ids); + walker.node_stack = vec![scope]; + walker.walk_depth = self.walk_depth; + walker.visit_node(root); + walker.flush_fn_ref_candidates(); + self.node_ids = std::mem::take(&mut walker.node_ids); + if walker.walk_aborted { + self.walk_aborted = true; + return None; + } + Some(CfmlSnippet { + nodes: walker.nodes, + edges: walker.edges, + references: walker.unresolved_references, + errors: walker.errors, + }) + } + + fn visit_erlang_node(&mut self, node: SyntaxNode<'tree>) -> bool { + match node.kind() { + "fun_decl" => { + self.visit_erlang_fun_decl(node); + true + } + "record_decl" => { + self.visit_erlang_record_decl(node); + true + } + "type_alias" | "opaque" => { + // Type-position bodies are `call` nodes; consume without descent + // so no bogus type call refs are minted. + if let Some(name) = crate::lang::erlang_type_alias_name(node, self.source) { + let signature: String = + crate::lang::erlang_collapse_ws(&node_text(node, self.source)) + .chars() .take(200) .collect(); self.create_node( @@ -1616,7 +2438,22 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } return; } - self.push_ref(&parent_id, &method_name, EdgeKind::Calls, node); + // G11 (upstream #2147's extraction half): the receiver stays with the + // method, `message.upcase`, so resolution can read what it is; `self` + // and `super` leave the bare name, which resolves in the class. + let receiver_name = node_text(receiver, self.source); + let skip = matches!(receiver_name.as_str(), "self" | "super"); + let callee = if skip { + method_name + } else { + format!("{receiver_name}.{method_name}") + }; + self.push_ref(&parent_id, &callee, EdgeKind::Calls, node); + // A constant receiver (`Formatter.shout`, a class or module method) is + // itself a dependency on that constant. + if !skip && receiver.kind() == "constant" { + self.push_ref(&parent_id, &receiver_name, EdgeKind::References, receiver); + } } fn visit_ruby_call_arguments(&mut self, node: SyntaxNode<'tree>) { @@ -1646,7 +2483,9 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } return true; } - if node.kind() == "const_declaration" && self.is_inside_class_like_node() { + // A `const` at file scope or in a class is a constant (upstream + // php.ts visitNode). + if node.kind() == "const_declaration" { for elem in node .named_children(&mut node.walk()) .filter(|child| child.kind() == "const_element") @@ -2128,8 +2967,12 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { return false; }; let name = node_text(name_node, self.source); - let in_class = self.is_inside_class_like_node(); - let kind = if in_class { + // #897 — an `object` is a singleton: its `val`s are shared + // constants, like a top-level `val`. A `class`, `trait`, `enum` + // or `given` value is per-instance state, a field. Both an + // object and a class are class-kind nodes, so the enclosing + // definition's syntax decides. + let kind = if scala_instance_scope(node) { NodeKind::Field } else if node.kind() == "val_definition" { NodeKind::Constant @@ -2328,6 +3171,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } let start_line = node.start_position().row as u32 + 1; + let value_target = !extra.local_value; let id = self.node_ids.generate( self.file_path, kind, @@ -2361,6 +3205,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { if let Some(qualified_name) = extra.qualified_name { new_node.qualified_name = qualified_name; } + if let Some((end_line, end_column)) = extra.end { + new_node.end_line = end_line; + new_node.end_column = end_column; + } // Upstream tree-sitter.ts:626-634: extractModifiers output (Kotlin // expect/actual) merges into decorators. let mods = self.spec.extract_modifiers(node); @@ -2369,6 +3217,22 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } self.nodes.push(new_node.clone()); + if crate::value_refs::has_value_refs(self.spec.language()) { + // A value no other scope names by this name (a local, or an + // object hung on a dotted path, upstream #2363) reads values but + // has no scope a read could name it from. + self.value_refs.capture( + self.spec.language(), + kind, + name, + &id, + node, + self.node_stack + .last() + .map(String::as_str) + .filter(|_| value_target), + ); + } if let Some(parent_id) = self.node_stack.last() { self.edges.push(Edge { @@ -2672,6 +3536,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { return; } let name = self.extract_name(node); + let resolved_body = self.resolve_body(node); let class_node = self.create_node( kind, &name, @@ -2680,18 +3545,272 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { docstring: self.docstring_for(node), visibility: self.spec.get_visibility(node), is_exported: self.spec.is_exported(node, self.source), + end: self.cpp_body_end(resolved_body), ..NodeExtra::default() }, ); let Some(class_node) = class_node else { return }; + self.open_cpp_class_scope(&class_node.id, resolved_body); self.extract_inheritance(node, &class_node.id); self.extract_decorators_for(node, &class_node.id); - self.node_stack.push(class_node.id); + self.node_stack.push(class_node.id.clone()); let body = self.resolve_body(node).unwrap_or(node); self.visit_named_children(body); + // After the body, so a hand-written member wins, and while the class + // is on the stack, so containment and qualified names attach. + if self.spec.language() == Language::Java { + self.synthesize_lombok_members(node, &class_node); + } self.node_stack.pop(); } + /// The members Lombok generates at compile time, which no source declares + /// (upstream #912 `synthesizeLombokMembers`): getters and setters for + /// `@Getter` / `@Setter` (class or field), `@Data` (setters for non-final + /// fields) and `@Value` (getters), `builder()` for `@Builder` / + /// `@SuperBuilder`, `toString` / `equals` / `hashCode` for `@Data`, + /// `@Value`, `@ToString` and `@EqualsAndHashCode`, and the `log` field of + /// the `@Log*` family. Each is anchored on the field's or the class's name + /// token, marked with a `lombok` decorator and a docstring naming its + /// annotation, and never replaces a member the class declares (methods + /// and fields apart). Constructors, fluent builder setters and + /// `@Accessors(fluent = true)` names are not generated. + fn synthesize_lombok_members(&mut self, class: SyntaxNode<'tree>, class_node: &Node) { + use crate::lang::{ + LOMBOK_LOG_ANNOTATIONS, has_word, lombok_annotation_names, lombok_getter_name, + lombok_setter_name, modifier_text, normalize_java_type, + }; + let class_annotations = lombok_annotation_names(class, self.source); + let has = |name: &str| class_annotations.iter().any(|a| a == name); + let class_getter = has("Getter"); + let class_setter = has("Setter"); + let data = has("Data"); + let value = has("Value"); + let builder = has("Builder") || has("SuperBuilder"); + let to_string = data || value || has("ToString"); + let equals = data || value || has("EqualsAndHashCode"); + let log = class_annotations + .iter() + .find(|a| LOMBOK_LOG_ANNOTATIONS.contains(&a.as_str())) + .cloned(); + let Some(body) = child_by_field(class, "body") else { + return; + }; + let fields: Vec> = body + .named_children(&mut body.walk()) + .filter(|child| child.kind() == "field_declaration") + .collect(); + let lombok_class = class_getter + || class_setter + || data + || value + || builder + || to_string + || equals + || log.is_some(); + if !lombok_class + && !fields + .iter() + .any(|field| !lombok_annotation_names(*field, self.source).is_empty()) + { + return; + } + // What the class declares itself: Lombok never replaces it. + let mut taken_methods: HashSet = HashSet::new(); + let mut taken_fields: HashSet = HashSet::new(); + for node in &self.nodes { + if node.file_path != self.file_path + || node.qualified_name != format!("{}::{}", class_node.qualified_name, node.name) + { + continue; + } + match node.kind { + NodeKind::Method | NodeKind::Function => { + taken_methods.insert(node.name.clone()); + } + NodeKind::Field | NodeKind::Variable | NodeKind::Constant | NodeKind::Property => { + taken_fields.insert(node.name.clone()); + } + _ => {} + } + } + let class_name_node = child_by_field(class, "name").unwrap_or(class); + let class_name = class_node.name.clone(); + let mut emit = |walker: &mut Self, + name: String, + anchor: SyntaxNode<'tree>, + signature: String, + annotation: &str, + is_static: bool, + return_type: Option| { + if name.is_empty() || !taken_methods.insert(name.clone()) { + return; + } + walker.create_node( + NodeKind::Method, + &name, + anchor, + NodeExtra { + visibility: Some("public".to_string()), + signature: Some(signature), + docstring: Some(format!("Lombok-generated ({annotation})")), + decorators: vec!["lombok".to_string()], + is_static, + return_type, + ..NodeExtra::default() + }, + ); + }; + for field in fields { + let modifiers = modifier_text(field, self.source); + // Lombok skips static fields. + if has_word(&modifiers, "static") { + continue; + } + let is_final = has_word(&modifiers, "final"); + let field_annotations = lombok_annotation_names(field, self.source); + let field_getter = field_annotations.iter().any(|a| a == "Getter"); + let field_setter = field_annotations.iter().any(|a| a == "Setter"); + let want_getter = class_getter || data || value || field_getter; + let want_setter = (class_setter || data || field_setter) && !is_final; + if !want_getter && !want_setter { + continue; + } + let type_node = child_by_field(field, "type"); + let type_text = type_node.map_or_else( + || "Object".to_string(), + |node| node_text(node, self.source).trim().to_string(), + ); + let boolean_primitive = type_node.is_some_and(|node| node.kind() == "boolean_type"); + let return_type = type_node.and_then(|node| normalize_java_type(node, self.source)); + let declarators: Vec> = field + .named_children(&mut field.walk()) + .filter(|child| child.kind() == "variable_declarator") + .collect(); + for declarator in declarators { + let Some(name_node) = child_by_field(declarator, "name") else { + continue; + }; + let field_name = node_text(name_node, self.source).trim().to_string(); + if field_name.is_empty() { + continue; + } + if want_getter { + let getter = lombok_getter_name(&field_name, boolean_primitive); + let annotation = if field_getter { + "@Getter" + } else if data { + "@Data" + } else if value { + "@Value" + } else { + "@Getter" + }; + let signature = format!("{type_text} {getter}()"); + emit( + self, + getter, + name_node, + signature, + annotation, + false, + return_type.clone(), + ); + } + if want_setter { + let setter = lombok_setter_name(&field_name, boolean_primitive); + let annotation = if field_setter { + "@Setter" + } else if data { + "@Data" + } else { + "@Setter" + }; + let signature = format!("void {setter}({type_text} {field_name})"); + emit(self, setter, name_node, signature, annotation, false, None); + } + } + } + if builder { + let annotation = if has("SuperBuilder") { + "@SuperBuilder" + } else { + "@Builder" + }; + emit( + self, + "builder".to_string(), + class_name_node, + format!("static {class_name}.{class_name}Builder builder()"), + annotation, + true, + Some(format!("{class_name}Builder")), + ); + } + if to_string { + let annotation = if data { + "@Data" + } else if value { + "@Value" + } else { + "@ToString" + }; + emit( + self, + "toString".to_string(), + class_name_node, + "String toString()".to_string(), + annotation, + false, + None, + ); + } + if equals { + let annotation = if data { + "@Data" + } else if value { + "@Value" + } else { + "@EqualsAndHashCode" + }; + emit( + self, + "equals".to_string(), + class_name_node, + "boolean equals(Object o)".to_string(), + annotation, + false, + None, + ); + emit( + self, + "hashCode".to_string(), + class_name_node, + "int hashCode()".to_string(), + annotation, + false, + None, + ); + } + if let Some(log) = log + && taken_fields.insert("log".to_string()) + { + self.create_node( + NodeKind::Field, + "log", + class_name_node, + NodeExtra { + visibility: Some("private".to_string()), + is_static: true, + signature: Some("Logger log".to_string()), + docstring: Some(format!("Lombok-generated (@{log})")), + decorators: vec!["lombok".to_string()], + ..NodeExtra::default() + }, + ); + } + } + fn extract_struct(&mut self, node: SyntaxNode<'tree>) { self.extract_aggregate(node, NodeKind::Struct); } @@ -2724,12 +3843,14 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { docstring: self.docstring_for(node), visibility: self.spec.get_visibility(node), is_exported: self.spec.is_exported(node, self.source), + end: self.cpp_body_end(body), ..NodeExtra::default() }, ); let Some(aggregate_node) = aggregate_node else { return; }; + self.open_cpp_class_scope(&aggregate_node.id, body); self.extract_inheritance(node, &aggregate_node.id); if let Some(body) = body { self.node_stack.push(aggregate_node.id); @@ -2869,7 +3990,25 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { for i in 0..body.named_child_count() { if let Some(child) = body.named_child(i as u32) { if has_type(self.spec.enum_member_types(), child.kind()) { - self.extract_enum_member(child); + let member = self.extract_enum_member(child); + // Java's `PLUS { int apply(…) { … } }`, Kotlin's `NewBuffer + // { override fun pipe() … }`: the constant's own body + // declares members of its own (upstream #2194). Only a + // `class_body` child is a body: a Rust struct-like variant's + // fields are not. + let mut cursor = child.walk(); + let entry_body = child + .named_children(&mut cursor) + .find(|inner| inner.kind() == "class_body"); + if let (Some(member), Some(entry_body)) = (member, entry_body) { + self.node_stack.push(member.id); + for j in 0..entry_body.named_child_count() { + if let Some(inner) = entry_body.named_child(j as u32) { + self.visit_node(inner); + } + } + self.node_stack.pop(); + } } else { self.visit_node(child); } @@ -2878,7 +4017,9 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { self.node_stack.pop(); } - fn extract_enum_member(&mut self, node: SyntaxNode<'tree>) { + /// The enum members `node` declares; returns the first, which owns the + /// body a Java or Kotlin constant may have. + fn extract_enum_member(&mut self, node: SyntaxNode<'tree>) -> Option { // tree-sitter.ts:1105-1131. The upstream WASM swift grammar exposes no // `name` field on enum_entry, so its identifier loop emits one member // per simple_identifier (`case put, delete` → put AND delete). @@ -2890,37 +4031,40 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { node.children_by_field_name("name", &mut cursor).collect(); drop(cursor); if name_nodes.len() == 1 { - self.create_node( + return self.create_node( NodeKind::EnumMember, &node_text(name_nodes[0], self.source), node, NodeExtra::default(), ); - return; } if name_nodes.len() > 1 { + let mut first = None; for name_node in name_nodes { - self.create_node( + let created = self.create_node( NodeKind::EnumMember, &node_text(name_node, self.source), name_node, NodeExtra::default(), ); + first = first.or(created); } - return; + return first; } + let mut first = None; for i in 0..node.named_child_count() { if let Some(child) = node.named_child(i as u32) { if matches!( child.kind(), "simple_identifier" | "identifier" | "property_identifier" ) { - self.create_node( + let created = self.create_node( NodeKind::EnumMember, &node_text(child, self.source), child, NodeExtra::default(), ); + first = first.or(created); } } } @@ -2928,13 +4072,31 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { // child); its own text is the member name. Emit from the node text when // the name-field / id-child paths above found nothing. if node.kind() == "enum_value" { - self.create_node( + return self.create_node( NodeKind::EnumMember, &node_text(node, self.source), node, NodeExtra::default(), ); } + first + } + + /// A C/C++ alias's declaration as written, its signature (KEEP-RUST, owner + /// 2026-10-12: upstream records no aliased type): the original source of + /// `node`, or of the `template_declaration` it is the body of, whitespace + /// collapsed and the trailing `;` dropped. Resolution reads an alias's + /// type from this same text (upstream #2399), so a sync sees every change + /// to it as a changed node. + fn c_family_alias_declaration(&self, node: SyntaxNode<'tree>) -> Option { + let span = node + .parent() + .filter(|parent| parent.kind() == "template_declaration") + .unwrap_or(node); + let text = self.raw_source.get(span.start_byte()..span.end_byte())?; + let collapsed = text.split_whitespace().collect::>().join(" "); + let declaration = collapsed.strip_suffix(';').unwrap_or(&collapsed).trim_end(); + (!declaration.is_empty()).then(|| declaration.to_string()) } fn extract_type_alias(&mut self, node: SyntaxNode<'tree>) { @@ -2946,6 +4108,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { .spec .resolve_type_alias_kind(node, self.source) .unwrap_or(NodeKind::TypeAlias); + let signature = (alias_kind == NodeKind::TypeAlias + && matches!(self.spec.language(), Language::C | Language::Cpp)) + .then(|| self.c_family_alias_declaration(node)) + .flatten(); let type_alias_node = self.create_node( alias_kind, &name, @@ -2953,6 +4119,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { NodeExtra { docstring: self.docstring_for(node), is_exported: self.spec.is_exported(node, self.source), + signature, ..NodeExtra::default() }, ); @@ -2986,6 +4153,14 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } if let Some(value) = child_by_field(node, "value") { self.extract_type_refs_from_subtree(value, &type_alias_node.id); + } else if self.spec.language() == Language::Go { + // Go's `type Event = mvccpb.Event` names its type in the + // `type` field, and so does a defined type, `type WatchChan + // <-chan WatchResponse` (upstream #2417, #2441). + for name_node in go_alias_type_names(node, self.source) { + let name = node_text(name_node, self.source); + self.push_ref(&type_alias_node.id, &name, EdgeKind::References, name_node); + } } } } @@ -3050,6 +4225,29 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } return; } + match self.spec.language() { + Language::Rust => { + self.extract_rust_value_item(node); + return; + } + Language::Ruby => { + self.extract_ruby_constant(node); + return; + } + Language::C => { + self.extract_c_globals(node); + return; + } + Language::Pascal => { + self.extract_pascal_constant(node); + return; + } + Language::Swift => { + self.extract_swift_global(node); + return; + } + _ => {} + } if !matches!( self.spec.language(), Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx @@ -3075,6 +4273,13 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { continue; }; if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" { + // A pattern declares no symbol of its own, but its initializer + // (and any default value in the pattern) still runs: walk the + // declarator the way a function body walks it, with the + // enclosing scope on the stack, the file for module code, which + // a `\n

      also(not)

      \n
      \n
      \n", + Language::Cfml, + ); + assert!(!refs.iter().any(|r| r.reference_kind == EdgeKind::Calls)); + } + + /// Upstream #2296: a `` inside a generic container tag at + /// declaration scope is extracted (a method of the component, a function + /// of the template), never through an ERROR node. + #[test] + fn cfml_functions_in_container_tags_are_extracted() { + let src = "\n\t\n\t\t\n\t\t\t\n\t\t\n\t\n\n"; + let (nodes, refs) = run("Application.cfc", src, Language::Cfml); + let method = node(&nodes, NodeKind::Method, "onRequestStart"); + assert_eq!(method.qualified_name, "Application::onRequestStart"); + let call = refs + .iter() + .find(|r| r.reference_name == "loadConfig") + .expect("the call is extracted"); + assert_eq!(call.from_node_id, method.id); + let (nodes, _refs) = run( + "lib.cfm", + "\n\n\n", + Language::Cfml, + ); + assert!(has_node(&nodes, NodeKind::Function, "helper")); + } + + /// Upstream #2296: `` is an interface named from the file, + /// with its functions as methods and an `extends` per parent. + #[test] + fn cfml_interface_tag_is_an_interface() { + let src = "\n\t\n\t\t\n\t\n\t\n\n"; + let (nodes, refs) = run("ISearchable.cfc", src, Language::Cfml); + let iface = node(&nodes, NodeKind::Interface, "ISearchable"); + assert_eq!((iface.start_line, iface.end_line), (1, 6)); + assert!(!nodes.iter().any(|n| n.kind == NodeKind::Class)); + let mut methods: Vec<&str> = nodes + .iter() + .filter(|n| n.kind == NodeKind::Method) + .map(|n| n.qualified_name.as_str()) + .collect(); + methods.sort_unstable(); + assert_eq!(methods, ["ISearchable::count", "ISearchable::search"]); + assert_eq!( + node(&nodes, NodeKind::Method, "search") + .return_type + .as_deref(), + Some("any") + ); + assert!(has_ref(&refs, EdgeKind::Extends, "IBase")); + assert!(has_ref(&refs, EdgeKind::Extends, "IOther")); + } + + /// Upstream #2296 / #1155: a `` body read with the cfscript + /// grammar: at component scope its top-level functions are the + /// component's methods, with a method's id; in a function its calls are + /// the function's, positions the file's. + #[test] + fn cfml_script_tag_bodies_are_read_in_place() { + let (nodes, refs) = run( + "ModuleConfig.cfc", + "\n\nfunction configure() { return helper(); }\n\n\n", + Language::Cfml, + ); + let method = node(&nodes, NodeKind::Method, "configure"); + assert_eq!(method.qualified_name, "ModuleConfig::configure"); + assert!(method.id.starts_with("method:"), "{}", method.id); + assert_eq!(method.start_line, 3); + let call = refs + .iter() + .find(|r| r.reference_name == "helper") + .expect("the call is extracted"); + assert_eq!( + (call.from_node_id.as_str(), call.line, call.col), + (method.id.as_str(), 3, 30) + ); + let (nodes, refs) = run( + "Tagged.cfc", + "\nvar x = make(); x.go();\n\n", + Language::Cfml, + ); + let run_method = node(&nodes, NodeKind::Method, "run"); + let calls = cfml_calls_from(&nodes, &refs, "Tagged::run"); + assert_eq!(calls, ["make@2", "x.go@2"], "{refs:?}"); + assert!( + refs.iter() + .filter(|r| r.reference_kind == EdgeKind::Calls) + .all(|r| r.from_node_id == run_method.id) + ); + } + // ---- Literal-receiver builtin calls (#1230 / upstream c472cfb) ---------- fn call_refs(file: &str, source: &str, lang: Language) -> Vec { @@ -9693,7 +13629,7 @@ impl Greet for Bits { fn greet(&self) -> String { "bits".to_string() } } } #[test] - fn bodiless_and_anonymous_unions_are_not_indexed() { + fn bodiless_unions_are_not_indexed_and_unnamed_ones_take_their_variables_name() { let src = concat!( "union Fwd;\n", "struct FwdS;\n", @@ -9706,13 +13642,16 @@ impl Greet for Bits { fn greet(&self) -> String { "bits".to_string() } } // drops it, the same rule that keeps `struct Foo;` out. assert!(!nodes.iter().any(|n| n.name == "Fwd")); assert!(!nodes.iter().any(|n| n.name == "FwdS")); - // An anonymous union mints nothing, because the anonymous struct beside it - // mints nothing — both are consumed by the `declaration` arm. + // An unnamed union defined in a declaration is named after the variable + // the declaration declares, as the unnamed struct beside it is (upstream + // #2446): neither mints an `` node. assert!( !nodes.iter().any(|n| n.name == ""), - "an anonymous aggregate must mint no node: {:?}", + "an unnamed aggregate with a variable must mint no anonymous node: {:?}", nodes.iter().map(|n| &n.name).collect::>() ); + assert!(has_node(&nodes, NodeKind::Union, "anon_var")); + assert!(has_node(&nodes, NodeKind::Struct, "anon_struct_var")); // Positive control, so the negatives cannot pass by the file failing to parse. assert!(has_node(&nodes, NodeKind::Union, "Named")); } diff --git a/crates/codegraph-extract/tests/batch_coverage_languages.rs b/crates/codegraph-extract/tests/batch_coverage_languages.rs index c3616d49..9b0b6975 100644 --- a/crates/codegraph-extract/tests/batch_coverage_languages.rs +++ b/crates/codegraph-extract/tests/batch_coverage_languages.rs @@ -970,13 +970,72 @@ fn detect_language_routes_embedded_and_shopify_json_paths() { assert_eq!(detect_language("comp.vue"), Language::Vue); assert_eq!(detect_language("page.astro"), Language::Astro); assert_eq!(detect_language("theme.liquid"), Language::Liquid); - // Shopify template JSON under templates/ or sections/ routes to Liquid. - assert_eq!(detect_language("templates/product.json"), Language::Liquid); - assert_eq!(detect_language("sections/header.json"), Language::Liquid); + // Shopify template JSON is Liquid only inside a theme under the project + // root (upstream #2393): without a root there is no theme to look for. + assert_eq!(detect_language("templates/product.json"), Language::Unknown); + assert_eq!(detect_language("sections/header.json"), Language::Unknown); // A plain JSON elsewhere is not a Shopify liquid template. assert_eq!(detect_language("data/config.json"), Language::Unknown); } +/// Upstream #2393's `reads JSON under templates/ or sections/ as Liquid only +/// inside a theme under the project root`. +#[test] +fn shopify_json_is_liquid_only_inside_a_theme_under_the_project_root() { + use codegraph_extract::detect_language_with; + use codegraph_extract::ext_config::ExtensionOverrides; + let root = std::env::temp_dir().join(format!("codegraph-shopify-{}", std::process::id())); + std::fs::create_dir_all(root.join("shop/layout")).unwrap(); + std::fs::write(root.join("shop/layout/theme.liquid"), "").unwrap(); + let themed = ExtensionOverrides::default().with_project_root(&root); + for file in [ + "shop/templates/product.json", + "shop/templates/customers/login.json", + "shop/sections/header-group.json", + ] { + assert_eq!( + detect_language_with(file, &themed), + Language::Liquid, + "{file}" + ); + assert_eq!( + detect_language_with(file, &ExtensionOverrides::default()), + Language::Unknown, + "{file}" + ); + } + // Beside the theme, not in it; and the theme's other JSON was never a template. + for file in [ + "templates/product.json", + "other/sections/header-group.json", + "shop/config/settings_data.json", + ] { + assert_eq!( + detect_language_with(file, &themed), + Language::Unknown, + "{file}" + ); + } + // Outside a theme, JSON gets whatever the project maps `.json` to, as any + // other JSON does. + std::fs::write( + root.join("codegraph.json"), + r#"{"extensions": {".json": "yaml"}}"#, + ) + .unwrap(); + let mapped = + ExtensionOverrides::load_from_file(&root.join("codegraph.json")).with_project_root(&root); + assert_eq!( + detect_language_with("templates/product.json", &mapped), + Language::Yaml + ); + assert_eq!( + detect_language_with("shop/templates/product.json", &mapped), + Language::Liquid + ); + std::fs::remove_dir_all(&root).unwrap(); +} + // --------------------------------------------------------------------------- // Rust — return-type variants (reference/Self/generic/primitive), private vs // public visibility, receiver via impl on a generic type, and use-import root diff --git a/crates/codegraph-extract/tests/batch_coverage_languages2.rs b/crates/codegraph-extract/tests/batch_coverage_languages2.rs index 1e0c90d0..d486e49f 100644 --- a/crates/codegraph-extract/tests/batch_coverage_languages2.rs +++ b/crates/codegraph-extract/tests/batch_coverage_languages2.rs @@ -54,15 +54,12 @@ void run() {} assert!(result.errors.is_empty(), "{:?}", result.errors); // tree-sitter-dart wraps the return in `function_signature > type > - // type_identifier`, but DartSpec::get_return_type / get_signature look for - // a DIRECT `type_identifier` child of the signature, so the return type is - // not surfaced (ret == None) and the signature carries only the params. + // type_identifier`: DartSpec::get_return_type reads it from the + // signature's `return_type` field (W4-DART-03), the name without its type + // arguments; get_signature still looks for a DIRECT `type_identifier` + // child, so the signature carries only the params. let compute = assert_node(&result.nodes, NodeKind::Function, "compute"); - assert!( - compute.return_type.is_none(), - "wrapped `type` node hides the return type; got {:?}", - compute.return_type - ); + assert_eq!(compute.return_type.as_deref(), Some("int")); assert!( compute .signature @@ -82,7 +79,7 @@ void run() {} ); let collect = assert_node(&result.nodes, NodeKind::Function, "collect"); - assert!(collect.return_type.is_none()); + assert_eq!(collect.return_type.as_deref(), Some("List")); let run = assert_node(&result.nodes, NodeKind::Function, "run"); assert!( @@ -95,8 +92,9 @@ void run() {} // --------------------------------------------------------------------------- // Dart — class-body methods (method_declaration): params in signature, -// getter/setter accessors, body-call extraction, and the honest false results -// of is_static / is_async / get_return_type under tree-sitter-dart. +// getter/setter accessors, body-call extraction, the static flag and return +// type read through the signature it holds, and the honest false is_async under +// tree-sitter-dart. // --------------------------------------------------------------------------- #[test] @@ -134,12 +132,13 @@ class Service { add.signature ); - // Class-body methods parse as `method_declaration`, but DartSpec::is_static - // only detects the `static` token on a `method_signature`, and DartSpec:: - // is_async only inspects a next-sibling `function_body`, which does not - // exist for a method_declaration; so both flags stay false for real Dart. + // Class-body methods parse as `method_declaration`: DartSpec::is_static + // reads the `static` token from the `method_signature` it holds + // (W4-DART-03), but DartSpec::is_async only inspects a next-sibling + // `function_body`, which does not exist for a method_declaration, so that + // flag stays false for real Dart. let tag = assert_node(&result.nodes, NodeKind::Method, "tag"); - assert!(!tag.is_static); + assert!(tag.is_static); let load = assert_node(&result.nodes, NodeKind::Method, "load"); assert!(!load.is_async); @@ -151,7 +150,7 @@ class Service { ); let make = assert_node(&result.nodes, NodeKind::Method, "make"); - assert!(make.return_type.is_none()); + assert_eq!(make.return_type.as_deref(), Some("Widget")); assert!( result .unresolved_references diff --git a/crates/codegraph-extract/tests/c_cpp_alias_signatures.rs b/crates/codegraph-extract/tests/c_cpp_alias_signatures.rs new file mode 100644 index 00000000..55cdfa81 --- /dev/null +++ b/crates/codegraph-extract/tests/c_cpp_alias_signatures.rs @@ -0,0 +1,132 @@ +//! A C or C++ `type_alias` node records its declaration as its signature +//! (KEEP-RUST, owner 2026-10-12; upstream #2399 reads an alias's type from the +//! declaration, and records nothing on the node): the original source of the +//! declaration, with the `template <…>` head of an alias template, +//! whitespace collapsed and the trailing `;` dropped. A sync compares it to +//! tell an alias that names another type. + +use codegraph_core::types::{ExtractionResult, Language, NodeKind}; +use codegraph_extract::extract_source; + +/// `(kind, qualified name, signature)` of every symbol. +fn signatures(result: &ExtractionResult) -> Vec<(NodeKind, String, Option)> { + result + .nodes + .iter() + .filter(|node| !matches!(node.kind, NodeKind::File | NodeKind::Import)) + .map(|node| { + ( + node.kind, + node.qualified_name.clone(), + node.signature.clone(), + ) + }) + .collect() +} + +fn alias(qualified: &str, signature: &str) -> (NodeKind, String, Option) { + ( + NodeKind::TypeAlias, + qualified.to_string(), + Some(signature.to_string()), + ) +} + +#[test] +fn a_c_typedef_records_its_declaration() { + let result = extract_source( + "attr_macro.c", + "typedef unsigned int UINT32;\n", + Some(Language::C), + ); + assert_eq!( + signatures(&result), + [alias("UINT32", "typedef unsigned int UINT32")] + ); +} + +#[test] +fn a_cpp_using_and_an_alias_template_record_their_declarations() { + let result = extract_source( + "db/table.h", + concat!( + "namespace leveldb {\n", + "using Lookup = Table;\n", + "template \n", + "using Ordered = SkipList;\n", + "} // namespace leveldb\n", + ), + Some(Language::Cpp), + ); + assert_eq!( + signatures(&result), + [ + alias("leveldb::Lookup", "using Lookup = Table"), + alias( + "leveldb::Ordered", + "template using Ordered = SkipList" + ), + ] + ); +} + +#[test] +fn whitespace_and_line_breaks_collapse_to_one_space() { + for source in [ + "typedef SkipList Table ;\n", + "typedef SkipList Table ;\r\n", + ] { + let result = extract_source("db/memtable.h", source, Some(Language::Cpp)); + assert_eq!( + signatures(&result), + [alias( + "Table", + "typedef SkipList Table" + )], + "{source:?}" + ); + } +} + +#[test] +fn the_signature_is_the_source_as_written_not_the_pre_parsed_text() { + // The C++ pre-parse blanks `GUARDED_BY(mu_)` after a declared name so the + // grammar reads the declaration; the signature keeps it, as resolution + // reads it from the file. + let result = extract_source( + "db/counter.h", + "class Counter {\n typedef int Count GUARDED_BY(mu_);\n};\n", + Some(Language::Cpp), + ); + assert_eq!( + signatures(&result), + [ + (NodeKind::Class, "Counter".to_string(), None), + alias("Counter::Count", "typedef int Count GUARDED_BY(mu_)"), + ] + ); +} + +#[test] +fn a_typedef_that_names_an_aggregate_has_no_signature() { + let result = extract_source( + "src/types.c", + concat!( + "typedef struct { int a; } Pair;\n", + "typedef union Word { int i; float f; } Word;\n", + "typedef enum { MODE_IDLE, MODE_BUSY } Mode;\n", + ), + Some(Language::C), + ); + assert_eq!( + signatures(&result) + .into_iter() + .filter(|(kind, _, _)| *kind != NodeKind::EnumMember) + .collect::>(), + [ + (NodeKind::Struct, "Pair".to_string(), None), + (NodeKind::Union, "Word".to_string(), None), + (NodeKind::Enum, "Mode".to_string(), None), + ] + ); +} diff --git a/crates/codegraph-extract/tests/c_cpp_declaration_types.rs b/crates/codegraph-extract/tests/c_cpp_declaration_types.rs new file mode 100644 index 00000000..cb7b65fe --- /dev/null +++ b/crates/codegraph-extract/tests/c_cpp_declaration_types.rs @@ -0,0 +1,258 @@ +//! C/C++ extraction for upstream #2446 (`235387ec`): a class, struct, union or +//! enum defined in the type of a variable declaration is indexed. +//! +//! ```c +//! namespace n { struct Foo { int a; void f() {} } foo; } // only n::foo +//! static struct { int argc; char **argv; } SPT; // nothing at all +//! enum { MODE_IDLE, MODE_BUSY } current_mode; // no enumerators +//! ``` +//! +//! The type is walked like one written on its own, beside the variables (which +//! keep their nodes), and the comment above the declaration documents it. An +//! unnamed one takes the name of the first variable its declaration declares, +//! as `typedef struct { … } Name;` takes the typedef name. In a C++ file whose +//! tree has errors the type is walked in the scopes the file's braces open +//! (#2426), and not at all when the braces do not balance. +//! +//! Upstream's cases, with two differences that predate #2446: a C++ file-scope +//! declaration mints no variable here (upstream's generic fallback makes one of +//! each bare declarator), so its C++ variable lines are not asserted; and the +//! body walk extracts no struct or union (upstream's does since v0.7.6 and +//! v1.6.0), so the function-body case is an enum and a class. + +use codegraph_core::types::{EdgeKind, ExtractionResult, Language, NodeKind}; +use codegraph_extract::extract_source; + +/// Each case runs with LF and with CRLF line endings. +fn sources(source: &str) -> [String; 2] { + [source.to_string(), source.replace('\n', "\r\n")] +} + +/// ` ` for every symbol, file and imports aside. +fn symbols(result: &ExtractionResult) -> Vec { + result + .nodes + .iter() + .filter(|node| !matches!(node.kind, NodeKind::File | NodeKind::Import)) + .map(|node| format!("{} {}", node.kind.as_str(), node.qualified_name)) + .collect() +} + +/// ` > ` for every containment edge below the file. +fn containment(result: &ExtractionResult) -> Vec { + let qualified = |id: &str| { + result + .nodes + .iter() + .find(|node| node.id == id) + .map(|node| (node.kind, node.qualified_name.clone())) + }; + result + .edges + .iter() + .filter(|edge| edge.kind == EdgeKind::Contains) + .filter_map(|edge| { + let (kind, source) = qualified(&edge.source)?; + (kind != NodeKind::File).then(|| { + format!( + "{source} > {}", + qualified(&edge.target).map_or_else(String::new, |(_, target)| target) + ) + }) + }) + .collect() +} + +#[test] +fn cpp_classes_and_structs_sit_beside_their_variables() { + for source in sources(concat!( + "namespace n {\n", + "struct Foo { int a; void f() {} } foo;\n", + "class Bar { void g() {} } bar, *pbar;\n", + "}\n", + )) { + let result = extract_source("src/decl.cpp", &source, Some(Language::Cpp)); + assert_eq!( + symbols(&result), + [ + "struct n::Foo", + "method n::Foo::f", + "class n::Bar", + "method n::Bar::g", + ] + ); + assert_eq!( + containment(&result), + ["n::Foo > n::Foo::f", "n::Bar > n::Bar::g"] + ); + } +} + +#[test] +fn c_structs_unions_and_enums_an_unnamed_one_takes_its_variables_name() { + for source in sources(concat!( + "struct Foo { int a; } foo;\n", + "static struct { int argc; char **argv; } SPT;\n", + "static const struct { const char *name; int code; } errors[] = { { \"none\", 0 } };\n", + "struct { int x; } *first_point, second_point;\n", + "union { int i; float f; } scratch = { 0 };\n", + "enum { MODE_IDLE, MODE_BUSY } current_mode;\n", + "typedef struct Bar { int c; } Bar;\n", + )) { + let result = extract_source("src/decl.c", &source, Some(Language::C)); + assert_eq!( + symbols(&result), + [ + "struct Foo", + "struct SPT", + "struct errors", + "constant errors", + "struct first_point", + "variable first_point", + "union scratch", + "variable scratch", + "enum current_mode", + "enum_member current_mode::MODE_IDLE", + "enum_member current_mode::MODE_BUSY", + "struct Bar", + ] + ); + } +} + +#[test] +fn a_type_whose_declaration_declares_no_variable_stays_unnamed() { + // A function's return type: naming it after the function would be wrong. + for source in sources("struct { int unused; } make_unnamed(void);\n") { + let result = extract_source("src/proto.c", &source, Some(Language::C)); + assert_eq!(symbols(&result), ["struct "]); + } +} + +/// ` ` of the nodes named `Registry` or `registry`. +fn registry_docs(result: &ExtractionResult) -> Vec { + result + .nodes + .iter() + .filter(|node| node.name == "Registry" || node.name == "registry") + .map(|node| { + format!( + "{} {}", + node.kind.as_str(), + node.docstring.as_deref().unwrap_or("-") + ) + }) + .collect() +} + +#[test] +fn the_comment_above_the_declaration_documents_the_type() { + for source in sources(concat!( + "namespace app {\n", + "/** The handler registry. */\n", + "struct Registry { int size() const { return 2; } } registry;\n", + "}\n", + )) { + let result = extract_source("src/registry.cc", &source, Some(Language::Cpp)); + assert_eq!(registry_docs(&result), ["struct The handler registry."]); + } + // In C the variable is a node too, and keeps the comment. + for source in sources(concat!( + "/** The handler registry. */\n", + "static struct Registry { int size; } *registry;\n", + )) { + let result = extract_source("src/registry.c", &source, Some(Language::C)); + assert_eq!( + registry_docs(&result), + [ + "struct The handler registry.", + "variable The handler registry." + ] + ); + } +} + +#[test] +fn an_unnamed_type_in_a_function_body_takes_its_variables_name_too() { + for source in sources(concat!( + "int hits(void) {\n", + " static enum { MODE_IDLE, MODE_BUSY } local_mode;\n", + " return local_mode;\n", + "}\n", + )) { + let result = extract_source("src/stats.c", &source, Some(Language::C)); + assert_eq!( + symbols(&result), + [ + "function hits", + "enum hits::local_mode", + "enum_member hits::local_mode::MODE_IDLE", + "enum_member hits::local_mode::MODE_BUSY", + ] + ); + } + for source in sources(concat!( + "int hits() {\n", + " static class { public: int count; } local_stats;\n", + " return ++local_stats.count;\n", + "}\n", + )) { + let result = extract_source("src/stats.cc", &source, Some(Language::Cpp)); + assert_eq!( + symbols(&result), + ["function hits", "class hits::local_stats"] + ); + } +} + +#[test] +fn a_misread_file_c_keeps_the_type_cpp_needs_balanced_braces() { + for source in sources("static struct { int argc; } SPT;\nint broken( { return 1; }\n") { + let c = extract_source("src/spt.c", &source, Some(Language::C)); + assert!( + symbols(&c).contains(&"struct SPT".to_string()), + "{:?}", + symbols(&c) + ); + } + // A C++ file with errors is walked in the scopes its braces open, so the + // class lands in its namespace. When the braces do not balance there are + // no scopes to trust, and error recovery can close a namespace or a class + // at the wrong `}`: the class is not walked. + let config = "namespace n {\nstruct Config { int retries; void apply() {} } config;\n}\n"; + for source in sources(&format!("{config}int broken( {{ return 1; }}\n")) { + let balanced = extract_source("src/config.cc", &source, Some(Language::Cpp)); + assert_eq!( + symbols(&balanced), + ["struct n::Config", "method n::Config::apply"] + ); + } + for source in sources(&format!("{config}int broken() {{ return 1;\n")) { + let unbalanced = extract_source("src/config2.cc", &source, Some(Language::Cpp)); + assert_eq!(symbols(&unbalanced), ["function broken"]); + } +} + +#[test] +fn a_function_value_in_the_types_body_is_captured_once_by_the_type() { + for source in sources(concat!( + "void on_open() {}\n", + "struct Handlers { void (*all[1])() = {&on_open}; } handlers;\n", + )) { + let result = extract_source("src/handlers.cc", &source, Some(Language::Cpp)); + let refs: Vec = result + .unresolved_references + .iter() + .filter(|reference| reference.is_function_ref) + .map(|reference| { + let from = result + .nodes + .iter() + .find(|node| node.id == reference.from_node_id) + .map_or("?", |node| node.name.as_str()); + format!("{from} {}", reference.reference_name) + }) + .collect(); + assert_eq!(refs, ["Handlers on_open"]); + } +} diff --git a/crates/codegraph-extract/tests/c_family_correctness.rs b/crates/codegraph-extract/tests/c_family_correctness.rs index f3f67472..5871e47f 100644 --- a/crates/codegraph-extract/tests/c_family_correctness.rs +++ b/crates/codegraph-extract/tests/c_family_correctness.rs @@ -253,3 +253,32 @@ fn single_argument_function_macros_are_never_guessed() { Some("old_style") ); } + +/// A `.h` header is C by its extension but is extracted as C++ or Objective-C +/// when its content says so. The file row records that same sniffed language, +/// so it agrees with the header's nodes (G15). +#[test] +fn a_sniffed_header_records_the_language_of_its_nodes() { + use codegraph_extract::engine::detect_language_for_source; + use codegraph_extract::ext_config::ExtensionOverrides; + + let overrides = ExtensionOverrides::default(); + let cpp = "class Shape {\npublic:\n int sides() const { return 4; }\n};\n"; + let objc = "@interface Shape : NSObject\n- (int)sides;\n@end\n"; + let c = "struct shape {\n int sides;\n};\n\nint shape_sides(const struct shape *s);\n"; + for (path, source, want) in [ + ("include/shape.h", cpp, Language::Cpp), + ("include/Shape.H", cpp, Language::Cpp), + ("include/shape.h", objc, Language::ObjC), + ("include/shape.h", c, Language::C), + // Only a header is sniffed: a `.c` file is C whatever it holds. + ("src/shape.c", cpp, Language::C), + ("src/shape.cpp", c, Language::Cpp), + ] { + let language = detect_language_for_source(path, source, &overrides); + assert_eq!(language, want, "{path} holding {source:?}"); + for node in extract_source(path, source, None).nodes { + assert_eq!(node.language, language, "{path}: node {} agrees", node.name); + } + } +} diff --git a/crates/codegraph-extract/tests/c_preparse_round_two.rs b/crates/codegraph-extract/tests/c_preparse_round_two.rs new file mode 100644 index 00000000..db40829b --- /dev/null +++ b/crates/codegraph-extract/tests/c_preparse_round_two.rs @@ -0,0 +1,102 @@ +//! C extraction for upstream's deferral round 2 (#1353, `b9d0f57`): kernel +//! annotations, macro arguments and declaration macros that tree-sitter-c +//! reads as errors are rewritten before parsing, so the declarations around +//! them keep their names and the ones after them are not swallowed. + +use codegraph_core::types::{ExtractionResult, Language, NodeKind}; +use codegraph_extract::extract_source; + +/// ` ` for every symbol, file and imports aside. +fn symbols(result: &ExtractionResult) -> Vec { + result + .nodes + .iter() + .filter(|node| !matches!(node.kind, NodeKind::File | NodeKind::Import)) + .map(|node| format!("{} {}", node.kind.as_str(), node.qualified_name)) + .collect() +} + +fn extract(source: &str) -> Vec { + symbols(&extract_source("src/kernel.c", source, Some(Language::C))) +} + +#[track_caller] +fn assert_has(found: &[String], expected: &[&str]) { + for symbol in expected { + assert!( + found.iter().any(|f| f == symbol), + "{symbol} missing: {found:?}" + ); + } +} + +#[test] +fn a_sandwiched_annotation_keeps_the_function_and_the_next_one() { + let found = extract( + "static notrace void tick_do(void) { do_tick(); }\nstatic nokprobe_inline void arm(void) { }\nstatic int after(void) { return 1; }\n", + ); + assert_has( + &found, + &["function tick_do", "function arm", "function after"], + ); +} + +#[test] +fn a_c23_auto_local_keeps_its_function() { + let found = + extract("void q(void) { auto hb = get_hb(); use(hb); }\nint after(void) { return 2; }\n"); + assert_has(&found, &["function q", "function after"]); +} + +#[test] +fn a_parameterized_annotation_is_not_the_function_name() { + let found = extract( + "static void __printf(4, 0) log_it(int a, const char *fmt, ...) { }\nint after(void) { return 3; }\n", + ); + assert_has(&found, &["function log_it", "function after"]); + assert!(!found.iter().any(|f| f == "function __printf"), "{found:?}"); +} + +#[test] +fn a_type_keyword_argument_keeps_its_function_and_the_next_one() { + let found = extract( + "void j(void *head) {\n\tvoid *p = kzalloc_obj(struct bpf_mount_opts);\n\tuse(p);\n}\nint later(void) { return 0; }\n", + ); + assert_has(&found, &["function j", "function later"]); +} + +#[test] +fn a_prefixed_declaration_macro_swallows_nothing_after_it() { + let found = extract( + "static DEFINE_PER_CPU(struct llist_head, rstat_backlog_list);\nint after(void) { return 4; }\n", + ); + assert_has(&found, &["function after"]); +} + +#[test] +fn an_initialized_declaration_macro_declares_its_variable() { + let found = extract( + "int bringup_cpu(void) { return 0; }\nstatic DEFINE_PER_CPU(struct cpuhp_cpu_state, cpuhp_state) = {\n\t.startup = bringup_cpu,\n};\nint after(void) { return 5; }\n", + ); + assert_has(&found, &["function bringup_cpu", "function after"]); + assert!( + found.iter().any(|f| f.ends_with(" cpuhp_state")), + "{found:?}" + ); +} + +#[test] +fn a_qualified_va_arg_type_keeps_its_function() { + let found = extract( + "void f(va_list ap) {\n\tconst char *s = va_arg(ap, const char *);\n\tuse(s);\n}\nint after(void) { return 6; }\n", + ); + assert_has(&found, &["function f", "function after"]); +} + +#[test] +fn a_named_variadic_define_is_a_macro() { + let found = extract( + "#define verbose(env, fmt, args...) log_write(env, fmt, ##args)\nint after(void) { return 7; }\n", + ); + assert_has(&found, &["constant verbose", "function after"]); +} diff --git a/crates/codegraph-extract/tests/coverage_embedded.rs b/crates/codegraph-extract/tests/coverage_embedded.rs index 02506d9b..3dd8aa9e 100644 --- a/crates/codegraph-extract/tests/coverage_embedded.rs +++ b/crates/codegraph-extract/tests/coverage_embedded.rs @@ -150,7 +150,8 @@ fn liquid_json_template_dedups_section_type_refs() { "#; let result = extract_source("templates/product.json", source, Some(Language::Liquid)); assert!(result.errors.is_empty(), "{:?}", result.errors); - assert_eq!(detect_language("templates/product.json"), Language::Liquid); + // Without the project root no theme can hold it (upstream #2393). + assert_eq!(detect_language("templates/product.json"), Language::Unknown); // Duplicate section type is emitted only once (BTreeSet dedup). let main_refs = result .unresolved_references diff --git a/crates/codegraph-extract/tests/cpp_phantom_macro_functions.rs b/crates/codegraph-extract/tests/cpp_phantom_macro_functions.rs new file mode 100644 index 00000000..aba2830c --- /dev/null +++ b/crates/codegraph-extract/tests/cpp_phantom_macro_functions.rs @@ -0,0 +1,70 @@ +//! C++ extraction for upstream #946 (`f63e5db2`, the shape at `b635dd46`): +//! a class or struct annotated with a macro tree-sitter does not know +//! (`class MYLIB_EXPORT Foo : public Bar { … }`) reads as an elaborated type +//! specifier followed by a function definition named after the class (or its +//! base), spanning the whole body. That phantom function polluted callers and +//! impact. The ALL-CAPS export-macro shapes are recovered as classes before +//! this check runs (W4-CPP-01); what is left, a lowercase macro or one the +//! recovery does not read, mints no function. + +use codegraph_core::types::{ExtractionResult, Language, NodeKind}; +use codegraph_extract::extract_source; + +/// ` ` for every symbol, file and imports aside. +fn symbols(result: &ExtractionResult) -> Vec { + result + .nodes + .iter() + .filter(|node| !matches!(node.kind, NodeKind::File | NodeKind::Import)) + .map(|node| format!("{} {}", node.kind.as_str(), node.qualified_name)) + .collect() +} + +/// Upstream #946's cases: a macro-annotated class that inherits, and the +/// struct variant beside a genuine class with a base clause. +#[test] +fn a_macro_annotated_class_mints_no_phantom_function() { + let header = "#pragma once\n#define MAPCORE_EXPORT __attribute__((visibility(\"default\")))\n\nclass DataProvider {\npublic:\n virtual bool Request(void* param) = 0;\n};\n\nclass MAPCORE_EXPORT LocalDataProvider : public DataProvider\n{\npublic:\n LocalDataProvider(int dataType);\n virtual bool Request(void* param) override;\n};\n"; + let result = extract_source("provider.h", header, Some(Language::Cpp)); + let found = symbols(&result); + assert!( + !found.contains(&"function LocalDataProvider".to_string()), + "{found:?}" + ); + assert!( + found.contains(&"class DataProvider".to_string()), + "{found:?}" + ); + + let source = "\n#define API __declspec(dllexport)\nstruct API Widget : public Base { int x; };\nclass Plain : public Base { public: int y; };\n"; + let result = extract_source("widget.cpp", source, Some(Language::Cpp)); + let found = symbols(&result); + assert!(!found.contains(&"function Widget".to_string()), "{found:?}"); + assert!(found.contains(&"class Plain".to_string()), "{found:?}"); +} + +/// The residue the export-macro recovery does not read: a lowercase macro, +/// with or without a base clause, mints no function, whichever name the +/// misparse gives it (the base's, `Base`, or the class's, `Zed`). +#[test] +fn a_lowercase_macro_before_a_class_name_mints_no_function() { + let source = "class my_export Foo : public Base {\n public:\n void run() {}\n};\nclass lower_api Zed {\n int z;\n};\n"; + let result = extract_source("shapes.cpp", source, Some(Language::Cpp)); + let found = symbols(&result); + assert!( + !found.iter().any(|symbol| symbol.starts_with("function ")), + "{found:?}" + ); +} + +/// Genuine code keeps its functions: a real inline-defined return type has a +/// body, and an elaborated return type on a real function has a function +/// declarator. +#[test] +fn genuine_functions_returning_a_class_stay() { + let source = "struct P { int x; } makeP() { return P{}; }\nclass Foo f() { return Foo(); }\n"; + let result = extract_source("genuine.cpp", source, Some(Language::Cpp)); + let found = symbols(&result); + assert!(found.contains(&"function makeP".to_string()), "{found:?}"); + assert!(found.contains(&"function f".to_string()), "{found:?}"); +} diff --git a/crates/codegraph-extract/tests/cpp_qualified_calls.rs b/crates/codegraph-extract/tests/cpp_qualified_calls.rs new file mode 100644 index 00000000..be8e9045 --- /dev/null +++ b/crates/codegraph-extract/tests/cpp_qualified_calls.rs @@ -0,0 +1,79 @@ +//! A C++ call keeps the scope it is written with (G13): `ns::f()` calls +//! `ns::f`, a static or base-class call `Base::m()` calls `Base::m`, and a +//! call from the global scope `::f()` calls `::f`. Template arguments are +//! dropped. The resolver binds a qualified name by its qualified-name match. + +use codegraph_core::types::{EdgeKind, Language}; +use codegraph_extract::extract_source; + +/// The callee names `caller` calls in `source`, sorted. +fn calls_from(source: &str, caller: &str) -> Vec { + let result = extract_source("src/run.cpp", source, Some(Language::Cpp)); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let caller_id = &result + .nodes + .iter() + .find(|node| node.qualified_name == caller) + .unwrap_or_else(|| panic!("no node {caller}")) + .id; + let mut names: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == EdgeKind::Calls && &r.from_node_id == caller_id) + .map(|r| r.reference_name.clone()) + .collect(); + names.sort(); + names +} + +const SOURCE: &str = r#"namespace ns { +void compute() {} +namespace inner { +void deep() {} +} +} + +void global_fn() {} + +struct Base { + static void make(); + void hook() {} +}; + +struct Derived : Base { + void hook() { Base::hook(); } +}; + +template struct Box { + static void make(); +}; + +void run() { + ns::compute(); + ns::inner::deep(); + ::global_fn(); + ::ns::compute(); + Base::make(); + Box::make(); + std::move(1); + compute(); +} +"#; + +#[test] +fn a_qualified_call_keeps_its_scope() { + assert_eq!( + calls_from(SOURCE, "run"), + [ + "::global_fn", + "::ns::compute", + "Base::make", + "Box::make", + "compute", + "ns::compute", + "ns::inner::deep", + "std::move", + ] + ); + assert_eq!(calls_from(SOURCE, "Derived::hook"), ["Base::hook"]); +} diff --git a/crates/codegraph-extract/tests/dart_annotations.rs b/crates/codegraph-extract/tests/dart_annotations.rs new file mode 100644 index 00000000..e8c6f9bf --- /dev/null +++ b/crates/codegraph-extract/tests/dart_annotations.rs @@ -0,0 +1,66 @@ +//! A Dart annotation's name (upstream #2390): a dotted one (`@meta.immutable`, +//! `@Riverpod.scoped`, `@p.Foo.named(…)`) is recorded by its last name, +//! `@Foo.named(…)` by the name its type arguments follow. KEEP-RUST: +//! this grammar writes a dotted name as a `qualified` node. An annotation +//! belongs to the declaration that follows it, whatever comments come +//! between them (upstream's comment fixture): ALREADY-HAVE, as this grammar +//! writes a declaration's annotations and comments inside it. + +use codegraph_core::types::{EdgeKind, Language}; +use codegraph_extract::extract_source; + +/// The annotation names recorded on the node `qualified`, sorted. +fn decorators(source: &str, qualified: &str) -> Vec { + let result = extract_source("lib/counter.dart", source, Some(Language::Dart)); + let node = result + .nodes + .iter() + .find(|n| n.qualified_name == qualified) + .unwrap_or_else(|| panic!("{qualified}")); + let mut names: Vec = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == EdgeKind::Decorates && r.from_node_id == node.id) + .map(|r| r.reference_name.clone()) + .collect(); + names.sort(); + names +} + +#[test] +fn a_dotted_annotation_is_recorded_by_its_last_name() { + let source = "import 'package:meta/meta.dart' as meta;\n\n@meta.immutable\nclass C {}\n\n@Riverpod.scoped\nint s() => 0;\n\n@Riverpod.forever()\nint k() => 0;\n\n@meta.Foo.named(1)\nint p() => 0;\n\n@Foo.named(1)\nint e() => 0;\n\nclass M {\n @Riverpod.scoped\n void g() {}\n}\n"; + assert_eq!(decorators(source, "C"), ["immutable"]); + assert_eq!(decorators(source, "s"), ["scoped"]); + assert_eq!(decorators(source, "k"), ["forever"]); + assert_eq!(decorators(source, "p"), ["named"]); + assert_eq!(decorators(source, "e"), ["Foo"]); + assert_eq!(decorators(source, "M::g"), ["scoped"]); +} + +/// Upstream's `dart-annotation-comments.test.ts` source. KEEP-RUST: stacked +/// annotations are recorded in source order (upstream's in reverse); the +/// graph keeps no order among a node's references, so they are compared +/// sorted. +const SOURCE: &str = "import 'package:meta/meta.dart';\n// After the import.\n@visibleForTesting\n// Below the annotation.\nvoid afterImport() {}\n\n@override // On the annotation's line.\nvoid trailing() {}\n\n@riverpod\n// expect_lint: functional_ref\nint counter(Ref ref) => 0;\n\n@Deprecated('x')\n/// Doc below the annotation.\n/* And a block comment. */\nvoid docBelow() {}\n\n@a\n// one\n@b\n/** two */\n@c\nvoid stacked() {}\n\n@x\nfinal answer = 42;\n// After a variable.\nvoid afterVariable() {}\n\nclass Counter {\n @override\n // ignore: must_call_super\n void dispose() {}\n\n @protected\n // A comment.\n void bodiless();\n\n @literal\n // A comment.\n const Counter.zero();\n\n @x /* inline */ void inline() {}\n\n @JsonKey(name: 'value')\n final int value = 0;\n // After a field.\n void afterField() {}\n\n void before() {} // Trailing the previous member.\n @override\n void afterBody() {}\n}\n"; + +#[test] +fn an_annotation_behind_a_comment_still_decorates_its_declaration() { + for source in [SOURCE.to_string(), SOURCE.replace('\n', "\r\n")] { + assert_eq!(decorators(&source, "afterImport"), ["visibleForTesting"]); + assert_eq!(decorators(&source, "trailing"), ["override"]); + assert_eq!(decorators(&source, "counter"), ["riverpod"]); + assert_eq!(decorators(&source, "docBelow"), ["Deprecated"]); + assert_eq!(decorators(&source, "stacked"), ["a", "b", "c"]); + assert_eq!(decorators(&source, "Counter::dispose"), ["override"]); + assert_eq!(decorators(&source, "Counter::bodiless"), ["protected"]); + assert_eq!(decorators(&source, "Counter::zero"), ["literal"]); + assert_eq!(decorators(&source, "Counter::inline"), ["x"]); + assert_eq!(decorators(&source, "Counter::afterBody"), ["override"]); + assert_eq!(decorators(&source, "afterVariable"), Vec::::new()); + assert_eq!( + decorators(&source, "Counter::afterField"), + Vec::::new() + ); + } +} diff --git a/crates/codegraph-extract/tests/dart_const_constructors.rs b/crates/codegraph-extract/tests/dart_const_constructors.rs new file mode 100644 index 00000000..d2d92b3f --- /dev/null +++ b/crates/codegraph-extract/tests/dart_const_constructors.rs @@ -0,0 +1,149 @@ +//! Dart `const` constructors and redirecting factories are methods +//! (upstream #2380, its kernel-parity fixture `TortureConstCtors.dart`): +//! named by the constructor, returning their class, with a parameters-only +//! signature; the unnamed ones stay unindexed, as the unnamed generative +//! constructor is. A redirecting factory references the class it constructs +//! (an UpperCamel name of its target), never the target's constructor or an +//! import prefix. + +use codegraph_core::types::{EdgeKind, Language, NodeKind}; +use codegraph_extract::extract_source; + +const SOURCE: &str = "import 'package:meta/meta.dart';\nimport 'package:other/other.dart' as p;\n\nconst kDepth = 3;\n\n/// A provider with const constructors and redirecting factories.\nclass ProviderT {\n /// The unnamed const constructor: no node.\n const ProviderT({required this.create, this.lazy = true}) : _value = null;\n\n /// A named const constructor that redirects.\n const ProviderT.value({required T value, int depth = kDepth})\n : this._(value: value);\n\n @visibleForTesting\n const ProviderT._({T? value})\n : create = null,\n lazy = false,\n _value = value;\n\n const factory ProviderT.redirect({int a}) = _ImplT;\n\n factory ProviderT.plain() = _ImplT.named;\n\n const factory ProviderT.json(Map json) = _ImplT.fromJson;\n\n const factory ProviderT.prefixed() = p.OtherT;\n\n const factory ProviderT.prefixedNamed() = p.OtherT.named;\n\n const factory ProviderT.unnamedTarget() = _ImplT;\n\n const factory ProviderT() = _ImplT.named;\n\n final Object? create;\n final bool lazy;\n final T? _value;\n\n void handle(Object value) => accept(value);\n\n static ProviderT make() => ProviderT.value(value: 1);\n\n static Object typed(dynamic box) {\n ProviderT.value(value: 2);\n final made = ProviderT.value(value: 3);\n accept(ProviderT.value(value: 4));\n wrap(child: ProviderT.value(value: 5));\n box.inner.value(6);\n return p.OtherT.named(made);\n }\n}\n\nclass _ImplT extends ProviderT {\n const _ImplT({int a = 0}) : super._();\n\n const _ImplT.named() : super._();\n\n const _ImplT.fromJson(Map json) : super._();\n}\n\nclass _$GeneratedT {\n const _$GeneratedT.create();\n}\n\nenum StatusT {\n ok(200),\n gone.named(410);\n\n const StatusT(this.code);\n\n const StatusT.named(this.code);\n\n final int code;\n}\n\nextension type const MetersT(int value) {\n const MetersT.zero() : this(0);\n}\n\nfinal topProvider = ProviderT.value(value: 7);\n\nObject build() => ProviderT.value(value: 8);\n\nvoid accept(Object o) {}\n\nvoid wrap({required Object child}) {}\n"; + +#[test] +fn named_const_constructors_and_redirecting_factories_are_methods() { + let result = extract_source("lib/torture.dart", SOURCE, Some(Language::Dart)); + let methods: Vec<(String, Option, Option)> = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Method) + .map(|n| { + ( + n.qualified_name.clone(), + n.signature.clone(), + n.return_type.clone(), + ) + }) + .collect(); + let method = |q: &str, sig: &str, ret: Option<&str>| { + ( + q.to_string(), + Some(sig.to_string()), + ret.map(str::to_string), + ) + }; + assert_eq!( + methods, + [ + method( + "ProviderT::value", + "({required T value, int depth = kDepth})", + Some("ProviderT") + ), + method("ProviderT::_", "({T? value})", Some("ProviderT")), + method("ProviderT::redirect", "({int a})", Some("ProviderT")), + method("ProviderT::plain", "()", Some("ProviderT")), + method( + "ProviderT::json", + "(Map json)", + Some("ProviderT") + ), + method("ProviderT::prefixed", "()", Some("ProviderT")), + method("ProviderT::prefixedNamed", "()", Some("ProviderT")), + method("ProviderT::unnamedTarget", "()", Some("ProviderT")), + method("ProviderT::handle", "(Object value)", None), + method("ProviderT::make", "()", Some("ProviderT")), + method("ProviderT::typed", "(dynamic box)", Some("Object")), + method("_ImplT::named", "()", Some("_ImplT")), + method( + "_ImplT::fromJson", + "(Map json)", + Some("_ImplT") + ), + method("_$GeneratedT::create", "()", Some("_$GeneratedT")), + method("StatusT::named", "(this.code)", Some("StatusT")), + method("MetersT::zero", "()", Some("MetersT")), + ] + ); +} + +#[test] +fn a_redirecting_factory_references_the_class_it_constructs() { + let result = extract_source("lib/torture.dart", SOURCE, Some(Language::Dart)); + let references_of = |name: &str| -> Vec { + let node = result + .nodes + .iter() + .find(|n| n.qualified_name == format!("ProviderT::{name}")) + .unwrap_or_else(|| panic!("ProviderT::{name}")); + result + .unresolved_references + .iter() + .filter(|r| r.from_node_id == node.id && r.reference_kind == EdgeKind::References) + .map(|r| r.reference_name.clone()) + .collect() + }; + assert_eq!(references_of("redirect"), ["_ImplT", "T"]); + assert_eq!(references_of("plain"), ["_ImplT", "T"]); + assert_eq!(references_of("json"), ["Map", "Object", "_ImplT"]); + assert_eq!(references_of("prefixed"), ["OtherT"]); + assert_eq!(references_of("prefixedNamed"), ["OtherT"]); + assert_eq!(references_of("unnamedTarget"), ["_ImplT"]); + // The unnamed redirecting factory (`const factory ProviderT() = + // _ImplT.named;`) is no node, and neither its target's constructor nor + // its class is anything's reference. + assert!( + !result.unresolved_references.iter().any(|r| r.line == 33 + || (r.reference_name == "named" && r.reference_kind == EdgeKind::References)), + "{:?}", + result.unresolved_references + ); +} + +/// KEEP-RUST: a constructor called with type arguments, +/// `ProviderT.value(…)`, is recorded by its member where the chain +/// starts, as a chain link is; the chain reader types `ProviderT` as the +/// type (W4-DART-03b), where upstream records `ProviderT.value`. +#[test] +fn a_constructor_called_with_type_arguments_is_recorded_where_its_type_is_written() { + let result = extract_source("lib/torture.dart", SOURCE, Some(Language::Dart)); + let mut calls: Vec<(i64, i64, String)> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == EdgeKind::Calls && r.reference_name.ends_with("value")) + .map(|r| (r.line, r.col, r.reference_name.clone())) + .collect(); + calls.sort(); + let at = |line: i64, col: i64, name: &str| (line, col, name.to_string()); + assert_eq!( + calls, + [ + at(41, 34, "value"), + at(44, 4, "value"), + at(45, 17, "value"), + at(46, 11, "value"), + at(47, 16, "value"), + at(48, 4, "inner.value"), + at(80, 20, "value"), + at(82, 18, "ProviderT.value"), + ] + ); +} + +/// The names before the parameters name a constructor; a redirect's target +/// constructor after them names nothing (`factory Box() = _BoxImpl.named;` +/// is the unnamed factory). One that names no enclosing type is error +/// recovery's, and no node. +#[test] +fn a_constructor_is_named_before_its_parameters_and_by_its_own_type() { + let source = "class Box {\n factory Box() = _BoxImpl.named;\n const factory Box.of() = _BoxImpl.named;\n}\n\nclass _BoxImpl implements Box {\n const _BoxImpl.named();\n}\n\nclass A {\n const B.made();\n}\n"; + let result = extract_source("lib/box.dart", source, Some(Language::Dart)); + let methods: Vec<&str> = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Method) + .map(|n| n.qualified_name.as_str()) + .collect(); + assert_eq!(methods, ["Box::of", "_BoxImpl::named"]); +} diff --git a/crates/codegraph-extract/tests/dart_generic_calls.rs b/crates/codegraph-extract/tests/dart_generic_calls.rs new file mode 100644 index 00000000..92e6ddda --- /dev/null +++ b/crates/codegraph-extract/tests/dart_generic_calls.rs @@ -0,0 +1,45 @@ +//! A Dart generic call names what it calls without its type arguments +//! (upstream #2375): `ref.read(p)` calls `ref.read`, the name the +//! method is declared under. tree-sitter-dart parses the callee as an +//! `instantiation_expression` around that name; the type arguments stay +//! `references` of the caller. + +use codegraph_core::types::{EdgeKind, Language}; +use codegraph_extract::extract_source; + +fn refs(source: &str, kind: EdgeKind) -> Vec { + let result = extract_source("lib/run.dart", source, Some(Language::Dart)); + let mut names: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == kind) + .map(|r| r.reference_name.clone()) + .collect(); + names.sort(); + names +} + +#[test] +fn a_generic_call_names_its_callee_without_type_arguments() { + let source = "void run(Ref ref, BuildContext context) {\n ref.read(repoProvider).load();\n BlocProvider.of(context).increment();\n final p = Provider((ref) => 0);\n identity(3);\n fetch>();\n ref.watch>(p);\n}\n"; + assert_eq!( + refs(source, EdgeKind::Calls), + [ + "BlocProvider.of", + "Provider", + "fetch", + "identity", + "increment", + "load", + "ref.read", + "ref.watch", + ] + ); + let references = refs(source, EdgeKind::References); + for type_argument in ["Counter", "Item", "List", "Map", "Repo"] { + assert!( + references.iter().any(|name| name == type_argument), + "{type_argument} in {references:?}" + ); + } +} diff --git a/crates/codegraph-extract/tests/dart_member_docs.rs b/crates/codegraph-extract/tests/dart_member_docs.rs new file mode 100644 index 00000000..3fa92cad --- /dev/null +++ b/crates/codegraph-extract/tests/dart_member_docs.rs @@ -0,0 +1,279 @@ +//! A Dart member keeps its dartdoc and annotations (upstream #2382 and +//! #2387). tree-sitter-dart wraps every class, mixin and extension member in a +//! `class_member`, a member with no body also in the `declaration` it opens, +//! and an `external` function in an `external_function_declaration`. The +//! member's annotations sit inside that wrapper or open the member's own +//! node, so the dartdoc written above them is the outermost wrapper's previous +//! sibling. A comment between the annotations and the member joins it. +//! +//! The sources are upstream's own test fixtures. Their `const` constructors +//! (`Counter.zero`) are not nodes until W4-DART-03 (#2380), so they are not +//! asserted here. + +use codegraph_core::types::{EdgeKind, ExtractionResult, Language}; +use codegraph_extract::extract_source; + +/// `__tests__/dart-bodiless-member-docs.test.ts` (upstream `1b752de2`). +const BODILESS: &str = r#"abstract class Repository { + /// Loads the user with [id]. + Future load(String id); + + @visibleForTesting + void reset(); + + /// Calls into the platform. + external int platformVersion(); +} + +class User { + /// Creates a user from its parts. + User.parts(this.name) : id = 0; + + @visibleForTesting + User.test(this.name) : id = 1; + + @Deprecated('use parts') + @internal + User.old(this.name) : id = 2; + + /// The user's name. + @deprecated + final String name; + User.afterField() : name = '', id = 3; + + final int id; +} +"#; + +/// `__tests__/dart-annotated-member-docs.test.ts` (upstream `99246848`). +const ANNOTATED: &str = r#"// Copyright 2026 the authors. +import 'package:meta/meta.dart'; + +@visibleForTesting +void afterImport() {} + +/// Runs at startup. +@pragma('vm:entry-point') +void main() {} + +/// Calls into the platform. +@pragma('vm:external-name', 'version') +external int platformVersion(); + +/// Watches the counter. +@riverpod +@Deprecated('Use the generated provider') +int counter(Ref ref) => 0; + +/// The answer. +final answer = 42; + +@visibleForTesting +void afterVariable() {} + +abstract class Repository { + /// Loads the user with [id]. + @protected + Future load(String id); +} + +/// A counter. +@immutable +class Counter { + /// Starts at zero. + @literal + const Counter.zero() : value = 0; + + /// Reads a counter from JSON. + @visibleForTesting + factory Counter.fromJson(Map json) => const Counter.zero(); + + /// Builds the widget. + @override + Widget build(BuildContext context) => const Text('0'); + + /// Releases resources. + /// + /// Call it once. + @protected + @mustCallSuper + void dispose() {} + + /// The current count. + @override + int get count => value; + + /// Use [build] instead. + @Deprecated( + 'Use build. ' + 'Removed in 2.0.', + ) + + Widget render() => const Text(''); + + /// Joined with the comment below the annotation. + @protected + // ignore: must_call_super + void joined() {} + + /// The cached value. + @JsonKey(name: 'value') + final int value; + + @override + void afterField() {} + + void before() {} + @override + void afterBody() {} +} + +/// A mixin. +@internal +mixin Logging on Counter {} + +/// An extension. +@internal +extension CounterX on Counter {} + +/// An enum. +@JsonEnum() +enum Mode { light, dark } + +/// A typedef. +@internal +typedef Callback = void Function(); +@visibleForTesting +void afterTypedef() {} +"#; + +fn extract(path: &str, source: &str, crlf: bool) -> ExtractionResult { + let source = if crlf { + source.replace('\n', "\r\n") + } else { + source.to_string() + }; + extract_source(path, &source, Some(Language::Dart)) +} + +fn doc(result: &ExtractionResult, qualified_name: &str) -> Option { + result + .nodes + .iter() + .find(|node| node.qualified_name == qualified_name) + .unwrap_or_else(|| panic!("no node {qualified_name}")) + .docstring + .clone() +} + +fn decorators(result: &ExtractionResult, qualified_name: &str) -> Vec { + let id = &result + .nodes + .iter() + .find(|node| node.qualified_name == qualified_name) + .unwrap_or_else(|| panic!("no node {qualified_name}")) + .id; + let mut names: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == EdgeKind::Decorates && &r.from_node_id == id) + .map(|r| r.reference_name.clone()) + .collect(); + names.sort(); + names +} + +/// #2382: a member with no body, an abstract or `external` method or a +/// constructor with an initializer list, takes its dartdoc and annotations +/// from before its `declaration`. A field keeps its own: they do not move on +/// to the constructor after it. +#[test] +fn a_member_with_no_body_keeps_its_dartdoc_and_annotations() { + for crlf in [false, true] { + let result = extract("lib/user.dart", BODILESS, crlf); + let doc = |name| doc(&result, name); + let decorators = |name| decorators(&result, name); + assert_eq!( + doc("Repository::load").as_deref(), + Some("Loads the user with [id].") + ); + assert_eq!( + doc("Repository::platformVersion").as_deref(), + Some("Calls into the platform.") + ); + assert_eq!( + doc("User::parts").as_deref(), + Some("Creates a user from its parts.") + ); + assert_eq!(decorators("Repository::reset"), ["visibleForTesting"]); + assert_eq!(decorators("User::test"), ["visibleForTesting"]); + assert_eq!(decorators("User::old"), ["Deprecated", "internal"]); + assert_eq!(doc("User::afterField"), None); + assert!(decorators("User::afterField").is_empty()); + } +} + +/// #2387: a member's dartdoc is the one written above its annotations, +/// stacked and multi-line ones included, and a comment between the +/// annotations and the member joins it. Whatever else comes first (an +/// import, a top-level variable, a field, the previous member's body, a +/// typedef) still ends the run. Class-like declarations kept theirs already, +/// and every annotation is still recorded on its member. +#[test] +fn a_member_keeps_the_dartdoc_above_its_annotations() { + for crlf in [false, true] { + let result = extract("lib/counter.dart", ANNOTATED, crlf); + let doc = |name| doc(&result, name); + let decorators = |name| decorators(&result, name); + assert_eq!(doc("main").as_deref(), Some("Runs at startup.")); + assert_eq!( + doc("platformVersion").as_deref(), + Some("Calls into the platform.") + ); + assert_eq!(doc("counter").as_deref(), Some("Watches the counter.")); + + assert_eq!(doc("Counter::build").as_deref(), Some("Builds the widget.")); + assert_eq!(doc("Counter::count").as_deref(), Some("The current count.")); + assert_eq!( + doc("Counter::fromJson").as_deref(), + Some("Reads a counter from JSON.") + ); + assert_eq!( + doc("Repository::load").as_deref(), + Some("Loads the user with [id].") + ); + assert_eq!( + doc("Counter::dispose").as_deref(), + Some("Releases resources.\n\nCall it once.") + ); + assert_eq!( + doc("Counter::render").as_deref(), + Some("Use [build] instead.") + ); + assert_eq!( + doc("Counter::joined").as_deref(), + Some("Joined with the comment below the annotation.\nignore: must_call_super") + ); + + assert_eq!(doc("afterImport"), None); + assert_eq!(doc("afterVariable"), None); + assert_eq!(doc("Counter::afterField"), None); + assert_eq!(doc("Counter::afterBody"), None); + assert_eq!(doc("afterTypedef"), None); + + assert_eq!(doc("Counter").as_deref(), Some("A counter.")); + assert_eq!(doc("Logging").as_deref(), Some("A mixin.")); + assert_eq!(doc("CounterX").as_deref(), Some("An extension.")); + assert_eq!(doc("Mode").as_deref(), Some("An enum.")); + assert_eq!(doc("Callback").as_deref(), Some("A typedef.")); + + assert_eq!(decorators("counter"), ["Deprecated", "riverpod"]); + assert_eq!(decorators("platformVersion"), ["pragma"]); + assert_eq!(decorators("Counter::build"), ["override"]); + assert_eq!( + decorators("Counter::dispose"), + ["mustCallSuper", "protected"] + ); + assert_eq!(decorators("Counter::afterField"), ["override"]); + } +} diff --git a/crates/codegraph-extract/tests/deep_nesting.rs b/crates/codegraph-extract/tests/deep_nesting.rs index 7aaad04b..ec388f63 100644 --- a/crates/codegraph-extract/tests/deep_nesting.rs +++ b/crates/codegraph-extract/tests/deep_nesting.rs @@ -146,3 +146,60 @@ fn depth_abort_is_scoped_to_one_extraction() { assert!(normal.errors.is_empty(), "{:?}", normal.errors); assert!(normal.nodes.iter().any(|node| node.name == "add")); } + +/// A CFML component whose tag tree is shallow but whose `` body or +/// `` `#…#` expression nests `depth` parentheses: the re-parsed +/// snippet is what runs deep. +fn cfml_snippet_source(shape: &str, depth: usize) -> String { + let nested = format!("{}1{}", "(".repeat(depth), ")".repeat(depth)); + match shape { + "script" => format!( + "\n\n\nx = {nested};\n\n\n" + ), + "query" => format!( + "\n\nSELECT * FROM t WHERE id = #{nested}#\n\n\n" + ), + _ => unreachable!("cfml snippet shapes are script and query"), + } +} + +fn extract_cfml_on_small_stack( + shape: &'static str, + depth: usize, +) -> codegraph_core::types::ExtractionResult { + let source = cfml_snippet_source(shape, depth); + std::thread::Builder::new() + .name(format!("deep-nesting-cfml-{shape}")) + .stack_size(1024 * 1024) + .spawn(move || extract_source("Deep.cfc", &source, Some(Language::Cfml))) + .expect("spawn 1 MiB extraction thread") + .join() + .expect("deep extraction must return instead of overflowing") +} + +#[test] +fn a_deep_cfml_snippet_fails_its_file_without_partial_graph() { + for shape in ["script", "query"] { + let result = extract_cfml_on_small_stack(shape, DEEP); + assert!( + result + .errors + .iter() + .any(|error| error.contains(EXPECTED_ERROR)), + "{shape}: errors={:?}", + result.errors + ); + assert!(result.nodes.is_empty(), "{shape}: partial nodes survived"); + assert!(result.edges.is_empty(), "{shape}: partial edges survived"); + assert!( + result.unresolved_references.is_empty(), + "{shape}: partial references survived" + ); + let shallow = extract_cfml_on_small_stack(shape, 20); + assert!(shallow.errors.is_empty(), "{shape}: {:?}", shallow.errors); + assert!( + shallow.nodes.iter().any(|node| node.name == "before"), + "{shape}: the component's function disappeared" + ); + } +} diff --git a/crates/codegraph-extract/tests/embedded_languages.rs b/crates/codegraph-extract/tests/embedded_languages.rs index df5d3362..40f0e6dd 100644 --- a/crates/codegraph-extract/tests/embedded_languages.rs +++ b/crates/codegraph-extract/tests/embedded_languages.rs @@ -72,7 +72,9 @@ fn liquid_extracts_template_nodes_and_shopify_json_section_refs() { 4, ); - let json = extract_fixture("templates/product.json", None); + // A JSON template is Liquid inside a Shopify theme, which takes the + // project root to find (upstream #2393); here it is extracted as one. + let json = extract_fixture("templates/product.json", Some(Language::Liquid)); assert_no_errors(&json); assert_ref( &json, @@ -86,7 +88,7 @@ fn liquid_extracts_template_nodes_and_shopify_json_section_refs() { "sections/related-products.liquid", 1, ); - assert_eq!(detect_language("templates/product.json"), Language::Liquid); + assert_eq!(detect_language("templates/product.json"), Language::Unknown); println!("liquid original-line assertions passed"); } @@ -223,9 +225,15 @@ fn astro_extracts_frontmatter_scripts_and_template_refs_on_original_lines() { let result = extract_fixture("sample.astro", Some(Language::Astro)); assert_no_errors(&result); - // Component node + delegated TS frontmatter/script symbols on their - // .astro lines (the upstream keeps the delegated file node; its line depends on - // node-id dedup ordering, so it is not asserted here). + // One file node holding the component (upstream #2268), and the + // delegated TS frontmatter/script symbols on their .astro lines. + let file_nodes: Vec<_> = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::File) + .map(|node| node.start_line) + .collect(); + assert_eq!(file_nodes, [1], "one file node: {file_nodes:?}"); assert_node(&result, NodeKind::Component, "sample", 1); assert_node(&result, NodeKind::Import, "./Layout.astro", 2); assert_node(&result, NodeKind::Import, "../utils/date", 3); diff --git a/crates/codegraph-extract/tests/inheritance_clauses.rs b/crates/codegraph-extract/tests/inheritance_clauses.rs new file mode 100644 index 00000000..3caf0d36 --- /dev/null +++ b/crates/codegraph-extract/tests/inheritance_clauses.rs @@ -0,0 +1,202 @@ +//! Supertype clauses each language writes: the `extends`/`implements` +//! references a declaration's heritage clause yields, by the name the +//! resolver binds. + +use codegraph_core::types::{EdgeKind, Language}; +use codegraph_extract::extract_source; + +/// `(declaration, kind, supertype)` for every supertype reference in +/// `source`, sorted. +fn supertypes(path: &str, source: &str, language: Language) -> Vec<(String, EdgeKind, String)> { + let result = extract_source(path, source, Some(language)); + let name_of = |id: &str| { + result + .nodes + .iter() + .find(|node| node.id == id) + .map_or_else(|| id.to_string(), |node| node.name.clone()) + }; + let mut refs: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| matches!(r.reference_kind, EdgeKind::Extends | EdgeKind::Implements)) + .map(|r| { + ( + name_of(&r.from_node_id), + r.reference_kind, + r.reference_name.clone(), + ) + }) + .collect(); + refs.sort_by(|a, b| (&a.0, &a.2).cmp(&(&b.0, &b.2))); + refs +} + +fn extends(declaration: &str, supertype: &str) -> (String, EdgeKind, String) { + ( + declaration.to_string(), + EdgeKind::Extends, + supertype.to_string(), + ) +} + +/// G1 (upstream `b712e4de`): a C# `base_list` names the base class and the +/// interfaces in one list. Each entry is an `extends` reference; the resolver +/// turns the one that binds an interface into `implements`. Type arguments +/// and a `global::` alias are dropped, a qualified name is kept, a primary +/// constructor's arguments are not part of the name, and an enum's underlying +/// type is no supertype. +#[test] +fn csharp_base_lists_name_every_supertype() { + let source = r#"namespace App +{ + public class Store : BaseStore, IStore { } + public class Repo : Base, IRepo where T : IEntity { } + public struct Point : IComparable, System.IEquatable { } + public interface IStore : IDisposable, global::App.IReadable { } + public record Person(string Name) : Entity(Name), IHasName; + public enum Flags : byte { None } + public class Plain { } +} +"#; + assert_eq!( + supertypes("src/Store.cs", source, Language::CSharp), + vec![ + extends("IStore", "App.IReadable"), + extends("IStore", "IDisposable"), + extends("Person", "Entity"), + extends("Person", "IHasName"), + extends("Point", "IComparable"), + extends("Point", "System.IEquatable"), + extends("Repo", "Base"), + extends("Repo", "IRepo"), + extends("Store", "BaseStore"), + extends("Store", "IStore"), + ] + ); +} + +/// G5 (upstream `61153f96`): `@interface Sub : Base ` extends +/// its superclass and implements each protocol it adopts. +#[test] +fn objc_interfaces_name_their_superclass_and_protocols() { + let source = + "@interface Sub : Base \n- (void)work;\n@end\n\n@interface Root\n@end\n"; + assert_eq!( + supertypes("Sources/Sub.h", source, Language::ObjC), + vec![ + extends("Sub", "Base"), + ("Sub".to_string(), EdgeKind::Implements, "Doer".to_string()), + ("Sub".to_string(), EdgeKind::Implements, "Saver".to_string()), + ] + ); +} + +/// G6 (upstream v1.0.1): a Python class's bases are `extends` refs, a dotted +/// base by its full path; a `metaclass=` keyword or a subscripted generic +/// (`Generic[T]`) is no base the resolver can bind. +#[test] +fn python_class_bases_are_supertypes() { + let source = "class Flask(Scaffold, mixins.Mixin, metaclass=Meta):\n pass\n\n\nclass Box(Generic[T], Base):\n pass\n\n\nclass Plain:\n pass\n"; + assert_eq!( + supertypes("app.py", source, Language::Python), + vec![ + extends("Box", "Base"), + extends("Flask", "Scaffold"), + extends("Flask", "mixins.Mixin"), + ] + ); +} + +/// G7: a PHP class extends its `base_clause` parent and implements each +/// interface of its `class_interface_clause`; an interface extends every +/// parent it lists. A qualified name binds by its last segment, the name a +/// class is stored under and a `use` import brings into scope. +#[test] +fn php_extends_and_implements_are_supertypes() { + let source = " = Shell with Wm;\nclass Generic extends Box> with Mx, My implements Ix, Iy {}\nclass Prefixed extends p.Root with p.Pm implements p.Pi {}\nenum Mode with Em implements Ei { a }\nmixin Logging on Base implements Li {}\n"; + let implements = |declaration: &str, supertype: &str| { + ( + declaration.to_string(), + EdgeKind::Implements, + supertype.to_string(), + ) + }; + assert_eq!( + supertypes("lib/child.dart", source, Language::Dart), + vec![ + extends("Applied", "Base"), + implements("Applied", "Face"), + implements("Applied", "Mixer"), + extends("Child", "Base"), + implements("Child", "I1"), + implements("Child", "I2"), + implements("Child", "M1"), + implements("Child", "M2"), + extends("Generic", "Box"), + implements("Generic", "Ix"), + implements("Generic", "Iy"), + implements("Generic", "Mx"), + implements("Generic", "My"), + implements("Logging", "Li"), + implements("Mode", "Ei"), + implements("Mode", "Em"), + implements("OnlyMixins", "M3"), + implements("Prefixed", "Pi"), + implements("Prefixed", "Pm"), + extends("Prefixed", "Root"), + extends("Wrapped", "Shell"), + implements("Wrapped", "Wm"), + ] + ); +} diff --git a/crates/codegraph-extract/tests/instantiations.rs b/crates/codegraph-extract/tests/instantiations.rs new file mode 100644 index 00000000..b71dc00f --- /dev/null +++ b/crates/codegraph-extract/tests/instantiations.rs @@ -0,0 +1,140 @@ +//! Object construction in each language's own syntax yields an `instantiates` +//! reference named by the constructed type (G2, upstream INSTANTIATION_KINDS +//! since `8eed2432`), and a Java anonymous class body becomes a class of its +//! own (upstream `34240eb2`). + +use codegraph_core::types::{EdgeKind, Language, NodeKind}; +use codegraph_extract::extract_source; + +/// `(from, reference)` for every `kind` reference in `source`, sorted. +fn refs_of(path: &str, source: &str, language: Language, kind: EdgeKind) -> Vec<(String, String)> { + let result = extract_source(path, source, Some(language)); + let name_of = |id: &str| { + result + .nodes + .iter() + .find(|node| node.id == id) + .map_or_else(|| id.to_string(), |node| node.name.clone()) + }; + let mut refs: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == kind) + .map(|r| (name_of(&r.from_node_id), r.reference_name.clone())) + .collect(); + refs.sort(); + refs +} + +fn instantiations(path: &str, source: &str, language: Language) -> Vec<(String, String)> { + refs_of(path, source, language, EdgeKind::Instantiates) +} + +fn pairs(rows: &[(&str, &str)]) -> Vec<(String, String)> { + rows.iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect() +} + +#[test] +fn java_object_creation_names_the_constructed_class() { + let source = "package app;\n\nclass Shop {\n void open() {\n Widget w = new Widget();\n java.util.List names = new java.util.ArrayList();\n Outer.Inner inner = new Outer.Inner(1);\n }\n}\n"; + assert_eq!( + instantiations("src/app/Shop.java", source, Language::Java), + pairs(&[("open", "ArrayList"), ("open", "Inner"), ("open", "Widget")]) + ); +} + +/// A Java anonymous class body is a class named for its type and line, which +/// extends that type and owns the methods written in it; calls in the +/// constructor arguments still belong to the enclosing method. +#[test] +fn java_anonymous_class_bodies_become_classes() { + let source = "package app;\n\nclass Shop {\n Runnable task() {\n return new Runnable(label()) {\n public void run() {\n helper();\n }\n };\n }\n}\n"; + let path = "src/app/Shop.java"; + let result = extract_source(path, source, Some(Language::Java)); + let anon = result + .nodes + .iter() + .find(|node| node.kind == NodeKind::Class && node.name == "") + .expect("the anonymous class is a class node"); + let run = result + .nodes + .iter() + .find(|node| node.kind == NodeKind::Method && node.name == "run") + .expect("its method is a method node"); + assert!( + result + .edges + .iter() + .any(|edge| edge.kind == EdgeKind::Contains + && edge.source == anon.id + && edge.target == run.id), + "the anonymous class contains `run`" + ); + assert_eq!( + instantiations(path, source, Language::Java), + pairs(&[("task", "Runnable")]) + ); + assert_eq!( + refs_of(path, source, Language::Java, EdgeKind::Extends), + pairs(&[("", "Runnable")]) + ); + assert_eq!( + refs_of(path, source, Language::Java, EdgeKind::Calls), + pairs(&[("run", "helper"), ("task", "label")]) + ); +} + +#[test] +fn csharp_object_creation_names_the_constructed_class() { + let source = "namespace App\n{\n class Shop\n {\n void Open()\n {\n var w = new Widget(1);\n var list = new List();\n var nested = new Outer.Inner();\n Widget typed = new();\n }\n }\n}\n"; + assert_eq!( + instantiations("src/Shop.cs", source, Language::CSharp), + pairs(&[("Open", "Inner"), ("Open", "List"), ("Open", "Widget")]) + ); +} + +#[test] +fn php_object_creation_names_the_constructed_class() { + let source = " inArrow())", + "export function probe() { const { j } = inner(); return j }", + "", + ] + .join("\n"); + for (file, language) in [ + ("app.ts", Language::TypeScript), + ("app.tsx", Language::Tsx), + ("app.js", Language::JavaScript), + ("app.jsx", Language::Jsx), + ] { + let result = extract(file, &code, language); + let kind_of = |id: &str| { + result + .nodes + .iter() + .find(|node| node.id == id) + .map_or("?", |node| node.kind.as_str()) + }; + let mut calls: Vec = result + .unresolved_references + .iter() + .filter(|reference| reference.reference_kind == EdgeKind::Calls) + .map(|reference| { + format!( + "{}:{}@{}", + kind_of(&reference.from_node_id), + reference.reference_name, + reference.line + ) + }) + .collect(); + calls.sort(); + assert_eq!( + calls, + [ + "file:exported@6", + "file:fallback@4", + "file:inArrow@7", + "file:list@5", + "file:load@7", + "file:nested@3", + "file:other@5", + "file:pair@2", + "file:useFoo@1", + "file:withDefault@4", + "function:inner@8", + ], + "{file}" + ); + // Still no symbol for a destructured binding. + assert!( + !result + .nodes + .iter() + .any(|node| matches!(node.kind, NodeKind::Constant | NodeKind::Variable)), + "{file}: {:?}", + result.nodes + ); + } +} + +/// Upstream #2263: a local `require('…')` specifier is an `imports` ref from +/// the file, once per specifier; a package, `require.resolve`, a member's +/// `require`, a template with a hole, comments, strings and TypeScript's +/// `import x = require(…)` are not. +#[test] +fn commonjs_requires_import_the_local_files_they_name() { + let code = [ + "var proto = require('./application');", + "var again = require('./application');", + "var pkg = require('express');", + "var root = require('..');", + "var alias = require(\"@/lib/x\");", + "var raw = require(`./tpl`);", + "var hole = require(`./${name}`);", + "var path = require.resolve('./resolved');", + "var other = loader.require('./member');", + "// require('./commented')", + "var text = \"require('./quoted')\";", + "", + ] + .join("\n"); + let result = extract("lib/express.js", &code, Language::JavaScript); + let imports: Vec<(&str, i64, &str)> = result + .unresolved_references + .iter() + .filter(|reference| reference.reference_kind == EdgeKind::Imports) + .map(|reference| { + ( + reference.reference_name.as_str(), + reference.line, + reference.from_node_id.as_str(), + ) + }) + .collect(); + assert_eq!( + imports, + [ + ("./application", 1, "file:lib/express.js"), + ("..", 4, "file:lib/express.js"), + ("@/lib/x", 5, "file:lib/express.js"), + ("./tpl", 6, "file:lib/express.js"), + ] + ); + // TypeScript's `import x = require(…)` is an import statement already: + // the require reader adds no second import of it. + let ts = extract( + "src/main.ts", + "import helper = require('./helper');\nexport const value = helper.run();\n", + Language::TypeScript, + ); + let helper_imports = ts + .unresolved_references + .iter() + .filter(|reference| { + reference.reference_kind == EdgeKind::Imports && reference.reference_name == "./helper" + }) + .count(); + assert!(helper_imports <= 1, "{:?}", ts.unresolved_references); +} diff --git a/crates/codegraph-extract/tests/markup_risk_languages.rs b/crates/codegraph-extract/tests/markup_risk_languages.rs index da18ccbe..df68ca5d 100644 --- a/crates/codegraph-extract/tests/markup_risk_languages.rs +++ b/crates/codegraph-extract/tests/markup_risk_languages.rs @@ -4,6 +4,23 @@ use std::fs; const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/markup_risk"); +/// `(kind, qualified name)` of every field, property, variable and constant. +fn value_nodes(result: &ExtractionResult) -> Vec<(NodeKind, String)> { + let mut nodes: Vec<_> = result + .nodes + .iter() + .filter(|node| { + matches!( + node.kind, + NodeKind::Field | NodeKind::Property | NodeKind::Variable | NodeKind::Constant + ) + }) + .map(|node| (node.kind, node.qualified_name.clone())) + .collect(); + nodes.sort_by(|a, b| (format!("{:?}", a.0), &a.1).cmp(&(format!("{:?}", b.0), &b.1))); + nodes +} + #[test] fn lang_markup_risk_file_level_only_languages_are_empty() { // Upstream grammars.ts:332-334 and tree-sitter.ts:4382-4387 return no extractor nodes. @@ -209,17 +226,15 @@ fn lang_markup_risk_kotlin_extracts_upstream_symbol_set() { let repo = assert_node(&result, NodeKind::Class, "Repo"); assert_eq!(repo.visibility.as_deref(), Some("public")); - // The upstream emits NO field/property/variable nodes for kotlin - // property_declaration: extractField (tree-sitter.ts:1180-1278) and - // extractVariable (tree-sitter.ts:1382-1463) find no declarators in the - // kotlin grammar shape. `val name` / `val topLevel` must NOT be nodes. - assert!( - !result.nodes.iter().any(|node| matches!( - node.kind, - NodeKind::Field | NodeKind::Property | NodeKind::Variable | NodeKind::Constant - )), - "kotlin must not emit field/property/variable nodes: {:#?}", - result.nodes + // Since upstream #897 a Kotlin property is a node by where it is declared: + // a class's `val name` a field, the top-level `val topLevel` a constant. A + // constructor parameter (`private val db`) is none. + assert_eq!( + value_nodes(&result), + [ + (NodeKind::Constant, "com.example.demo::topLevel".to_string()), + (NodeKind::Field, "com.example.demo::Repo::name".to_string()), + ] ); let fetch = assert_node(&result, NodeKind::Method, "fetch"); @@ -332,15 +347,16 @@ fn lang_markup_risk_swift_extracts_upstream_symbol_set() { let top_level = assert_node(&result, NodeKind::Function, "topLevel"); assert!(!top_level.is_async); - // Stored properties are NOT nodes (tree-sitter.ts:453-487); the wrapper - // attribute decorates the enclosing type instead. - assert!( - !result.nodes.iter().any(|node| matches!( - node.kind, - NodeKind::Field | NodeKind::Property | NodeKind::Variable | NodeKind::Constant - )), - "swift must not emit field/property/variable nodes: {:#?}", - result.nodes + // Since upstream #897 a stored property is a node (an instance one a + // field, the top-level `let answer` a constant); the wrapper attribute + // still decorates the enclosing type (tree-sitter.ts:453-487). + assert_eq!( + value_nodes(&result), + [ + (NodeKind::Constant, "answer".to_string()), + (NodeKind::Field, "Greeter::name".to_string()), + (NodeKind::Field, "Point::x".to_string()), + ] ); // Golden unresolved references from the upstream branches: diff --git a/crates/codegraph-extract/tests/objc_messages.rs b/crates/codegraph-extract/tests/objc_messages.rs new file mode 100644 index 00000000..88025523 --- /dev/null +++ b/crates/codegraph-extract/tests/objc_messages.rs @@ -0,0 +1,40 @@ +//! Objective-C message sends are calls named by their full selector (G5, +//! upstream since `61153f96`): `[obj a:1 b:2]` calls `obj.a:b:`; a `self` or +//! `super` receiver leaves the bare selector, which resolves in the +//! enclosing class; a class receiver is also a reference to that class. + +use codegraph_core::types::{EdgeKind, Language}; +use codegraph_extract::extract_source; + +fn refs_of(source: &str, kind: EdgeKind) -> Vec { + let result = extract_source("Sources/Sub.m", source, Some(Language::ObjC)); + let mut refs: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == kind) + .map(|r| r.reference_name.clone()) + .collect(); + refs.sort(); + refs +} + +#[test] +fn message_sends_are_named_by_receiver_and_selector() { + let source = "@implementation Sub\n- (void)work {\n [self ping];\n [super ping];\n [c reset];\n [c storeImage:k];\n [cache storeImage:img forKey:key];\n [Base new];\n [[Factory create] doIt];\n [[obj foo] bar];\n}\n@end\n"; + assert_eq!( + refs_of(source, EdgeKind::Calls), + [ + "Base.new", + "Factory.create", + "Factory.create().doIt", + "bar", + "c.reset", + "c.storeImage:", + "cache.storeImage:forKey:", + "obj.foo", + "ping", + "ping", + ] + ); + assert_eq!(refs_of(source, EdgeKind::References), ["Base", "Factory"]); +} diff --git a/crates/codegraph-extract/tests/ruby_receivers.rs b/crates/codegraph-extract/tests/ruby_receivers.rs new file mode 100644 index 00000000..659cec8a --- /dev/null +++ b/crates/codegraph-extract/tests/ruby_receivers.rs @@ -0,0 +1,38 @@ +//! A Ruby method call keeps its receiver (G11, upstream #2147's extraction +//! half): `message.upcase` is called as `message.upcase`, so resolution can +//! read what the receiver is; a `self`/`super` receiver leaves the bare name, +//! a constant receiver is also a reference to the constant, and `Foo.new` +//! stays an instantiation of `Foo`. + +use codegraph_core::types::{EdgeKind, Language}; +use codegraph_extract::extract_source; + +fn refs_of(source: &str, kind: EdgeKind) -> Vec { + let result = extract_source("app/service.rb", source, Some(Language::Ruby)); + let mut refs: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == kind) + .map(|r| r.reference_name.clone()) + .collect(); + refs.sort(); + refs +} + +#[test] +fn method_calls_keep_their_receiver() { + let source = "class Service\n def run(message)\n message.upcase\n @logger.log(message)\n Formatter.shout(message)\n self.helper\n super.helper\n Logger.new\n Admin::User.new\n plain(message)\n end\nend\n"; + assert_eq!( + refs_of(source, EdgeKind::Calls), + [ + "@logger.log", + "Formatter.shout", + "helper", + "helper", + "message.upcase", + "plain", + ] + ); + assert_eq!(refs_of(source, EdgeKind::References), ["Formatter"]); + assert_eq!(refs_of(source, EdgeKind::Instantiates), ["Logger", "User"]); +} diff --git a/crates/codegraph-extract/tests/scan_membership.rs b/crates/codegraph-extract/tests/scan_membership.rs index 270d97b8..93807ec3 100644 --- a/crates/codegraph-extract/tests/scan_membership.rs +++ b/crates/codegraph-extract/tests/scan_membership.rs @@ -165,3 +165,60 @@ fn directories_missing_paths_and_symlinks() { fs::remove_dir_all(&root).ok(); fs::remove_dir_all(&outside).ok(); } + +/// The repository's own `.git/info/exclude` prunes the scan like a root +/// `.gitignore` (upstream #1728). The root `.gitignore` is read after it, so its +/// `!` lines re-include what the exclude file ignored, as in git. +#[test] +fn the_repositorys_info_exclude_prunes_the_scan() { + let root = sandbox("info-exclude"); + fs::create_dir_all(root.join(".git/info")).unwrap(); + fs::write( + root.join(".git/info/exclude"), + "# local excludes\nscratch/\n*.local.ts\nkept/\n", + ) + .unwrap(); + fs::write(root.join(".gitignore"), "!kept/\n").unwrap(); + for file in [ + "src/app.ts", + "src/debug.local.ts", + "scratch/try.ts", + "kept/again.ts", + ] { + touch(&root, file); + } + + let options = ExtractOptions::default(); + let scanned = scan_project(&root, &options).expect("scan"); + assert_eq!(scanned, ["kept/again.ts", "src/app.ts"]); + for file in files_on_disk(&root) { + let membership = scan_membership(&root, &options, &file, false); + assert_eq!( + membership == Membership::Admitted, + scanned.contains(&file), + "{file}: membership {membership:?} agrees with the scan" + ); + } + fs::remove_dir_all(&root).ok(); +} + +/// Only the project's own repository counts: a `.git` file (a linked worktree +/// or submodule) points outside the project, and is never followed. +#[test] +fn a_git_file_pointing_elsewhere_contributes_no_excludes() { + let root = sandbox("info-exclude-gitfile"); + let elsewhere = sandbox("info-exclude-elsewhere"); + fs::create_dir_all(elsewhere.join("info")).unwrap(); + fs::write(elsewhere.join("info/exclude"), "src/\n").unwrap(); + fs::write( + root.join(".git"), + format!("gitdir: {}\n", elsewhere.display()), + ) + .unwrap(); + touch(&root, "src/app.ts"); + + let scanned = scan_project(&root, &ExtractOptions::default()).expect("scan"); + assert_eq!(scanned, ["src/app.ts"]); + fs::remove_dir_all(&root).ok(); + fs::remove_dir_all(&elsewhere).ok(); +} diff --git a/crates/codegraph-extract/tests/sfc_scripts.rs b/crates/codegraph-extract/tests/sfc_scripts.rs new file mode 100644 index 00000000..2222fdfb --- /dev/null +++ b/crates/codegraph-extract/tests/sfc_scripts.rs @@ -0,0 +1,169 @@ +//! A single-file component's `\n"; + +#[test] +fn a_vue_script_is_extracted_by_the_typescript_extractor() { + let result = extract_source("src/App.vue", APP_VUE, None); + let go = result + .nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.name == "go") + .expect("the script's function is a node"); + assert_eq!((go.start_line, go.end_line), (10, 13), "at its file lines"); + assert_eq!(go.language, Language::Vue); + assert_eq!(calls_from(&result, "go"), ["helper", "run"]); + assert!( + result.nodes.iter().any(|n| n.name == "msg"), + "a top-level constant is a node" + ); + let imports: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == EdgeKind::Imports) + .map(|r| (r.reference_name.as_str(), r.line)) + .collect(); + assert!(imports.contains(&("./util", 6)), "{imports:?}"); +} + +fn node_id(result: &ExtractionResult, kind: NodeKind, name: &str) -> String { + result + .nodes + .iter() + .find(|n| n.kind == kind && n.name == name) + .unwrap_or_else(|| panic!("no {kind:?} {name}")) + .id + .clone() +} + +fn refs_from(result: &ExtractionResult, from: &str, kind: EdgeKind) -> Vec { + let mut names: Vec = result + .unresolved_references + .iter() + .filter(|r| r.from_node_id == from && r.reference_kind == kind) + .map(|r| r.reference_name.clone()) + .collect(); + names.sort(); + names +} + +fn parents_of(result: &ExtractionResult, id: &str) -> Vec { + result + .edges + .iter() + .filter(|e| e.kind == EdgeKind::Contains && e.target == id) + .map(|e| e.source.clone()) + .collect() +} + +/// G9 / upstream #2268: the SFC has one file node, holding the component; +/// the component holds what the script holds at its top level, and a +/// `\n\n\n\n

      hi

      \n"; + let result = extract_source("src/Widget.svelte", source, None); + let file = node_id(&result, NodeKind::File, "Widget.svelte"); + let component = node_id(&result, NodeKind::Component, "Widget"); + assert_eq!(refs_from(&result, &component, EdgeKind::Calls), ["doSetup"]); + assert_eq!(refs_from(&result, &file, EdgeKind::Calls), ["prepare"]); + assert_eq!( + refs_from(&result, &file, EdgeKind::Imports), + ["./setup", "doSetup"] + ); +} + +/// Upstream #2268 applies to Astro too: one file node holds the component, +/// the component holds what the frontmatter holds at its top level, and the +/// frontmatter and a client `\n"; + let result = extract_source("src/pages/index.astro", source, None); + let files: Vec<_> = result + .nodes + .iter() + .filter(|n| n.kind == NodeKind::File) + .map(|n| (n.id.as_str(), n.start_line)) + .collect(); + assert_eq!(files, [("file:src/pages/index.astro", 1)], "one file node"); + let file = node_id(&result, NodeKind::File, "index.astro"); + let component = node_id(&result, NodeKind::Component, "index"); + assert_eq!(parents_of(&result, &component), std::slice::from_ref(&file)); + let title = node_id(&result, NodeKind::Function, "title"); + assert_eq!( + parents_of(&result, &title), + std::slice::from_ref(&component), + "one parent each" + ); + assert_eq!( + refs_from(&result, &component, EdgeKind::Calls), + ["getPosts", "title", "track"] + ); + assert_eq!(refs_from(&result, &title, EdgeKind::Calls), ["format"]); + let imports = refs_from(&result, &file, EdgeKind::Imports); + for module in ["../lib/posts", "./Layout.astro"] { + assert!(imports.iter().any(|name| name == module), "{imports:?}"); + } + for reference in &result.unresolved_references { + assert!( + result.nodes.iter().any(|n| n.id == reference.from_node_id), + "{reference:?} comes from a node of the file" + ); + } +} diff --git a/crates/codegraph-extract/tests/static_member_reads.rs b/crates/codegraph-extract/tests/static_member_reads.rs new file mode 100644 index 00000000..0b3aa78d --- /dev/null +++ b/crates/codegraph-extract/tests/static_member_reads.rs @@ -0,0 +1,160 @@ +//! A type read only through a static member or an enum value is a dependency +//! of the code that reads it (G4, upstream `STATIC_MEMBER_LANGS`): `Enum.value`, +//! `Type.CONST`, `Foo::BAR` give a `references` ref named by the receiver, at +//! the receiver. The receiver must be a single capitalized name; a lowercase +//! receiver (`this.helper`, `obj.field`), a longer path's head, and an access +//! that is the callee of a call (`Type.method()`, already linked) give none. + +use codegraph_core::types::{EdgeKind, Language}; +use codegraph_extract::extract_source; + +/// `(reader, receiver)` for every `references` ref in `source`, sorted. +fn member_reads(path: &str, source: &str, language: Language) -> Vec<(String, String)> { + let result = extract_source(path, source, Some(language)); + let name_of = |id: &str| { + result + .nodes + .iter() + .find(|node| node.id == id) + .map_or_else(|| id.to_string(), |node| node.name.clone()) + }; + let mut refs: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == EdgeKind::References) + .map(|r| (name_of(&r.from_node_id), r.reference_name.clone())) + .collect(); + refs.sort(); + refs +} + +fn pairs(rows: &[(&str, &str)]) -> Vec<(String, String)> { + rows.iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect() +} + +/// Upstream's Java seed: `JsonScope.EMPTY_DOCUMENT`. A field read whose +/// result a call is made on (`Color.RED.ordinal()`) is that call's receiver, +/// which upstream counts as the callee and skips. +#[test] +fn java_static_field_reads_reference_their_type() { + let source = "class Reader {\n private int helper;\n int peek() {\n return JsonScope.EMPTY_DOCUMENT + Limits.MAX;\n }\n int noop() {\n return this.helper + Color.RED.ordinal() + Util.twice(1);\n }\n}\n"; + assert_eq!( + member_reads("src/Reader.java", source, Language::Java), + pairs(&[("peek", "JsonScope"), ("peek", "Limits")]) + ); +} + +#[test] +fn csharp_member_reads_reference_their_type() { + let source = "class Svc {\n int F() { return Color.Red + Limits.Max + this.count; }\n void G() { Console.WriteLine(1); }\n}\n"; + assert_eq!( + member_reads("src/Svc.cs", source, Language::CSharp), + pairs(&[("F", "Color"), ("F", "Limits")]) + ); +} + +#[test] +fn kotlin_member_reads_reference_their_type() { + let source = "class Device {\n fun sdk(): Int = Build.VERSION + Color.RED.ordinal\n fun make() = Factory.create()\n}\n"; + assert_eq!( + member_reads("src/Device.kt", source, Language::Kotlin), + pairs(&[("sdk", "Build"), ("sdk", "Color")]) + ); +} + +#[test] +fn swift_member_reads_reference_their_type() { + let source = "func level() -> Int {\n let a = Color.red.rawValue\n let b = Limits.max\n return a + b\n}\n\nfunc make() -> Int {\n return Factory.create()\n}\n"; + assert_eq!( + member_reads("Sources/Level.swift", source, Language::Swift), + pairs(&[("level", "Color"), ("level", "Limits")]) + ); +} + +#[test] +fn scala_member_reads_reference_their_type() { + let source = "object Levels {\n def level(): Int = Color.Red + Limits.Max\n def make(): Int = Factory.create(1)\n}\n"; + assert_eq!( + member_reads("src/Levels.scala", source, Language::Scala), + pairs(&[("level", "Color"), ("level", "Limits")]) + ); +} + +/// PHP: a class constant, `Foo::class` and a static property; `self::` / +/// `static::` name no type, and `Foo::make()` is a call. +#[test] +fn php_class_constant_and_static_property_reads_reference_their_class() { + let source = ".`, for the getter +/// it may run (upstream #2360); one through a longer path is not. +#[test] +fn dart_member_reads_reference_their_type() { + let source = "int level(context) {\n final p = Theme.of(context);\n final q = Provider.of(context);\n return Color.red.index + Limits.max + context.size;\n}\n"; + assert_eq!( + member_reads("lib/level.dart", source, Language::Dart), + pairs(&[ + ("level", "Color"), + ("level", "Color.red"), + ("level", "Limits"), + ("level", "Limits.max"), + ("level", "context.size"), + ]) + ); +} + +/// Rust: a variant path read, matched, passed as a value or patterned names +/// its receiver; `Self` is the impl's type. A call's callee (`Mode::C(1)`), a +/// struct literal, a `use` tree, a longer path's prefix and a lowercase +/// receiver or member give none. +#[test] +fn rust_variant_paths_reference_their_receiver() { + let source = "use crate::mode::{self, Mode};\n\npub fn code(flag: bool, xs: &[u8]) -> u8 {\n let m = if flag { mode::Mode::A } else { Mode::B };\n let _c = Mode::C(3);\n let _d = Mode::D { x: 2 };\n let _n = xs.iter().map(Mode::C).count();\n let _u = util::take(3);\n let _f = Limits::new;\n match m {\n Mode::A => 1,\n Mode::C(x) => x,\n Mode::D { x } => x,\n _ => 0,\n }\n}\n\nimpl Mode {\n pub fn flip(&self) -> u8 {\n match self {\n Self::A => 1,\n _ => 0,\n }\n }\n}\n"; + assert_eq!( + member_reads("src/variant_use.rs", source, Language::Rust), + pairs(&[ + ("code", "Mode"), + ("code", "Mode"), + ("code", "Mode"), + ("code", "Mode"), + ("code", "Mode"), + ("code", "Mode"), + ("flip", "Mode"), + ]) + ); +} + +/// C++: `Color::RED` and `Limits::kMax` reference the scope they read +/// through. tree-sitter-cpp writes every scope as a `namespace_identifier`, so +/// the scope is read only when it is capitalized; a lowercase namespace +/// (`std::`, `ns::`) and a global-scope read (`::g`) name no type, and a call +/// (`Factory::make()`) is the call's. +#[test] +fn cpp_scoped_reads_reference_their_scope() { + let source = "enum Color { RED };\nstruct Limits { static const int kMax = 3; };\nint g = 0;\nint f() {\n int a = Color::RED + Limits::kMax + ns::k + ::g;\n return a + Factory::make();\n}\n"; + assert_eq!( + member_reads("src/f.cpp", source, Language::Cpp), + pairs(&[("f", "Color"), ("f", "Limits")]) + ); +} + +/// A Swift property wrapper names a type by metatype in its arguments +/// (Fluent's `@Siblings(through: Pivot.self, …)`): the enclosing type +/// depends on it, as on the wrapper and the property's declared type. +#[test] +fn a_swift_property_wrapper_argument_references_its_type() { + let source = "final class User {\n @Siblings(through: Pivot.self, from: \\.$user, to: \\.$tag)\n var tags: [Tag]\n}\n"; + assert_eq!( + member_reads("Sources/User.swift", source, Language::Swift), + pairs(&[("User", "Pivot"), ("User", "Tag")]) + ); +} diff --git a/crates/codegraph-extract/tests/type_references.rs b/crates/codegraph-extract/tests/type_references.rs new file mode 100644 index 00000000..e38ebc99 --- /dev/null +++ b/crates/codegraph-extract/tests/type_references.rs @@ -0,0 +1,269 @@ +//! A declaration's annotated types are `references` from it (G3, upstream +//! `TYPE_ANNOTATION_LANGUAGES`): parameter and return types of functions and +//! methods, and the other type positions each language's own path reads. +//! Built-in and primitive types name no project symbol and are skipped. + +use codegraph_core::types::{EdgeKind, Language, NodeKind}; +use codegraph_extract::extract_source; + +/// `(from, type)` for every `references` ref in `source`, sorted. +fn type_refs(path: &str, source: &str, language: Language) -> Vec<(String, String)> { + let result = extract_source(path, source, Some(language)); + let name_of = |id: &str| { + result + .nodes + .iter() + .find(|node| node.id == id) + .map_or_else(|| id.to_string(), |node| node.name.clone()) + }; + let mut refs: Vec<_> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == EdgeKind::References) + .map(|r| (name_of(&r.from_node_id), r.reference_name.clone())) + .collect(); + refs.sort(); + refs +} + +fn pairs(rows: &[(&str, &str)]) -> Vec<(String, String)> { + rows.iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect() +} + +#[test] +fn rust_parameter_and_return_types_are_references() { + let source = "pub fn build(w: Widget, s: &shop::Store, items: Vec, n: i32, name: &str) -> Result {\n todo!()\n}\n\npub struct Shape;\n\nimpl Shape {\n pub fn grow(&self, by: Scale) -> f64 {\n 0.0\n }\n}\n"; + assert_eq!( + type_refs("src/build.rs", source, Language::Rust), + pairs(&[ + ("build", "Error"), + ("build", "Item"), + ("build", "Out"), + ("build", "Result"), + ("build", "Store"), + ("build", "Vec"), + ("build", "Widget"), + ("grow", "Scale"), + ]) + ); +} + +/// Go's builtin types (`int`, `error`, ...) are identifiers in its grammar and +/// are skipped by name; a method's receiver is not a parameter type. +#[test] +fn go_parameter_and_result_types_are_references() { + let source = "package shop\n\nfunc Build(w Widget, s *store.Store, n int, items []Item) (Out, error) {\n\treturn Out{}, nil\n}\n\nfunc (s *Server) Handle(r Request) Response {\n\treturn Response{}\n}\n"; + assert_eq!( + type_refs("shop/build.go", source, Language::Go), + pairs(&[ + ("Build", "Item"), + ("Build", "Out"), + ("Build", "Store"), + ("Build", "Widget"), + ("Handle", "Request"), + ("Handle", "Response"), + ]) + ); +} + +/// Java: parameter, return and field types; a qualified type names only its +/// last segment, and primitives are their own grammar kinds. +#[test] +fn java_parameter_return_and_field_types_are_references() { + let source = "package app;\n\nclass Shop {\n private Repo repo;\n public Result build(Widget w, java.util.List items, int n, String s) {\n return null;\n }\n}\n"; + assert_eq!( + type_refs("src/app/Shop.java", source, Language::Java), + pairs(&[ + ("build", "Item"), + ("build", "List"), + ("build", "Out"), + ("build", "Result"), + ("build", "Widget"), + ("repo", "Repo"), + ]) + ); +} + +/// Kotlin (kotlin-ng writes a type as `user_type` over identifiers): +/// parameter and return types; an extension receiver is not a parameter type. +#[test] +fn kotlin_parameter_and_return_types_are_references() { + let source = "class Svc {\n fun build(w: Widget, items: List, n: Int): Result? { return x }\n fun Widget.ext(n: Int): Unit {}\n}\n"; + assert_eq!( + type_refs("src/Svc.kt", source, Language::Kotlin), + pairs(&[ + ("build", "Item"), + ("build", "List"), + ("build", "Out"), + ("build", "Result"), + ("build", "Widget"), + ]) + ); +} + +/// Scala: every parameter list of a curried definition, the return type, and +/// the bounds of its type parameters. +#[test] +fn scala_parameter_lists_return_type_and_bounds_are_references() { + let source = "object Shop {\n def build[A: Monoid, F <: Base](w: Widget)(implicit ord: Ordering[Item]): Result[Out] = ???\n}\n"; + assert_eq!( + type_refs("src/Shop.scala", source, Language::Scala), + pairs(&[ + ("build", "Base"), + ("build", "Item"), + ("build", "Monoid"), + ("build", "Ordering"), + ("build", "Out"), + ("build", "Result"), + ("build", "Widget"), + ]) + ); +} + +/// C#: only type positions are walked (a parameter's or tuple element's name +/// never surfaces), a qualified name by its last segment, and a built-in +/// (`predefined_type`) is skipped. +#[test] +fn csharp_type_positions_are_references() { + let source = "namespace App {\n class Svc {\n private readonly ILogger _log;\n public Widget Size { get; set; }\n public Result Build(Widget w, List items, int n, (int Code, Foo Payload) t, App.Models.Order o) { return null; }\n }\n}\n"; + assert_eq!( + type_refs("src/Svc.cs", source, Language::CSharp), + pairs(&[ + ("Build", "Foo"), + ("Build", "Item"), + ("Build", "List"), + ("Build", "Order"), + ("Build", "Out"), + ("Build", "Result"), + ("Build", "Widget"), + ("Size", "Widget"), + ("_log", "ILogger"), + ]) + ); +} + +/// PHP: parameter and return type hints; a qualified name by its last +/// segment; primitives and pseudo-types (`self`, `null`) are skipped. +#[test] +fn php_type_hints_are_references() { + let source = " build(Widget w, List items, int n) { return x; }\n}\n"; + assert_eq!( + type_refs("lib/svc.dart", source, Language::Dart), + pairs(&[ + ("build", "Item"), + ("build", "List"), + ("build", "Out"), + ("build", "Result"), + ("build", "Widget"), + ]) + ); +} + +/// Swift: parameter and return types; and a protocol composition continued +/// on an `&` line inside a type's body keeps the continuation's types +/// (upstream #2108), which the grammar otherwise parses as an error. +#[test] +fn swift_signature_and_composition_types_are_references() { + let source = "final class EditorStore {\n typealias EditorClient = AutocompleteService.Client\n & MediaUploadService.Client\n func build(w: Widget, items: [Item]) -> Result { fatalError() }\n}\n"; + assert_eq!( + type_refs("Sources/EditorStore.swift", source, Language::Swift), + pairs(&[ + ("EditorClient", "AutocompleteService"), + ("EditorClient", "Client"), + ("EditorClient", "Client"), + ("EditorClient", "MediaUploadService"), + ("build", "Error"), + ("build", "Item"), + ("build", "Out"), + ("build", "Result"), + ("build", "Widget"), + ]) + ); +} + +/// W2-03d (KEEP-RUST: upstream mints these nodes without reading their +/// types): a Kotlin property's declared type is a reference from the property. +/// An extension property's receiver is not its type; a primary-constructor +/// parameter and a local mint no node, so they reference nothing. +#[test] +fn kotlin_property_types_are_references() { + let source = "package app\n\nval shared: Repo = Repo()\n\nobject Config {\n val clock: Clock = Clock()\n var cache: Cache? = null\n}\n\nclass Svc(private val ctor: Ctor) {\n private val clock: Clock = Clock()\n val count: Int = 0\n val Widget.label: String get() = \"x\"\n fun run() { val local: Local = Local() }\n}\n"; + assert_eq!( + type_refs("src/Svc.kt", source, Language::Kotlin), + pairs(&[ + ("cache", "Cache"), + ("cache", "Repo"), + ("clock", "Clock"), + ("clock", "Clock"), + ("shared", "Repo"), + ]) + ); +} + +/// W2-03d: a PHP class property is a `field` node, one per +/// `property_element`, whose name nests as `variable_name → name` (upstream +/// `extractField`). Its signature is the declared type and `$name`, and its +/// visibility defaults to `public` as PHP's does. +#[test] +fn php_class_properties_are_field_nodes() { + let source = " = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Field) + .map(|node| { + ( + node.name.as_str(), + node.start_line, + node.signature.as_deref(), + node.visibility.as_deref(), + node.is_static, + ) + }) + .collect(); + fields.sort(); + assert_eq!( + fields, + [ + ("a", 8, Some("$a"), Some("public"), false), + ("b", 8, Some("$b"), Some("public"), false), + ("logger", 5, Some("Logger $logger"), Some("private"), false), + ("mailer", 6, Some("?Mailer $mailer"), Some("public"), true), + ("untyped", 7, Some("$untyped"), Some("public"), false), + ] + ); +} + +/// W2-03d (KEEP-RUST: upstream's PHP field branch returns before it reads a +/// type): a PHP property's declared type is a reference from its field node, +/// a qualified name by its last segment; a primitive is skipped and an untyped +/// property references nothing. +#[test] +fn php_property_types_are_references() { + let source = " Vec { .iter() .map(|node| (node.name.as_str(), node)) .collect::>(); - let import_nodes = result + let files = result .nodes .iter() - .filter(|node| node.kind == NodeKind::Import) - .map(|node| (node.name.as_str(), node)) + .filter(|node| node.kind == NodeKind::File) + .map(|node| (node.file_path.as_str(), node)) .collect::>(); for reference in &result.unresolved_references { let target = match reference.reference_kind { - EdgeKind::Imports => import_nodes - .get(reference.reference_name.as_str()) - .copied() + // A module path names the file it resolves to, never the import + // statement it is written in (upstream #2392 and #2263); a named + // specifier names the symbol. + EdgeKind::Imports => module_file(reference, &files) .or_else(|| by_name.get(reference.reference_name.as_str()).copied()), EdgeKind::Calls => { let simple = reference @@ -375,6 +376,32 @@ fn resolve_mini_edges(result: &ExtractionResult) -> Vec { edges } +/// The file a relative module path (`./math`) names from the importing file, +/// by the TypeScript extensions the mini fixture uses. +fn module_file<'a>(reference: &UnresolvedRef, files: &HashMap<&str, &'a Node>) -> Option<&'a Node> { + let name = reference.reference_name.as_str(); + if !name.starts_with("./") && !name.starts_with("../") { + return None; + } + let mut parts: Vec<&str> = reference + .file_path + .rsplit_once('/') + .map_or_else(Vec::new, |(dir, _)| dir.split('/').collect()); + for segment in name.split('/') { + match segment { + "." => {} + ".." => { + parts.pop()?; + } + _ => parts.push(segment), + } + } + let stem = parts.join("/"); + [".ts", ".tsx", "/index.ts"] + .iter() + .find_map(|suffix| files.get(format!("{stem}{suffix}").as_str()).copied()) +} + fn edge_from_ref(reference: &UnresolvedRef, target: &str) -> Edge { Edge { id: None, diff --git a/crates/codegraph-extract/tests/value_refs.rs b/crates/codegraph-extract/tests/value_refs.rs new file mode 100644 index 00000000..eabd308c --- /dev/null +++ b/crates/codegraph-extract/tests/value_refs.rs @@ -0,0 +1,528 @@ +//! Same-file value references (upstream #895, #897): a `references` edge, +//! marked `{"valueRef": true}`, from a symbol to a constant or variable of its +//! own file that it reads, so impact analysis reaches the readers of a shared +//! value. Only a distinctive name is a target, and a name the file binds again +//! in an inner scope is none, since its nested readers read that binding. +//! Always on (OD-8). The sources are upstream's own test fixtures where they +//! exist. + +use codegraph_core::types::{EdgeKind, Language}; +use codegraph_extract::extract_source; + +/// The names of the symbols with a value reference to a node named `target`. +fn readers(path: &str, source: &str, language: Language, target: &str) -> Vec { + let result = extract_source(path, source, Some(language)); + let name_of = |id: &str| { + result + .nodes + .iter() + .find(|node| node.id == id) + .map_or_else(|| id.to_string(), |node| node.name.clone()) + }; + let mut names: Vec<_> = result + .edges + .iter() + .filter(|edge| { + edge.kind == EdgeKind::References + && edge.metadata == Some(serde_json::json!({ "valueRef": true })) + && result + .nodes + .iter() + .any(|node| node.id == edge.target && node.name == target) + }) + .map(|edge| name_of(&edge.source)) + .collect(); + names.sort(); + names.dedup(); + names +} + +#[test] +fn typescript_readers_of_a_file_scope_constant() { + let source = "export const TABLE_CONFIG = { rows: 10, cols: 4 };\nexport function rowCount() { return TABLE_CONFIG.rows; }\nexport function describeTable() { return `${TABLE_CONFIG.rows}x${TABLE_CONFIG.cols}`; }\nexport const HEADER = TABLE_CONFIG.cols;"; + assert_eq!( + readers("config.ts", source, Language::TypeScript, "TABLE_CONFIG"), + ["HEADER", "describeTable", "rowCount"] + ); +} + +/// The bundled pattern: a file-scope `const Module` bound again by an inner +/// `var Module`. Nested readers read the inner binding, so the outer constant +/// is no target. +#[test] +fn a_constant_bound_again_in_an_inner_scope_has_no_readers() { + let source = "const Module = (function () {\n return function (Module) {\n var Module = typeof Module !== \"undefined\" ? Module : {};\n function locate() { return Module.path; }\n function getFunc() { return Module.lookup; }\n return { locate, getFunc };\n };\n})();\nexport default Module;"; + assert!(readers("bundled.ts", source, Language::TypeScript, "Module").is_empty()); +} + +#[test] +fn tsx_readers_inside_jsx() { + let source = "export const THEME_TOKENS = { color: \"red\", size: 12 };\nexport function Label() {\n return hi;\n}\nexport const Box = () =>
      ;"; + assert_eq!( + readers("widget.tsx", source, Language::Tsx, "THEME_TOKENS"), + ["Box", "Label"] + ); +} + +/// A name of fewer than three characters, or with no uppercase letter or +/// `_`, is too common to be a target; a symbol never reads itself. +#[test] +fn only_distinctive_names_are_targets() { + let source = "const ID = 1;\nconst limit = 2;\nconst MAX_ROWS = ID + limit;\nfunction rows() { return ID + limit + MAX_ROWS; }\n"; + assert!(readers("rows.js", source, Language::JavaScript, "ID").is_empty()); + assert!(readers("rows.js", source, Language::JavaScript, "limit").is_empty()); + assert_eq!( + readers("rows.js", source, Language::JavaScript, "MAX_ROWS"), + ["rows"] + ); +} + +#[test] +fn go_readers_of_a_package_constant_and_variable() { + let source = "package main\n\nconst MaxRetries = 3\nvar DefaultLabels = map[string]string{\"env\": \"prod\"}\n\nfunc retry() int { return MaxRetries }\nfunc labels() map[string]string { return DefaultLabels }"; + assert_eq!( + readers("main.go", source, Language::Go, "MaxRetries"), + ["retry"] + ); + assert_eq!( + readers("main.go", source, Language::Go, "DefaultLabels"), + ["labels"] + ); +} + +/// A Go constant or variable reads what its initializer names, as upstream's +/// reader (the whole `const_spec` / `var_spec`) does. +#[test] +fn a_go_value_reads_the_values_its_initializer_names() { + let source = "package main\n\nconst BaseValue = 1\nconst DerivedValue = BaseValue + 1\nvar ScaledValue = DerivedValue * 2\n\nfunc total() int { return ScaledValue }"; + assert_eq!( + readers("values.go", source, Language::Go, "BaseValue"), + ["DerivedValue"] + ); + assert_eq!( + readers("values.go", source, Language::Go, "DerivedValue"), + ["ScaledValue"] + ); + assert_eq!( + readers("values.go", source, Language::Go, "ScaledValue"), + ["total"] + ); +} + +#[test] +fn a_go_constant_shadowed_by_a_short_variable_has_no_readers() { + let source = "package main\n\nconst Timeout = 30\n\nfunc usesConst() int { return Timeout }\nfunc shadows() int {\n\tTimeout := 5\n\treturn Timeout\n}"; + assert!(readers("shadow.go", source, Language::Go, "Timeout").is_empty()); +} + +/// A module constant defined on both branches of a `try` is one value, not a +/// shadow; its two halves never read each other. +#[test] +fn a_conditionally_defined_python_constant_keeps_its_reader() { + let source = "try:\n\tHAS_SSL = True\nexcept ImportError:\n\tHAS_SSL = False\n\ndef uses_ssl():\n\treturn HAS_SSL"; + assert_eq!( + readers("cond.py", source, Language::Python, "HAS_SSL"), + ["uses_ssl"] + ); +} + +/// A Vue SFC's script is the TypeScript extractor's, so it reads values the +/// same way; a `\n\n\n"; + assert_eq!( + readers("src/App.vue", source, Language::Vue, "PAGE_SIZE"), + ["App", "pages"] + ); +} + +/// Svelte and Astro scripts are the script extractor's too. +#[test] +fn svelte_and_astro_scripts_inherit_value_references() { + let svelte = "\n\n

      {pages(100)}

      \n"; + assert_eq!( + readers("src/App.svelte", svelte, Language::Svelte, "PAGE_SIZE"), + ["pages"] + ); + let astro = "---\nconst PAGE_SIZE = 20;\nfunction pages(total: number) { return total / PAGE_SIZE; }\n---\n

      {pages(100)}

      \n"; + assert_eq!( + readers("src/pages/index.astro", astro, Language::Astro, "PAGE_SIZE"), + ["pages"] + ); +} + +/// `(kind, qualified name)` of every constant, variable and field in `source`. +fn values(path: &str, source: &str, language: Language) -> Vec<(String, String)> { + let result = extract_source(path, source, Some(language)); + let mut values: Vec<_> = result + .nodes + .iter() + .filter(|node| { + matches!( + node.kind, + codegraph_core::types::NodeKind::Constant + | codegraph_core::types::NodeKind::Variable + | codegraph_core::types::NodeKind::Field + ) + }) + .map(|node| (format!("{:?}", node.kind), node.qualified_name.clone())) + .collect(); + values.sort(); + values +} + +/// PHP: a file-scope `const` and a class `const` are constants, read bare or +/// through `self::` / `Config::`; a static property is mutable class state, a +/// field (W2-03d, as upstream's `extractField`) and no target. +#[test] +fn php_readers_of_file_and_class_constants() { + let source = " self::MAX_ITEMS ? self::MAX_ITEMS : $n; }\n function label($i) { return Config::STATUS_NAMES[$i]; }\n function version() { return APP_VERSION; }\n}"; + assert_eq!( + values("Config.php", source, Language::Php), + [ + ("Constant".to_string(), "APP_VERSION".to_string()), + ("Constant".to_string(), "Config::MAX_ITEMS".to_string()), + ("Constant".to_string(), "Config::STATUS_NAMES".to_string()), + ("Field".to_string(), "Config::counter".to_string()), + ] + ); + assert_eq!( + readers("Config.php", source, Language::Php, "MAX_ITEMS"), + ["capped"] + ); + assert_eq!( + readers("Config.php", source, Language::Php, "STATUS_NAMES"), + ["label"] + ); + assert_eq!( + readers("Config.php", source, Language::Php, "APP_VERSION"), + ["version"] + ); + assert!(readers("Config.php", source, Language::Php, "counter").is_empty()); +} + +/// Scala: an `object` is a singleton, so its `val`s are shared constants, as +/// a top-level `val` is; a `class` `val` is a per-instance field. +#[test] +fn scala_readers_of_top_level_and_object_values() { + let source = "val AppVersion = \"1.0\"\nobject Config {\n val TIMEOUT_MS = 30\n val STATUS_NAMES = List(\"ok\", \"fail\")\n def capped(n: Int): Int = if (n > TIMEOUT_MS) TIMEOUT_MS else n\n def label(i: Int): String = STATUS_NAMES(i)\n}\nclass Widget {\n val MaxItems = 100\n def within(n: Int): Int = if (n < MaxItems) n else MaxItems\n}"; + assert_eq!( + values("Demo.scala", source, Language::Scala), + [ + ("Constant".to_string(), "AppVersion".to_string()), + ("Constant".to_string(), "Config::STATUS_NAMES".to_string()), + ("Constant".to_string(), "Config::TIMEOUT_MS".to_string()), + ("Field".to_string(), "Widget::MaxItems".to_string()), + ] + ); + assert_eq!( + readers("Demo.scala", source, Language::Scala, "TIMEOUT_MS"), + ["capped"] + ); + assert_eq!( + readers("Demo.scala", source, Language::Scala, "STATUS_NAMES"), + ["label"] + ); + assert!(readers("Demo.scala", source, Language::Scala, "MaxItems").is_empty()); +} + +#[test] +fn a_scala_object_value_shadowed_by_a_local_has_no_readers() { + let source = "object Config {\n val TIMEOUT = 30\n def usesConst(): Int = TIMEOUT\n def shadows(): Int = { val TIMEOUT = 5; TIMEOUT }\n}"; + assert!(readers("Shadow.scala", source, Language::Scala, "TIMEOUT").is_empty()); +} + +/// Rust: a module-level `const` and `static` are values (upstream's generic +/// path names them `variable`). +#[test] +fn rust_readers_of_a_module_const_and_static() { + let source = "const MAX_RETRIES: u32 = 3;\nstatic DEFAULT_LABEL: &str = \"prod\";\n\nfn retry() -> u32 { MAX_RETRIES }\nfn label() -> &'static str { DEFAULT_LABEL }"; + assert_eq!( + values("lib.rs", source, Language::Rust), + [ + ("Variable".to_string(), "DEFAULT_LABEL".to_string()), + ("Variable".to_string(), "MAX_RETRIES".to_string()), + ] + ); + assert_eq!( + readers("lib.rs", source, Language::Rust, "MAX_RETRIES"), + ["retry"] + ); + assert_eq!( + readers("lib.rs", source, Language::Rust, "DEFAULT_LABEL"), + ["label"] + ); +} + +#[test] +fn a_rust_const_shadowed_by_a_let_has_no_readers() { + let source = "const TIMEOUT: u32 = 30;\n\nfn uses_const() -> u32 { TIMEOUT }\nfn shadows() -> u32 {\n let TIMEOUT = 5;\n TIMEOUT\n}"; + assert!(readers("shadow.rs", source, Language::Rust, "TIMEOUT").is_empty()); +} + +/// Ruby keeps most constants inside a class or module; both a top-level and +/// a class constant are values, read by the methods around them. +#[test] +fn ruby_readers_of_top_level_and_class_constants() { + let source = "MAX_RETRIES = 3\n\ndef retry_count\n MAX_RETRIES\nend\n\nclass Config\n TIMEOUT = 30\n def self.get_timeout\n TIMEOUT\n end\n def describe\n \"timeout=#{TIMEOUT}\"\n end\nend"; + assert_eq!( + values("app.rb", source, Language::Ruby), + [ + ("Variable".to_string(), "Config::TIMEOUT".to_string()), + ("Variable".to_string(), "MAX_RETRIES".to_string()), + ] + ); + assert_eq!( + readers("app.rb", source, Language::Ruby, "MAX_RETRIES"), + ["retry_count"] + ); + assert_eq!( + readers("app.rb", source, Language::Ruby, "TIMEOUT"), + ["describe", "get_timeout"] + ); +} + +/// C: a file-scope `static const` scalar and lookup table are constants, +/// plain globals are variables; a prototype is none. +#[test] +fn c_readers_of_file_scope_constants() { + let source = "static const int MAX_ITEMS = 100;\nstatic const char *const STATUS_NAMES[] = { \"ok\", \"fail\", \"pending\" };\nint counter_total = 0;\nint capped(int n);\n\nint capped(int n) { return n > MAX_ITEMS ? MAX_ITEMS : n; }\nconst char *label(int i) { return STATUS_NAMES[i]; }"; + assert_eq!( + values("config.c", source, Language::C), + [ + ("Constant".to_string(), "MAX_ITEMS".to_string()), + ("Constant".to_string(), "STATUS_NAMES".to_string()), + ("Variable".to_string(), "counter_total".to_string()), + ] + ); + assert_eq!( + readers("config.c", source, Language::C, "MAX_ITEMS"), + ["capped"] + ); + assert_eq!( + readers("config.c", source, Language::C, "STATUS_NAMES"), + ["label"] + ); +} + +#[test] +fn a_c_constant_shadowed_by_a_local_has_no_readers() { + let source = "static const int TIMEOUT = 30;\n\nint uses_const(void) { return TIMEOUT; }\nint shadows(void) {\n int TIMEOUT = 5;\n return TIMEOUT;\n}"; + assert!(readers("shadow.c", source, Language::C, "TIMEOUT").is_empty()); +} + +/// A prototype led by an unknown macro (`CURL_EXTERN CURLcode fn(int);`) +/// never mints a value named by its return type. +#[test] +fn a_macro_prefixed_c_prototype_mints_no_value() { + let source = "typedef enum { CURLE_OK, CURLE_FAIL } CURLcode;\nCURL_EXTERN CURLcode curl_easy_init(int x);\nCURL_EXTERN CURLcode curl_easy_setopt(int y);\n\nstatic const int REAL_LIMIT = 42;\nint use_real(void) { return REAL_LIMIT; }"; + assert_eq!( + values("api.c", source, Language::C), + [("Constant".to_string(), "REAL_LIMIT".to_string())] + ); + assert_eq!( + readers("api.c", source, Language::C, "REAL_LIMIT"), + ["use_real"] + ); +} + +/// Pascal: a unit `const` is a constant; a routine's body is its header's +/// sibling, and a routine's own `const` section binds the name again. +#[test] +fn pascal_readers_of_unit_constants() { + let source = "unit Demo;\ninterface\nconst\n MAX_ITEMS = 100;\n APP_NAME = 'MyApp';\nimplementation\nfunction Capped(n: Integer): Integer;\nbegin\n if n > MAX_ITEMS then Capped := MAX_ITEMS else Capped := n;\nend;\nfunction AppLabel: string;\nbegin\n AppLabel := APP_NAME;\nend;\nend."; + assert_eq!( + values("demo.pas", source, Language::Pascal), + [ + ("Constant".to_string(), "APP_NAME".to_string()), + ("Constant".to_string(), "MAX_ITEMS".to_string()), + ] + ); + assert_eq!( + readers("demo.pas", source, Language::Pascal, "MAX_ITEMS"), + ["Capped"] + ); + assert_eq!( + readers("demo.pas", source, Language::Pascal, "APP_NAME"), + ["AppLabel"] + ); + let shadow = "unit Shadow;\ninterface\nconst\n TIMEOUT = 30;\nimplementation\nfunction UsesConst: Integer;\nbegin\n UsesConst := TIMEOUT;\nend;\nfunction Shadows: Integer;\nconst TIMEOUT = 5;\nbegin\n Shadows := TIMEOUT;\nend;\nend."; + assert_eq!( + values("shadow.pas", shadow, Language::Pascal), + [("Constant".to_string(), "TIMEOUT".to_string())] + ); + assert!(readers("shadow.pas", shadow, Language::Pascal, "TIMEOUT").is_empty()); +} + +fn row(kind: &str, name: &str) -> (String, String) { + (kind.to_string(), name.to_string()) +} + +/// Java: a `static final` field is a constant (OD-5: it was a field); an +/// instance `final` field is per-object state, a field and no target. +#[test] +fn java_readers_of_static_final_constants() { + let source = "class Limits {\n public static final int MAX_ITEMS = 100;\n static final String[] STATUS_NAMES = { \"ok\", \"fail\" };\n final int instanceId = 1;\n int capped(int n) { return n > MAX_ITEMS ? MAX_ITEMS : n; }\n String label(int i) { return STATUS_NAMES[i]; }\n int id() { return instanceId; }\n}"; + assert_eq!( + values("Limits.java", source, Language::Java), + [ + row("Constant", "Limits::MAX_ITEMS"), + row("Constant", "Limits::STATUS_NAMES"), + row("Field", "Limits::instanceId"), + ] + ); + assert_eq!( + readers("Limits.java", source, Language::Java, "MAX_ITEMS"), + ["capped"] + ); + assert_eq!( + readers("Limits.java", source, Language::Java, "STATUS_NAMES"), + ["label"] + ); + assert!(readers("Limits.java", source, Language::Java, "instanceId").is_empty()); +} + +#[test] +fn a_java_constant_shadowed_by_a_local_has_no_readers() { + let source = "class Shadow {\n static final int TIMEOUT = 30;\n int usesConst() { return TIMEOUT; }\n int shadows() { int TIMEOUT = 5; return TIMEOUT; }\n}"; + assert!(readers("Shadow.java", source, Language::Java, "TIMEOUT").is_empty()); +} + +/// C#: `const` and `static readonly` fields are constants; an instance +/// `readonly` field stays a field. +#[test] +fn csharp_readers_of_const_and_static_readonly_fields() { + let source = "class Limits {\n const int MAX_ITEMS = 100;\n static readonly string[] STATUS_NAMES = { \"ok\", \"fail\" };\n readonly int instanceId = 1;\n int Capped(int n) { return n > MAX_ITEMS ? MAX_ITEMS : n; }\n string Label(int i) { return STATUS_NAMES[i]; }\n int Id() { return instanceId; }\n}"; + assert_eq!( + values("Limits.cs", source, Language::CSharp), + [ + row("Constant", "Limits::MAX_ITEMS"), + row("Constant", "Limits::STATUS_NAMES"), + row("Field", "Limits::instanceId"), + ] + ); + assert_eq!( + readers("Limits.cs", source, Language::CSharp, "MAX_ITEMS"), + ["Capped"] + ); + assert_eq!( + readers("Limits.cs", source, Language::CSharp, "STATUS_NAMES"), + ["Label"] + ); + assert!(readers("Limits.cs", source, Language::CSharp, "instanceId").is_empty()); +} + +#[test] +fn a_csharp_constant_shadowed_by_a_local_has_no_readers() { + let source = "class Shadow {\n const int TIMEOUT = 30;\n int UsesConst() { return TIMEOUT; }\n int Shadows() { int TIMEOUT = 5; return TIMEOUT; }\n}"; + assert!(readers("Shadow.cs", source, Language::CSharp, "TIMEOUT").is_empty()); +} + +/// Kotlin: a top-level property and an `object`'s or companion object's are +/// shared (`val` a constant, `var` a variable); a class property is a field; +/// a local is no node. +#[test] +fn kotlin_readers_of_top_level_object_and_companion_constants() { + let source = "const val TOP_LEVEL_MAX = 100\nobject Config {\n const val TIMEOUT_MS = 30\n val STATUS_NAMES = listOf(\"ok\", \"fail\")\n fun capped(n: Int): Int = if (n > TIMEOUT_MS) TIMEOUT_MS else n\n fun label(i: Int): String = STATUS_NAMES[i]\n}\nclass Widget {\n companion object { const val MAX_RETRIES = 3 }\n val instanceField = 1\n fun retries(): Int = MAX_RETRIES\n fun within(n: Int): Int = if (n < TOP_LEVEL_MAX) n else TOP_LEVEL_MAX\n}"; + assert_eq!( + values("Demo.kt", source, Language::Kotlin), + [ + row("Constant", "Config::STATUS_NAMES"), + row("Constant", "Config::TIMEOUT_MS"), + row("Constant", "TOP_LEVEL_MAX"), + row("Constant", "Widget::MAX_RETRIES"), + row("Field", "Widget::instanceField"), + ] + ); + assert_eq!( + readers("Demo.kt", source, Language::Kotlin, "STATUS_NAMES"), + ["label"] + ); + assert_eq!( + readers("Demo.kt", source, Language::Kotlin, "MAX_RETRIES"), + ["retries"] + ); + assert_eq!( + readers("Demo.kt", source, Language::Kotlin, "TOP_LEVEL_MAX"), + ["within"] + ); + assert!(readers("Demo.kt", source, Language::Kotlin, "instanceField").is_empty()); +} + +#[test] +fn a_kotlin_constant_shadowed_by_a_local_has_no_readers() { + let source = "object Config {\n const val TIMEOUT = 30\n fun usesConst(): Int = TIMEOUT\n fun shadows(): Int { val TIMEOUT = 5; return TIMEOUT }\n}"; + assert!(readers("Shadow.kt", source, Language::Kotlin, "TIMEOUT").is_empty()); +} + +/// Swift: a top-level `let` and a type's `static let` are constants; an +/// instance stored `let` is a field; a computed property is a property. +#[test] +fn swift_readers_of_top_level_and_static_constants() { + let source = "let topLevelMax = 100\nenum Constants {\n static let TIMEOUT_MS = 30\n static let STATUS_NAMES = [\"ok\", \"fail\"]\n}\nstruct Widget {\n static let MAX_RETRIES = 3\n let instanceField = 1\n func retries() -> Int { return Widget.MAX_RETRIES }\n func within(_ n: Int) -> Int { return n < topLevelMax ? n : topLevelMax }\n}\nfunc labels(_ i: Int) -> String { return Constants.STATUS_NAMES[i] }"; + assert_eq!( + values("Demo.swift", source, Language::Swift), + [ + row("Constant", "Constants::STATUS_NAMES"), + row("Constant", "Constants::TIMEOUT_MS"), + row("Constant", "Widget::MAX_RETRIES"), + row("Constant", "topLevelMax"), + row("Field", "Widget::instanceField"), + ] + ); + assert_eq!( + readers("Demo.swift", source, Language::Swift, "STATUS_NAMES"), + ["labels"] + ); + assert_eq!( + readers("Demo.swift", source, Language::Swift, "MAX_RETRIES"), + ["retries"] + ); + assert_eq!( + readers("Demo.swift", source, Language::Swift, "topLevelMax"), + ["within"] + ); + assert!(readers("Demo.swift", source, Language::Swift, "instanceField").is_empty()); +} + +#[test] +fn a_swift_constant_shadowed_by_a_local_has_no_readers() { + let source = "enum Config {\n static let TIMEOUT = 30\n static func usesConst() -> Int { return TIMEOUT }\n static func shadows() -> Int { let TIMEOUT = 5; return TIMEOUT }\n}"; + assert!(readers("Shadow.swift", source, Language::Swift, "TIMEOUT").is_empty()); +} + +/// Dart: a top-level `const`/`final` and a class `static const`/`static +/// final` (`static_final_declaration`) are constants; an instance field is +/// none. +#[test] +fn dart_readers_of_top_level_and_static_constants() { + let source = "const TOP_LEVEL_MAX = 100;\nclass Config {\n static const TIMEOUT_MS = 30;\n static final STATUS_NAMES = [\"ok\", \"fail\"];\n final int instanceField = 1;\n int capped(int n) => n > TIMEOUT_MS ? TIMEOUT_MS : n;\n String label(int i) { return STATUS_NAMES[i]; }\n int withinLimit(int n) => n < TOP_LEVEL_MAX ? n : TOP_LEVEL_MAX;\n}"; + assert_eq!( + values("demo.dart", source, Language::Dart), + [ + row("Constant", "Config::STATUS_NAMES"), + row("Constant", "Config::TIMEOUT_MS"), + row("Constant", "TOP_LEVEL_MAX"), + ] + ); + assert_eq!( + readers("demo.dart", source, Language::Dart, "TIMEOUT_MS"), + ["capped"] + ); + assert_eq!( + readers("demo.dart", source, Language::Dart, "STATUS_NAMES"), + ["label"] + ); + assert_eq!( + readers("demo.dart", source, Language::Dart, "TOP_LEVEL_MAX"), + ["withinLimit"] + ); +} + +#[test] +fn a_dart_constant_shadowed_by_a_local_has_no_readers() { + let source = "const TIMEOUT = 30;\nclass C {\n int usesConst() => TIMEOUT;\n int shadows() { const TIMEOUT = 5; return TIMEOUT; }\n}"; + assert!(readers("shadow.dart", source, Language::Dart, "TIMEOUT").is_empty()); +} diff --git a/crates/codegraph-graph/src/dead_code.rs b/crates/codegraph-graph/src/dead_code.rs index a834b4bf..2e7411d4 100644 --- a/crates/codegraph-graph/src/dead_code.rs +++ b/crates/codegraph-graph/src/dead_code.rs @@ -5,7 +5,10 @@ //! edge from whatever holds it points at the symbol. `dead` is an inference on //! top of that fact, and every step of it is subtractive — a candidate leaves //! the list the moment there is any reason to believe something outside the -//! graph reaches it: exported or declared in a header; in a test, generated or +//! graph reaches it: exported (a TypeScript `declare module 'x'` or `declare +//! global` body exports what it declares, which merges into a library's or +//! the global scope's type, upstream #2418) or declared in a header; in a +//! test, generated or //! vendored file; abstract or declared on an interface; decorated; overriding //! an ancestor's member (or an ancestor we cannot read); named something the //! language calls by itself; in an in-file test scope; in a component file diff --git a/crates/codegraph-graph/src/query/scoring.rs b/crates/codegraph-graph/src/query/scoring.rs index f88d356f..c9e2b1bd 100644 --- a/crates/codegraph-graph/src/query/scoring.rs +++ b/crates/codegraph-graph/src/query/scoring.rs @@ -539,7 +539,8 @@ fn matches_separator_test(lower_name: &str) -> bool { } let stem = &lower_name[..dot]; let stem_chars: Vec = stem.chars().collect(); - for marker in ["test", "tests", "spec", "specs"] { + // `unittest(s)`: Google's C++ `foo_unittest.cc` (upstream #2434). + for marker in ["test", "tests", "unittest", "unittests", "spec", "specs"] { if let Some(pos) = stem.rfind(marker) && pos + marker.len() == stem.len() && pos > 0 @@ -944,6 +945,10 @@ mod tests { assert!(is_test_file("FooTest.java")); assert!(is_test_file("FooSpec.scala")); assert!(is_test_file("MyTestCase.cs")); + // Google's C++ unittests (upstream #2434); a bare `unittest.go` is code. + assert!(is_test_path("src/stack_unittest.cc")); + assert!(is_test_path("src/stack_unittests.cc")); + assert!(!is_test_path("cmd/promtool/unittest.go")); } #[test] diff --git a/crates/codegraph-mcp/src/engine.rs b/crates/codegraph-mcp/src/engine.rs index 6904119c..6b6709de 100644 --- a/crates/codegraph-mcp/src/engine.rs +++ b/crates/codegraph-mcp/src/engine.rs @@ -4430,13 +4430,11 @@ fn with_health_banner( return result; } let banner = match health { - Some(codegraph_watch::WatchHealth::Recovering { .. }) => { - "⚠️ CodeGraph auto-sync is RECOVERING — another process held the index lock, so \ - syncing paused; changes are still being collected, but the full index catch-up \ - has not completed. Read files directly to confirm current content before relying \ - on these results." - .to_string() - } + Some(codegraph_watch::WatchHealth::Recovering { reason }) => format!( + "⚠️ CodeGraph auto-sync is RECOVERING — a full index catch-up has not completed \ + yet; changes are still being collected. Read files directly to confirm current \ + content before relying on these results.\n Reason: {reason}" + ), Some(codegraph_watch::WatchHealth::Disabled { reason }) => format!( "⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the index is \ frozen and any file edited since then is stale here. Read files directly to confirm \ diff --git a/crates/codegraph-mcp/src/instructions.rs b/crates/codegraph-mcp/src/instructions.rs index c6c9a6fc..be872264 100644 --- a/crates/codegraph-mcp/src/instructions.rs +++ b/crates/codegraph-mcp/src/instructions.rs @@ -108,7 +108,7 @@ typically one to a few calls; a grep/read exploration is dozens. ## Limitations - If a tool reports the project isn't initialized, `.codegraph/` doesn't exist yet — offer to run `codegraph init .` to build the index. -- Index lags file writes by ~1 second. +- Index lags file writes by ~1 second. A burst of many changes (a branch switch, a mass rename) or a sign that the OS dropped file events makes the watcher re-scan the whole project on its own, and responses can start with "⚠️ CodeGraph auto-sync is RECOVERING" until that catch-up commits. Renames never need a rebuild: `codegraph sync` reconciles the whole project when no watcher is running. - Cross-file resolution is best-effort name matching; ambiguous calls may return multiple candidates. - Explore matches names and indexed code words lexically, not by meaning; an empty result reports word matches and may suggest indexed candidate names to retry with `codegraph_explore`. - No live correctness validation — that's still the TypeScript compiler / test suite / linter's job. Codegraph supplements those with structural context they don't have. diff --git a/crates/codegraph-mcp/src/rmcp_handler.rs b/crates/codegraph-mcp/src/rmcp_handler.rs index 95c729b4..11ae34ba 100644 --- a/crates/codegraph-mcp/src/rmcp_handler.rs +++ b/crates/codegraph-mcp/src/rmcp_handler.rs @@ -191,7 +191,7 @@ impl CodeGraphHandler { let search_from = default_project.clone().or_else(|| cwd.clone()); let mut discovery = ServerRootDiscovery::new(search_from); let mut resolved_default = default_project; - if let Some(resolution) = discovery.initial_resolution() { + if let Some(resolution) = discovery.startup_resolution() { if let Some(root) = resolution.root { if resolution.via_subproject_scan { let relative = root @@ -251,13 +251,16 @@ impl CodeGraphHandler { let Some(discovery) = &self.root_discovery else { return; }; - let resolution = discovery + let mut discovery = discovery .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .retry_resolution(); - let Some(resolution) = resolution else { + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(resolution) = discovery.retry_resolution() else { return; }; + if let Some(notice) = discovery.scan_notice(&resolution) { + eprintln!("{notice}"); + } + drop(discovery); let Some(root) = resolution.root else { return; }; @@ -279,9 +282,9 @@ impl CodeGraphHandler { *default = Some(root); } - fn discovery_snapshot(&self) -> (Option, Vec) { + fn discovery_snapshot(&self) -> (Option, Vec, bool) { let Some(discovery) = &self.root_discovery else { - return (None, Vec::new()); + return (None, Vec::new(), false); }; let discovery = discovery .lock() @@ -289,6 +292,7 @@ impl CodeGraphHandler { ( discovery.search_from().map(Path::to_path_buf), discovery.known_candidates().to_vec(), + discovery.scan_truncated(), ) } @@ -527,8 +531,13 @@ impl ServerHandler for CodeGraphHandler { .into()); } crate::roots::ProjectArg::NotIndexed => { - let (search_from, candidates) = self.discovery_snapshot(); - let message = not_indexed_message(raw_project, search_from.as_deref(), &candidates); + let (search_from, candidates, truncated) = self.discovery_snapshot(); + let message = not_indexed_message( + raw_project, + search_from.as_deref(), + &candidates, + truncated, + ); let result = if raw_project.is_some() { ToolResult::error(message) } else { @@ -1221,6 +1230,14 @@ mod handler_tests { Some(workspace.path.clone()), Some(workspace.path.clone()), ); + // Construction, which precedes the `initialize` answer, walks upward + // only; the downward scan runs on the first retry, which `tools/list` + // and every tool call make before they read the default project. + assert_eq!( + handler.default_project_snapshot().as_deref(), + Some(workspace.path.as_path()) + ); + handler.retry_default_project(); assert_eq!( handler.default_project_snapshot().as_deref(), @@ -1240,7 +1257,8 @@ mod handler_tests { Some(workspace.path.clone()), Some(workspace.path.clone()), ); - let (base, candidates) = handler.discovery_snapshot(); + handler.retry_default_project(); + let (base, candidates, _) = handler.discovery_snapshot(); assert_eq!( handler.default_project_snapshot().as_deref(), diff --git a/crates/codegraph-mcp/src/roots.rs b/crates/codegraph-mcp/src/roots.rs index c5e19e4a..5dda1790 100644 --- a/crates/codegraph-mcp/src/roots.rs +++ b/crates/codegraph-mcp/src/roots.rs @@ -10,6 +10,13 @@ pub const ROOTS_LIST_REQUEST_ID: &str = "codegraph-roots-list-1"; const SUBPROJECT_SCAN_MAX_DEPTH: usize = 4; const SUBPROJECT_SCAN_MAX_CANDIDATES: usize = 64; +/// Directory entries one downward scan may examine. Counted rather than timed, +/// so where a scan stops is deterministic; a launch directory like `/tmp`, with +/// tens of thousands of entries, used to keep `initialize` waiting. +const SUBPROJECT_SCAN_MAX_VISITS: usize = 10_000; +/// Wall-clock backstop for a filesystem slow enough (a network mount) that +/// even the counted budget takes too long. +const SUBPROJECT_SCAN_DEADLINE: Duration = Duration::from_millis(500); const SUBPROJECT_RESCAN_TTL: Duration = Duration::from_secs(5); const SUBPROJECT_SCAN_SKIP: &[&str] = &[ @@ -153,6 +160,9 @@ pub struct ServerRootResolution { pub root: Option, pub via_subproject_scan: bool, pub candidates: Vec, + /// The downward scan stopped at its visit budget or deadline, so the + /// candidates are incomplete and none was adopted. + pub scan_truncated: bool, } /// Resolve the default project for a stdio MCP server. @@ -172,6 +182,7 @@ pub fn resolve_server_root( root: Some(root), via_subproject_scan: false, candidates: Vec::new(), + scan_truncated: false, }; } if !scan_subprojects || !eligible_for_subproject_scan(&search_from) { @@ -180,31 +191,44 @@ pub fn resolve_server_root( root: None, via_subproject_scan: false, candidates: Vec::new(), + scan_truncated: false, }; } - let candidates = find_indexed_subproject_roots( + let scan = scan_indexed_subprojects( &search_from, - SUBPROJECT_SCAN_MAX_DEPTH, - SUBPROJECT_SCAN_MAX_CANDIDATES, + SubprojectScanLimits { + max_depth: SUBPROJECT_SCAN_MAX_DEPTH, + max_candidates: SUBPROJECT_SCAN_MAX_CANDIDATES, + max_visits: SUBPROJECT_SCAN_MAX_VISITS, + deadline: Instant::now() + SUBPROJECT_SCAN_DEADLINE, + }, ); - let root = (candidates.len() == 1).then(|| candidates[0].clone()); + // Only a complete scan can prove a candidate is the only one. + let adopt = !scan.truncated && scan.candidates.len() == 1; + let root = adopt.then(|| scan.candidates[0].clone()); ServerRootResolution { search_from, root, - via_subproject_scan: candidates.len() == 1, - candidates, + via_subproject_scan: adopt, + candidates: scan.candidates, + scan_truncated: scan.truncated, } } -/// Stateful wrapper shared by both stdio front-ends. Initial construction scans -/// immediately; retries always perform the cheap upward walk and run the -/// downward scan no more than once per five seconds. +/// Stateful wrapper shared by both stdio front-ends. Construction walks upward +/// only; retries always perform the cheap upward walk and run the bounded +/// downward scan no more than once per five seconds, the first one at once. #[derive(Debug)] pub(crate) struct ServerRootDiscovery { search_from: Option, known_candidates: Vec, last_subproject_scan: Option, + /// The last downward scan stopped at its budget or deadline. + scan_truncated: bool, + /// The scan outcome last reported on stderr, so a retry reports a new + /// outcome once instead of every few seconds. + reported_scan: Option<(Vec, bool)>, } impl ServerRootDiscovery { @@ -213,11 +237,18 @@ impl ServerRootDiscovery { search_from, known_candidates: Vec::new(), last_subproject_scan: None, + scan_truncated: false, + reported_scan: None, } } - pub(crate) fn initial_resolution(&mut self) -> Option { - self.resolve_at(Instant::now(), true) + /// The resolution a server makes while it is being constructed, before it + /// can answer `initialize`: the cheap upward walk only. The bounded + /// downward scan is left to the first retry, so a large launch directory + /// never delays the handshake. + pub(crate) fn startup_resolution(&mut self) -> Option { + let search_from = self.search_from.as_ref()?; + Some(resolve_server_root(search_from, false)) } pub(crate) fn retry_resolution(&mut self) -> Option { @@ -238,6 +269,7 @@ impl ServerRootDiscovery { let resolution = resolve_server_root(search_from, scan_due); if scan_due { self.last_subproject_scan = Some(now); + self.scan_truncated = resolution.scan_truncated; } if resolution.root.is_some() { self.known_candidates.clear(); @@ -255,6 +287,43 @@ impl ServerRootDiscovery { &self.known_candidates } + pub(crate) fn scan_truncated(&self) -> bool { + self.scan_truncated + } + + /// The stderr notice a retry owes when its scan resolved no default: the + /// candidates it found and whether it stopped early, once per distinct + /// outcome. `None` when there is nothing new to say. + pub(crate) fn scan_notice(&mut self, resolution: &ServerRootResolution) -> Option { + if resolution.root.is_some() + || (resolution.candidates.is_empty() && !resolution.scan_truncated) + { + return None; + } + let outcome = (resolution.candidates.clone(), resolution.scan_truncated); + if self.reported_scan.as_ref() == Some(&outcome) { + return None; + } + self.reported_scan = Some(outcome); + let mut notice = if resolution.candidates.is_empty() { + format!( + "[CodeGraph MCP] No indexed sub-project found below {}.", + resolution.search_from.display() + ) + } else { + format!( + "[CodeGraph MCP] Indexed sub-projects found: {}. Pass `projectPath` per call, or launch with --path.", + format_subproject_candidates(Some(&resolution.search_from), &resolution.candidates) + ) + }; + if resolution.scan_truncated { + notice.push_str(&format!( + " The search stopped after {SUBPROJECT_SCAN_MAX_VISITS} entries, so it may have missed some and adopted none." + )); + } + Some(notice) + } + pub(crate) fn clear_candidates(&mut self) { self.known_candidates.clear(); } @@ -297,55 +366,105 @@ fn eligible_for_subproject_scan(base: &Path) -> bool { || base.join(".git").exists() } +#[cfg(test)] fn find_indexed_subproject_roots( root: &Path, max_depth: usize, max_candidates: usize, ) -> Vec { - fn walk( - dir: &Path, - depth: usize, - max_depth: usize, - max_candidates: usize, - out: &mut Vec, - ) { - if out.len() >= max_candidates || depth > max_depth { - return; + scan_indexed_subprojects( + root, + SubprojectScanLimits { + max_depth, + max_candidates, + max_visits: usize::MAX, + deadline: Instant::now() + Duration::from_secs(3_600), + }, + ) + .candidates +} + +/// The bounds of one downward sub-project scan. +#[derive(Debug, Clone, Copy)] +struct SubprojectScanLimits { + max_depth: usize, + max_candidates: usize, + /// Directory entries the scan may examine in all. + max_visits: usize, + deadline: Instant, +} + +/// What a downward scan found, and whether it stopped before it was done. +#[derive(Debug, Default)] +struct SubprojectScan { + candidates: Vec, + truncated: bool, +} + +fn scan_indexed_subprojects(root: &Path, limits: SubprojectScanLimits) -> SubprojectScan { + struct Walk { + limits: SubprojectScanLimits, + visits: usize, + scan: SubprojectScan, + } + + impl Walk { + /// Whether the walk must stop: enough candidates, or a budget spent. + fn done(&mut self) -> bool { + if self.scan.candidates.len() >= self.limits.max_candidates || self.scan.truncated { + return true; + } + if self.visits >= self.limits.max_visits || Instant::now() >= self.limits.deadline { + self.scan.truncated = true; + return true; + } + false } - let Ok(read_dir) = fs::read_dir(dir) else { - return; - }; - let mut entries = read_dir.filter_map(Result::ok).collect::>(); - entries.sort_by_key(fs::DirEntry::file_name); - for entry in entries { - if out.len() >= max_candidates { + + fn dir(&mut self, dir: &Path, depth: usize) { + if depth > self.limits.max_depth || self.done() { return; } - let Ok(file_type) = entry.file_type() else { - continue; + let Ok(read_dir) = fs::read_dir(dir) else { + return; }; - if !file_type.is_dir() { - continue; - } - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.starts_with('.') || SUBPROJECT_SCAN_SKIP.contains(&name.as_ref()) { - continue; - } - let child = entry.path(); - if db_exists_for(&child) { - out.push(child); - continue; + let mut entries = read_dir.filter_map(Result::ok).collect::>(); + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + if self.done() { + return; + } + self.visits += 1; + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with('.') || SUBPROJECT_SCAN_SKIP.contains(&name.as_ref()) { + continue; + } + let child = entry.path(); + if db_exists_for(&child) { + self.scan.candidates.push(child); + continue; + } + self.dir(&child, depth + 1); } - walk(&child, depth + 1, max_depth, max_candidates, out); } } - let mut out = Vec::new(); - walk(root, 1, max_depth, max_candidates, &mut out); - out.sort(); - out.dedup(); - out + let mut walk = Walk { + limits, + visits: 0, + scan: SubprojectScan::default(), + }; + walk.dir(root, 1); + walk.scan.candidates.sort(); + walk.scan.candidates.dedup(); + walk.scan } pub(crate) fn format_subproject_candidates(base: Option<&Path>, roots: &[PathBuf]) -> String { @@ -368,19 +487,27 @@ pub(crate) fn not_indexed_message( raw_project: Option<&str>, search_from: Option<&Path>, candidates: &[PathBuf], + scan_truncated: bool, ) -> String { if let Some(raw) = raw_project { return format!( "No indexed project found for projectPath {raw:?}. Pass an absolute path to an indexed project, or run `codegraph init` there." ); } - if candidates.is_empty() { - return "No indexed project resolved. Pass a `projectPath` argument, run `codegraph init` in the project, or start the server with `--path `.".to_string(); + let mut message = if candidates.is_empty() { + "No indexed project resolved. Pass a `projectPath` argument, run `codegraph init` in the project, or start the server with `--path `.".to_string() + } else { + let listed = format_subproject_candidates(search_from, candidates); + format!( + "No indexed project resolved. Indexed sub-projects were found below the server root: {listed}. Pass one as `projectPath`, or launch the server with `--path `." + ) + }; + if scan_truncated { + message.push_str(&format!( + " The search below the server root stopped after {SUBPROJECT_SCAN_MAX_VISITS} entries, so it may have missed indexed sub-projects and adopted none." + )); } - let listed = format_subproject_candidates(search_from, candidates); - format!( - "No indexed project resolved. Indexed sub-projects were found below the server root: {listed}. Pass one as `projectPath`, or launch the server with `--path `." - ) + message } /// Pure classifier for an [`codegraph_core::IndexPaths::resolve`] outcome, split @@ -1451,6 +1578,73 @@ mod tests { assert_eq!(resolved.candidates, vec![depth_four]); } + #[test] + fn truncated_scan_never_adopts() { + // A workspace whose only indexed child sorts after more directories + // than the scan may visit: a scan that stops early cannot know the + // child is the only one, so it must not adopt it. + let workspace = unindexed_dir("subscan-truncated"); + std::fs::create_dir(workspace.path().join(".git")).unwrap(); + for index in 0..10_050 { + std::fs::create_dir(workspace.path().join(format!("a{index:05}"))).unwrap(); + } + let only = workspace.path().join("zz-service"); + mark_indexed(&only); + + let resolved = resolve_server_root(workspace.path(), true); + assert_eq!(resolved.root, None, "a truncated scan adopts nothing"); + assert!(!resolved.via_subproject_scan); + } + + #[test] + fn subproject_scan_stops_at_visit_budget() { + let workspace = unindexed_dir("subscan-budget"); + std::fs::create_dir(workspace.path().join(".git")).unwrap(); + for index in 0..12 { + std::fs::create_dir(workspace.path().join(format!("dir-{index:02}"))).unwrap(); + } + mark_indexed(&workspace.path().join("dir-02")); + mark_indexed(&workspace.path().join("dir-10")); + let limits = |max_visits| SubprojectScanLimits { + max_depth: 4, + max_candidates: 64, + max_visits, + deadline: Instant::now() + Duration::from_secs(60), + }; + + // `.git` and dir-00 .. dir-04 are the first six entries: the budget + // stops the walk before dir-10, and says so. + let cut = scan_indexed_subprojects(workspace.path(), limits(6)); + assert!(cut.truncated); + assert_eq!(cut.candidates, [workspace.path().join("dir-02")]); + // The same budget on the same tree stops at the same entry. + let again = scan_indexed_subprojects(workspace.path(), limits(6)); + assert_eq!(again.candidates, cut.candidates); + + let whole = scan_indexed_subprojects(workspace.path(), limits(10_000)); + assert!(!whole.truncated); + assert_eq!(whole.candidates.len(), 2); + + let late = scan_indexed_subprojects( + workspace.path(), + SubprojectScanLimits { + deadline: Instant::now(), + ..limits(10_000) + }, + ); + assert!(late.truncated, "the deadline stops the walk too"); + } + + #[test] + fn a_truncated_scan_says_so_when_nothing_resolved() { + let message = not_indexed_message(None, None, &[], true); + assert!(message.contains("stopped after"), "{message}"); + assert!( + !not_indexed_message(None, None, &[], false).contains("stopped after"), + "a complete scan says nothing about stopping" + ); + } + #[test] fn subproject_scan_caps_candidates_at_sixty_four() { let workspace = unindexed_dir("subscan-cap"); @@ -1499,6 +1693,7 @@ mod tests { None, Some(base), &[base.join("service-b"), base.join("service-a")], + false, ); assert!(message.starts_with("No indexed project resolved.")); assert!(message.contains("service-a, service-b")); diff --git a/crates/codegraph-mcp/src/server.rs b/crates/codegraph-mcp/src/server.rs index 33d9446c..3489812c 100644 --- a/crates/codegraph-mcp/src/server.rs +++ b/crates/codegraph-mcp/src/server.rs @@ -228,7 +228,7 @@ impl McpServer { .or_else(|| use_cwd_for_discovery.then(|| cwd.clone()).flatten()); let mut root_discovery = ServerRootDiscovery::new(search_from); let mut resolved_default = default_project; - if let Some(resolution) = root_discovery.initial_resolution() { + if let Some(resolution) = root_discovery.startup_resolution() { if let Some(root) = resolution.root { if resolution.via_subproject_scan { let relative = root @@ -285,6 +285,9 @@ impl McpServer { return None; } let resolution = self.root_discovery.retry_resolution()?; + if let Some(notice) = self.root_discovery.scan_notice(&resolution) { + eprintln!("{notice}"); + } let root = resolution.root?; let relative = root .strip_prefix(&resolution.search_from) @@ -550,6 +553,7 @@ impl McpServer { raw_project, self.root_discovery.search_from(), self.root_discovery.known_candidates(), + self.root_discovery.scan_truncated(), ); let result = if raw_project.is_some() { ToolResult::error(message) @@ -817,10 +821,15 @@ mod tests { let workspace = workspace("single-child"); let child = indexed_child(&workspace, "service-a"); - let server = McpServer::new_with_cwd( + let mut server = McpServer::new_with_cwd( Some(workspace.path().to_path_buf()), Some(workspace.path().to_path_buf()), ); + // Construction, which precedes the `initialize` answer, walks upward + // only; the downward scan runs on the first retry, which `tools/list` + // and every tool call make. + assert_eq!(server.default_project(), Some(workspace.path())); + server.retry_default_project(); assert_eq!(server.default_project(), Some(child.as_path())); } @@ -830,10 +839,11 @@ mod tests { let workspace = workspace("ambiguous"); let a = indexed_child(&workspace, "service-a"); let b = indexed_child(&workspace, "service-b"); - let server = McpServer::new_with_cwd( + let mut server = McpServer::new_with_cwd( Some(workspace.path().to_path_buf()), Some(workspace.path().to_path_buf()), ); + server.retry_default_project(); assert_eq!(server.default_project(), Some(workspace.path())); assert_eq!( diff --git a/crates/codegraph-mcp/src/tools_list.json b/crates/codegraph-mcp/src/tools_list.json index c3c106d4..db0b0a6e 100644 --- a/crates/codegraph-mcp/src/tools_list.json +++ b/crates/codegraph-mcp/src/tools_list.json @@ -235,7 +235,7 @@ }, { "name": "codegraph_status", - "description": "Index health check (files / nodes / edges). Skip unless debugging.", + "description": "Index health check (files / nodes / edges), auto-sync state, and files changed since the last sync. Skip unless debugging or results look stale.", "inputSchema": { "type": "object", "properties": { diff --git a/crates/codegraph-resolve/src/awaited.rs b/crates/codegraph-resolve/src/awaited.rs index 73c1e609..255ca029 100644 --- a/crates/codegraph-resolve/src/awaited.rs +++ b/crates/codegraph-resolve/src/awaited.rs @@ -219,10 +219,32 @@ fn word_positions<'a>(text: &'a str, name: &'a str) -> impl Iterator bool { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' +} + +/// Start offsets of `name` in `text` with no identifier character (`$` +/// included) on either side: `(?(text: &'a str, name: &'a str) -> impl Iterator + 'a { + text.match_indices(name).filter_map(move |(at, _)| { + let before = text[..at] + .chars() + .next_back() + .is_some_and(is_identifier_char); + let after = text[at + name.len()..] + .chars() + .next() + .is_some_and(is_identifier_char); + (!before && !after).then_some(at) + }) +} + /// `hasParameterBinding`: `name` is an arrow parameter (`name =>`) or appears -/// inside a balanced parameter list that opens a function or arrow body. +/// inside a balanced parameter list that opens a function or arrow body. The +/// name is bounded as an identifier, `$` included (upstream #2378). pub(crate) fn has_parameter_binding(code: &str, name: &str) -> bool { - for at in word_positions(code, name) { + for at in identifier_positions(code, name) { let rest = code[at + name.len()..].trim_start_matches(char::is_whitespace); if rest.starts_with("=>") { return true; @@ -254,7 +276,9 @@ pub(crate) fn has_parameter_binding(code: &str, name: &str) -> bool { close += 1; } if depth == 0 - && contains_word(&code[open + 1..close - 1], name) + && identifier_positions(&code[open + 1..close - 1], name) + .next() + .is_some() && body.is_match(&code[close..]) { return true; @@ -286,6 +310,33 @@ pub(crate) fn declaration_offsets(code: &str, name: &str) -> Vec { .collect() } +/// `\b(?:const|let|var|function|class)\s+(?:NAME(?![\w$])|\{[^}]*(? Vec { + static KEYWORD: OnceLock = OnceLock::new(); + let keyword = KEYWORD.get_or_init(|| { + Regex::new(r"(?-u:\b)(?:const|let|var|function|class)\s+").expect("declaration keyword") + }); + keyword + .find_iter(code) + .filter(|found| { + let rest = &code[found.end()..]; + let direct = rest.starts_with(name) + && !rest[name.len()..] + .chars() + .next() + .is_some_and(is_identifier_char); + let destructured = rest.strip_prefix('{').is_some_and(|inner| { + let inner = inner.split('}').next().unwrap_or(inner); + identifier_positions(inner, name).next().is_some() + }); + direct || destructured + }) + .map(|found| found.start()) + .collect() +} + /// `\bNAME\s*=(?!=)`: an assignment (not a comparison) to `name`. fn is_reassigned(code: &str, name: &str) -> bool { word_positions(code, name).any(|at| { @@ -347,13 +398,15 @@ pub(crate) fn import_shadowed_at( stack }; let scope = stack_at(code.len()); - declaration_offsets(&code, name).into_iter().any(|at| { - let declared = stack_at(at); - declared - .iter() - .enumerate() - .all(|(depth, open)| scope.get(depth) == Some(open)) - }) + identifier_declaration_offsets(&code, name) + .into_iter() + .any(|at| { + let declared = stack_at(at); + declared + .iter() + .enumerate() + .all(|(depth, open)| scope.get(depth) == Some(open)) + }) } /// Infer the project type of an awaited receiver (`inferEsmAwaitedCallType`). @@ -672,6 +725,11 @@ mod tests { assert!(!has_parameter_binding("if (handle) { run(); }", "handle")); assert!(!has_parameter_binding("call(handle);", "handle")); assert!(!has_parameter_binding("(handler) => 1", "handle")); + // A `$` is part of the identifier (upstream #2378). + assert!(has_parameter_binding("function f($store) {", "$store")); + assert!(has_parameter_binding("items$ => 1", "items$")); + assert!(!has_parameter_binding("($stores) => 1", "$store")); + assert!(!has_parameter_binding("(items$$) => 1", "items$")); } #[test] @@ -712,4 +770,20 @@ mod tests { let mapped = map_column(source, &source_lines, &code, &code_lines, 0, column).unwrap(); assert_eq!(&code[mapped..mapped + 1], "x"); } + + #[test] + fn import_shadowing_declarations_bound_the_name_as_an_identifier() { + use super::identifier_declaration_offsets; + assert_eq!( + identifier_declaration_offsets("const items$ = 1;", "items$"), + [0] + ); + assert_eq!( + identifier_declaration_offsets("let { a, $store } = x;", "$store"), + [0] + ); + assert!(identifier_declaration_offsets("const items$$ = 1;", "items$").is_empty()); + assert!(identifier_declaration_offsets("const { $stores } = x;", "$store").is_empty()); + assert!(identifier_declaration_offsets("const items = 1;", "items$").is_empty()); + } } diff --git a/crates/codegraph-resolve/src/c_macro_visibility.rs b/crates/codegraph-resolve/src/c_macro_visibility.rs index aa3c14df..5d10167d 100644 --- a/crates/codegraph-resolve/src/c_macro_visibility.rs +++ b/crates/codegraph-resolve/src/c_macro_visibility.rs @@ -17,6 +17,13 @@ //! its own name is how that function gets called and hides nothing. //! Object-like definitions take part in conditions but never enter the //! per-root timelines. +//! +//! A guard the walk cannot decide (`#define X_H 1`, or any header reached +//! under an unknown `#if`) re-enters its header on every inclusion path, which +//! is exponential in the include graph's depth (upstream #2292, #2127). A +//! re-entry that provably repeats an earlier visit which changed nothing is +//! skipped, and a hard budget bounds whatever is left: past it, nothing is +//! known and nothing is suppressed. use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::sync::{Arc, LazyLock}; @@ -86,12 +93,36 @@ enum FileEvent { op: BranchOp, expression: String, guard: bool, + line: usize, + }, + Once { + line: usize, }, - Once, } -/// Per root file: macro name → `(root line, defined)` events in root order. -type Timeline = BTreeMap>; +impl FileEvent { + fn line(&self) -> usize { + match self { + Self::Define { line, .. } + | Self::Undef { line, .. } + | Self::Include { line, .. } + | Self::Branch { line, .. } + | Self::Once { line } => *line, + } + } +} + +/// Per root file: macro name → `(root line, defined)` events in root order; +/// from `cutoff` on (the root line where the walk's budget ran out), nothing +/// is known. +#[derive(Debug, Default)] +struct Timeline { + events: BTreeMap>, + cutoff: Option, +} + +/// Directive events one translation-unit walk may evaluate (upstream #2292). +const WALK_EVENT_BUDGET: usize = 1_000_000; /// Roots kept at once; a pure cache, so eviction only costs a recomputation. const ROOT_TIMELINE_CAP: usize = 32; @@ -163,7 +194,11 @@ pub(crate) fn is_visible_macro( .unwrap_or_else(std::sync::PoisonError::into_inner) .timeline_for(&reference.file_path, reference.language, context); let line = usize::try_from(reference.line).unwrap_or(0); + if timeline.cutoff.is_some_and(|cutoff| line >= cutoff) { + return false; + } timeline + .events .get(&reference.reference_name) .and_then(|events| events.iter().rev().find(|(at, _)| *at <= line)) .is_some_and(|(_, defined)| *defined == Some(true)) @@ -217,6 +252,7 @@ impl MacroVisibility { op, expression, guard, + line: index + 1, }); continue; } @@ -246,7 +282,7 @@ impl MacroVisibility { }); } if PRAGMA_ONCE.is_match(text) { - events.push(FileEvent::Once); + events.push(FileEvent::Once { line: index + 1 }); } } let events = Arc::new(events); @@ -322,18 +358,25 @@ impl MacroVisibility { while let Some(frame) = walk.stack.last_mut() { let Some(event) = frame.events.get(frame.next).cloned() else { let done = walk.stack.pop().expect("a frame to pop"); - walk.scanning.remove(&done.file); + walk.leave(done); continue; }; frame.next += 1; let (file, active, include_line) = (frame.file.clone(), frame.active, frame.include_line); let at = |line: usize| include_line.unwrap_or(line); + // Past the budget the timeline is unknown from this line on. + if walk.budget == 0 { + walk.timeline.cutoff = Some(at(event.line())); + break; + } + walk.budget -= 1; match event { FileEvent::Branch { op, expression, guard, + .. } => { let known = walk .definitions @@ -374,9 +417,13 @@ impl MacroVisibility { } } _ if active == Some(false) => {} - FileEvent::Once => { + FileEvent::Once { .. } => { let prior = walk.once.get(&file).copied().unwrap_or(Some(false)); - walk.once.insert(file, or(prior, active)); + let next = or(prior, active); + if next != prior { + walk.version += 1; + } + walk.once.insert(file, next); } FileEvent::Include { quote, spec, line } => { if let Some(target) = @@ -431,9 +478,21 @@ struct Frame { branches: Vec<(Truth, Truth)>, /// The root file's line of the outermost `#include`, for nested events. include_line: Option, + /// [`Walk::version`] when the visit began. + start: u64, + /// Where the visit's own include cycles begin in [`Walk::cuts`]. + first_cut: usize, +} + +/// What one finished visit of a file saw. +struct Visit { + inherited: Truth, + start: u64, + end: u64, + /// The files on the recursion stack it did not re-enter. + cuts: Vec, } -#[derive(Default)] struct Walk { stack: Vec, definitions: HashMap, @@ -441,6 +500,30 @@ struct Walk { macro_names: HashSet, once: HashMap, timeline: Timeline, + /// Bumped by every change a later directive could observe: a + /// definition, `#pragma once`, the macro-name set. + version: u64, + /// The include cycles the recursion stack cut, in visit order. + cuts: Vec, + visits: HashMap, + budget: usize, +} + +impl Default for Walk { + fn default() -> Self { + Self { + stack: Vec::new(), + definitions: HashMap::new(), + scanning: HashSet::new(), + macro_names: HashSet::new(), + once: HashMap::new(), + timeline: Timeline::default(), + version: 0, + cuts: Vec::new(), + visits: HashMap::new(), + budget: WALK_EVENT_BUDGET, + } + } } impl Walk { @@ -452,10 +535,26 @@ impl Walk { cache: &mut MacroVisibility, context: &dyn ResolutionContext, ) -> Option { - if inherited == Some(false) - || self.scanning.contains(file) - || self.once.get(file).copied() == Some(Some(true)) + if inherited == Some(false) || self.once.get(file).copied() == Some(Some(true)) { + return None; + } + if self.scanning.contains(file) { + self.cuts.push(file.to_string()); + return None; + } + // A visit that changed nothing, entered again with the same inherited + // truth while nothing has changed since, would start from the same + // state, take the same branches and change nothing again; the events + // it would record repeat each name's current state, already its last + // event. The include cycles it cut must still be cut. + if let Some(seen) = self.visits.get(file) + && seen.inherited == inherited + && seen.start == seen.end + && seen.end == self.version + && seen.cuts.iter().all(|cut| self.scanning.contains(cut)) { + let cuts = seen.cuts.clone(); + self.cuts.extend(cuts); return None; } self.scanning.insert(file.to_string()); @@ -467,9 +566,32 @@ impl Walk { active: inherited, branches: Vec::new(), include_line, + start: self.version, + first_cut: self.cuts.len(), }) } + /// A visit is done: remember what it saw. + fn leave(&mut self, frame: Frame) { + self.scanning.remove(&frame.file); + let mut own: Vec = Vec::new(); + for cut in self.cuts.drain(frame.first_cut..) { + if !own.contains(&cut) { + own.push(cut); + } + } + self.cuts.extend(own.iter().cloned()); + self.visits.insert( + frame.file, + Visit { + inherited: frame.inherited, + start: frame.start, + end: self.version, + cuts: own, + }, + ); + } + /// What an `#if` expression says, as far as the source decides it: the /// whole expression, three-valued (see [`evaluate`]). Upstream reads a /// literal, one `defined` test or a bare name only (KEEP-RUST). @@ -511,19 +633,29 @@ impl Walk { } else { None }; - self.definitions.insert( - name.to_string(), - Definition { - defined, - value, - macro_, + let next = Definition { + defined, + value, + macro_, + }; + let unchanged = prior.map_or( + next.defined.is_none() && next.value.is_none() && next.macro_.is_none(), + |prior| { + prior.defined == next.defined + && prior.value == next.value + && prior.macro_ == next.macro_ }, ); - if function_like { - self.macro_names.insert(name.to_string()); + if !unchanged { + self.version += 1; + } + self.definitions.insert(name.to_string(), next); + if function_like && self.macro_names.insert(name.to_string()) { + self.version += 1; } if self.macro_names.contains(name) { self.timeline + .events .entry(name.to_string()) .or_default() .push((line, macro_)); @@ -1221,6 +1353,229 @@ fn skip_literal(bytes: &[u8], start: usize, quote: u8) -> usize { mod tests { use super::*; + /// In-memory files where every name is both a function-like macro and a + /// function, so a call's visibility is read from its translation unit + /// (upstream #2292's `contextOver`). + struct FilesContext { + files: HashMap, + } + + impl ResolutionContext for FilesContext { + fn get_nodes_in_file(&self, _file_path: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_name(&self, name: &str) -> Vec { + let node = |kind: NodeKind, signature: Option| Node { + id: format!("{}:{name}", kind.as_str()), + kind, + name: name.to_string(), + qualified_name: name.to_string(), + file_path: "elsewhere.c".to_string(), + language: Language::Cpp, + start_line: 1, + end_line: 1, + start_column: 0, + end_column: 0, + docstring: None, + signature, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + }; + vec![ + node(NodeKind::Constant, Some(format!("#define {name}(v) (v)"))), + node(NodeKind::Function, None), + ] + } + fn get_nodes_by_qualified_name(&self, _qualified_name: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_kind(&self, _kind: NodeKind) -> Vec { + Vec::new() + } + fn file_exists(&self, file_path: &str) -> bool { + self.files.contains_key(file_path) + } + fn read_file(&self, file_path: &str) -> Option { + self.files.get(file_path).cloned() + } + fn get_project_root(&self) -> &str { + "/proj" + } + fn get_all_files(&self) -> Vec { + let mut files: Vec = self.files.keys().cloned().collect(); + files.sort(); + files + } + fn get_nodes_by_lower_name(&self, _lower_name: &str) -> Vec { + Vec::new() + } + fn get_node_by_id(&self, _id: &str) -> Option { + None + } + fn get_import_mappings( + &self, + _file_path: &str, + _language: Language, + ) -> Vec { + Vec::new() + } + } + + #[derive(Clone, Copy, Debug)] + enum Guard { + Value, + Bare, + UnknownFlag, + } + + /// `depth` layers of two headers; each includes both headers of the next + /// layer (upstream #2292's `layeredHeaders`). + fn layered_headers(depth: usize, guard: Guard) -> Vec<(String, String)> { + let mut files = Vec::new(); + for d in 0..depth { + for side in ["a", "b"] { + let name = format!("H_{d}_{side}"); + let open = match guard { + Guard::Value => format!("#ifndef {name}\n#define {name} 1\n"), + Guard::Bare => format!("#ifndef {name}\n#define {name}\n"), + Guard::UnknownFlag => format!("#ifdef USE_{d}\n"), + }; + let next = if d + 1 < depth { + format!("#include \"h{}_a.h\"\n#include \"h{}_b.h\"\n", d + 1, d + 1) + } else { + String::new() + }; + files.push(( + format!("h{d}_{side}.h"), + format!( + "{open}{next}#define M_{d}_{side}(x) (x)\nint f_{d}_{side}(int);\n#endif\n" + ), + )); + } + } + files + } + + fn visible(files: &[(String, String)], name: &str, line: i64) -> bool { + let context = FilesContext { + files: files.iter().cloned().collect(), + }; + let cache = std::sync::Mutex::new(MacroVisibility::default()); + let reference = RefView { + row_id: None, + from_node_id: "function:unit".to_string(), + reference_name: name.to_string(), + reference_kind: EdgeKind::Calls, + line, + column: 0, + file_path: "unit.cpp".to_string(), + language: Language::Cpp, + is_function_ref: false, + reference_subkind: None, + }; + is_visible_macro(&cache, &reference, &context) + } + + fn deep_unit(depth: usize, guard: Guard) -> Vec<(String, String)> { + let mut files = layered_headers(depth, guard); + files.push(( + "trace.h".to_string(), + "#define TRACE(v) ((void)(v))\n".to_string(), + )); + files.push(( + "unit.cpp".to_string(), + "#include \"h0_a.h\"\n#include \"h0_b.h\"\n#include \"trace.h\"\nvoid unit() { TRACE(1); }\n".to_string(), + )); + files + } + + /// Upstream #2292 (#2127): a layered include graph whose guards the walk + /// cannot decide used to cost 2^depth header scans; 21 layers, the depth + /// the audit measured at 4 s, now walk in well under a second. + #[test] + fn a_21_layer_include_graph_is_walked_in_under_a_second() { + for guard in [Guard::Value, Guard::Bare, Guard::UnknownFlag] { + let files = deep_unit(21, guard); + let started = std::time::Instant::now(); + assert!(visible(&files, "TRACE", 4), "{guard:?}"); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "{guard:?}: {:?}", + started.elapsed() + ); + } + } + + /// Upstream #2292's own case: 40 layers (2^40 inclusion paths), with the + /// answers unchanged: a header macro under an undecidable guard is not + /// definitely visible, and a call above the include defining `TRACE` is + /// still a call. + #[test] + fn a_40_layer_include_graph_keeps_its_answers() { + for guard in [Guard::Value, Guard::Bare, Guard::UnknownFlag] { + let files = deep_unit(40, guard); + let started = std::time::Instant::now(); + assert!(visible(&files, "TRACE", 4), "{guard:?}"); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "{guard:?}: {:?}", + started.elapsed() + ); + assert_eq!( + visible(&files, "M_39_a", 4), + matches!(guard, Guard::Bare), + "{guard:?}" + ); + assert!(!visible(&files, "TRACE", 2), "{guard:?}"); + } + } + + /// Upstream #2292: a walk that cannot be shortened (a known flag toggled + /// on every inclusion, no guards) stops at its budget, and suppresses + /// nothing past the line where it stopped. + #[test] + fn a_walk_past_its_budget_suppresses_nothing_after_the_cut() { + let mut files: Vec<(String, String)> = vec![ + ( + "toggle.h".to_string(), + "#ifdef T\n#undef T\n#else\n#define T\n#endif\n".to_string(), + ), + ( + "unit.cpp".to_string(), + "#define T\n#define TRACE(v) ((void)(v))\nvoid before() { TRACE(1); }\n#include \"h0.h\"\nvoid after() { TRACE(2); }\n".to_string(), + ), + ]; + for d in 0..22 { + files.push(( + format!("h{d}.h"), + if d + 1 < 22 { + format!( + "#include \"toggle.h\"\n#include \"h{}.h\"\n#include \"h{}.h\"\n", + d + 1, + d + 1 + ) + } else { + "#include \"toggle.h\"\n".to_string() + }, + )); + } + let started = std::time::Instant::now(); + assert!(visible(&files, "TRACE", 3)); + assert!(!visible(&files, "TRACE", 5)); + assert!( + started.elapsed() < std::time::Duration::from_secs(30), + "{:?}", + started.elapsed() + ); + } + #[test] fn directive_lines_drop_comments_but_keep_directive_text() { let lines = directive_lines( diff --git a/crates/codegraph-resolve/src/cfml_scope.rs b/crates/codegraph-resolve/src/cfml_scope.rs new file mode 100644 index 00000000..4732c331 --- /dev/null +++ b/crates/codegraph-resolve/src/cfml_scope.rs @@ -0,0 +1,582 @@ +//! CFML calls reached by name alone (upstream #2148, in the shape at +//! `b635dd46`): CFML types no receiver, so a bare call written in a component +//! (`.cfc`) reaches only a method of the component or of one it `extends`, +//! read from source; anything else is a built-in (coldbox's `now()` went to a +//! date helper's `now` 228 times). +//! +//! The `extends` chain is read from the components' text, as upstream reads +//! it: a dotted path matched against the indexed `.cfc` files by name, the +//! same directory first, else by the longest path suffix. + +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::csharp_types::js_regex; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// How many component files one chain holds (upstream's 30). +const CHAIN_LIMIT: usize = 30; + +/// The per-pass memo of the `extends` chains (upstream `CFML_CHAINS`). +#[derive(Debug, Default)] +pub struct CfmlTables { + chains: Mutex>>>, +} + +impl CfmlTables { + pub fn new() -> Self { + Self::default() + } +} + +/// Whether `path` is a CFML component file (`/\.cfc$/i`). +pub fn is_component_file(path: &str) -> bool { + path.len() >= 4 + && path + .get(path.len() - 4..) + .is_some_and(|tail| tail.eq_ignore_ascii_case(".cfc")) +} + +/// `^[A-Za-z_]\w*$`: a call's name with no receiver written into it. +fn is_bare_name(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Whether `reference` is a bare CFML call, whose candidates +/// [`is_cfml_method_in_scope`] judges (upstream `cfmlBare`). +pub(crate) fn is_cfml_bare_call(reference: &RefView) -> bool { + reference.language == Language::Cfml + && reference.reference_kind == EdgeKind::Calls + && is_bare_name(&reference.reference_name) +} + +/// `/\bfunction\b| &'static Regex { + static PATTERN: OnceLock = OnceLock::new(); + PATTERN.get_or_init(|| { + Regex::new(r"(?i)(?-u:\b)function(?-u:\b)| &'static Regex { + static PATTERN: OnceLock = OnceLock::new(); + PATTERN.get_or_init(|| js_regex(r#"(?i)(?-u:\b)extends\s*=\s*["']?([A-Za-z0-9_./:-]+)["']?"#)) +} + +/// What a component's text names as the component it extends: the first +/// `extends=` before its first function (upstream reads +/// `text.slice(0, text.search(/\bfunction\b|>> 0 || +/// text.length)`, so a text with no function, or one opening with it, is +/// read whole). +pub fn extends_of(text: &str) -> Option { + let cut = first_function_pattern() + .find(text) + .map(|found| found.start()) + .filter(|&start| start > 0) + .unwrap_or(text.len()); + extends_pattern() + .captures(&text[..cut]) + .and_then(|captures| captures.get(1)) + .map(|name| name.as_str().to_string()) +} + +/// The directory part of `path`, with its trailing `/` (`''` at the root). +fn directory_of(path: &str) -> &str { + path.rfind('/').map_or("", |slash| &path[..=slash]) +} + +/// The indexed `.cfc` files a component path names (upstream +/// `cfmlComponentFiles`): the files declaring a class of its last segment's +/// name, any case; for a bare name the ones in `from`'s directory first, else +/// the ones ending in the longest suffix of the path. The files are in path +/// order (upstream: index order; KEEP-RUST), so the chain's limit cuts the +/// same files on every index. +fn component_files(dotted: &str, from: &str, context: &dyn ResolutionContext) -> Vec { + let flat = dotted.replace('/', "."); + let segments: Vec<&str> = flat.split('.').filter(|s| !s.is_empty()).collect(); + let Some(last) = segments.last() else { + return Vec::new(); + }; + let name = last.to_ascii_lowercase(); + let files: Vec = context + .get_nodes_by_lower_name_shared(&name) + .into_iter() + .filter(|node| node.kind == NodeKind::Class && is_component_file(&node.file_path)) + .map(|node| node.file_path.clone()) + .collect::>() + .into_iter() + .collect(); + if files.is_empty() { + return files; + } + if segments.len() == 1 { + let dir = directory_of(from); + let local: Vec = files + .iter() + .filter(|file| directory_of(file) == dir) + .cloned() + .collect(); + return if local.is_empty() { files } else { local }; + } + for take in (1..=segments.len()).rev() { + let suffix = format!( + "/{}.cfc", + segments[segments.len() - take..] + .join("/") + .to_ascii_lowercase() + ); + let hits: Vec = files + .iter() + .filter(|file| format!("/{}", file.to_ascii_lowercase()).ends_with(&suffix)) + .cloned() + .collect(); + if !hits.is_empty() { + return hits; + } + } + files +} + +/// A component file and every component file it extends, at most 30 +/// (upstream `cfmlChain`). +fn chain(file: &str, context: &dyn ResolutionContext) -> Arc> { + let tables = context.cfml_tables(); + if let Some(hit) = tables + .chains + .lock() + .ok() + .and_then(|memo| memo.get(file).cloned()) + { + return hit; + } + let mut chain: HashSet = HashSet::new(); + let mut queue: VecDeque = VecDeque::from([file.to_string()]); + while chain.len() < CHAIN_LIMIT { + let Some(current) = queue.pop_front() else { + break; + }; + if !chain.insert(current.clone()) { + continue; + } + let extended = context + .source_facts(¤t) + .and_then(|facts| extends_of(facts.source())); + if let Some(extended) = extended { + queue.extend(component_files(&extended, ¤t, context)); + } + } + let chain = Arc::new(chain); + if let Ok(mut memo) = tables.chains.lock() { + memo.insert(file.to_string(), Arc::clone(&chain)); + } + chain +} + +/// Whether a bare CFML call can mean `method` (upstream +/// `isCfmlMethodInScope`): in a component, only a method of its own file or +/// of a component it extends. A call in a `.cfm` template is not judged (a +/// ColdBox view runs inside the renderer's scope), nor is one whose name its +/// line does not show without a receiver (a link of a chain the extractor +/// hands over bare). +pub(crate) fn is_cfml_method_in_scope( + method: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if !is_component_file(&reference.file_path) + || !crate::name_matcher::has_no_receiver_on_line(reference, context) + { + return true; + } + chain(&reference.file_path, context).contains(&method.file_path) +} + +/// Whether `reference` names a CFML supertype by its component path +/// (upstream #1152): an `extends` or `implements` written with a `.` or a +/// `/`. A dotted `calls` ref is a member-access chain, never a path. +pub(crate) fn is_cfml_component_path_ref(reference: &RefView) -> bool { + reference.language == Language::Cfml + && matches!( + reference.reference_kind, + EdgeKind::Extends | EdgeKind::Implements + ) + && (reference.reference_name.contains('.') || reference.reference_name.contains('/')) +} + +/// The CFML supertype a component path names (upstream +/// `resolveCfmlComponentPath`, #1152 / #1154). Two forms exist: +/// +/// - Relative, `extends="../base"` / `"./base"` (FW/1): resolved against the +/// referencing file's directory; the class named by the last segment whose +/// file is exactly that path (any case) is it. +/// - Dotted, `extends="coldbox.system.web.Controller"`: dots are directories +/// from the webroot or a mapping, whose leading segments may not exist in +/// the repository (in coldbox itself the path is `system/web/ +/// Controller.cfc`). Among the CFML classes and interfaces of the last +/// segment's name, the one whose parent directories match the most +/// segments right to left (any case) is it; one matching none is an +/// out-of-repository library's (mxunit, testbox), and a tie is no edge. +/// +/// No other strategy sees the reference: the whole path can only mis-match. +pub(crate) fn resolve_cfml_component_path( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let candidates = |name: &str| -> Vec> { + context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| { + matches!(node.kind, NodeKind::Class | NodeKind::Interface) + && node.language == Language::Cfml + }) + .collect() + }; + let norm = |path: &str| path.replace('\\', "/").to_ascii_lowercase(); + let name = &reference.reference_name; + if name.contains('/') { + let rel = strip_cfc_suffix(name); + let from = reference.file_path.replace('\\', "/"); + let mut parts: Vec<&str> = from.split('/').collect(); + parts.pop(); + for segment in rel.split('/') { + match segment { + "" | "." => {} + ".." => { + parts.pop()?; + } + other => parts.push(other), + } + } + let class_name = parts.last().copied().filter(|n| !n.is_empty())?; + let want = norm(&format!("{}.cfc", parts.join("/"))); + let mut found: Vec> = candidates(class_name) + .into_iter() + .filter(|candidate| norm(&candidate.file_path) == want) + .collect(); + found.sort_by(|a, b| a.id.cmp(&b.id)); + let target = found.into_iter().next()?; + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: target.id.clone(), + confidence: 0.95, + resolved_by: ResolvedBy::FilePath, + }); + } + let segments: Vec<&str> = name + .split('.') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + if segments.len() < 2 { + return None; + } + let class_name = segments[segments.len() - 1]; + let directories = &segments[..segments.len() - 1]; + let mut best: Option> = None; + let mut best_score = 0usize; + let mut tie = false; + let mut all = candidates(class_name); + all.sort_by(|a, b| { + (&a.file_path, a.start_line, &a.id).cmp(&(&b.file_path, b.start_line, &b.id)) + }); + for candidate in all { + let score = corroboration(directories, &candidate.file_path); + if score > best_score { + best = Some(candidate); + best_score = score; + tie = false; + } else if score == best_score && score > 0 { + tie = true; + } + } + if best_score == 0 || tie { + return None; + } + let best = best?; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: best.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::QualifiedName, + }) +} + +/// How many of a dotted component path's directories `file_path`'s own +/// directories corroborate, right to left and in any case (upstream #1154): +/// `system/web/Controller.cfc` corroborates two of `coldbox.system.web`. +fn corroboration(directories: &[&str], file_path: &str) -> usize { + let path = file_path.replace('\\', "/"); + let mut dirs: Vec<&str> = path.split('/').collect(); + dirs.pop(); + let mut score = 0usize; + while score < directories.len() + && score < dirs.len() + && directories[directories.len() - 1 - score] + .eq_ignore_ascii_case(dirs[dirs.len() - 1 - score]) + { + score += 1; + } + score +} + +/// What a call on a typed CFML receiver reaches ([`typed_receiver_call`]). +#[derive(Debug)] +pub(crate) enum TypedReceiverCall { + /// The method of the receiver's type, or of a supertype, the call means. + Method(Arc), + /// Components of the type's name in several files declare the method and + /// nothing written tells which one the receiver is. + Ambiguous, + /// Neither the type nor a supertype declares the method. + Missing, +} + +/// The method a call on a CFML receiver reaches when its declaration writes +/// its type as `written` (upstream #1155): `svc.save()` after `var svc = new +/// a.UserService()`, `createObject("component", "a.UserService")` or a +/// ``. +/// +/// Upstream reduces the written type to its last segment and, when +/// components of that name in several files declare the method, takes the +/// first. Here (KEEP-RUST, fail closed) the component is told apart the way +/// CFML finds it: a dotted path by the directories its file corroborates, +/// right to left and in any case, as an `extends` path is (#1154); a bare +/// name by the call's own directory, which CFML searches first. No +/// corroboration, a tie, or namesakes of a supertype (reached by its name +/// alone) are [`TypedReceiverCall::Ambiguous`]. A method in one file only is +/// the type's whatever the path says, as upstream has it. +pub(crate) fn typed_receiver_call( + written: &str, + method_name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> TypedReceiverCall { + let segments: Vec<&str> = written + .split('.') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + let Some((type_name, directories)) = segments.split_last() else { + return TypedReceiverCall::Missing; + }; + typed_method_on(type_name, directories, method_name, reference, context, 0) +} + +/// [`typed_receiver_call`] on the type `type_name` written under +/// `directories`, walking supertypes as `resolve_method_on_type` does. +fn typed_method_on( + type_name: &str, + directories: &[&str], + method_name: &str, + reference: &RefView, + context: &dyn ResolutionContext, + depth: u32, +) -> TypedReceiverCall { + let want = format!("{type_name}::{method_name}"); + let nested = format!("::{want}"); + let matches: Vec> = context + .get_nodes_by_name_shared(method_name) + .into_iter() + .filter(|m| { + m.kind == NodeKind::Method + && crate::name_matcher::same_language_family(m.language, reference.language) + && (m.qualified_name == want || m.qualified_name.ends_with(&nested)) + }) + .collect(); + if matches.is_empty() { + if depth < 4 { + for supertype in context.get_supertypes(type_name, reference.language) { + match typed_method_on(&supertype, &[], method_name, reference, context, depth + 1) { + TypedReceiverCall::Missing => {} + reached => return reached, + } + } + } + return TypedReceiverCall::Missing; + } + let files: BTreeSet<&str> = matches.iter().map(|m| m.file_path.as_str()).collect(); + if files.len() == 1 { + return TypedReceiverCall::Method(Arc::clone(&matches[0])); + } + if depth > 0 { + return TypedReceiverCall::Ambiguous; + } + let chosen = if directories.is_empty() { + let here = directory_of(&reference.file_path); + let local: Vec<&str> = files + .iter() + .copied() + .filter(|file| directory_of(file) == here) + .collect(); + match local.as_slice() { + [only] => Some(*only), + _ => None, + } + } else { + let mut best: Option<&str> = None; + let mut best_score = 0usize; + let mut tie = false; + for file in &files { + let score = corroboration(directories, file); + if score > best_score { + best = Some(file); + best_score = score; + tie = false; + } else if score == best_score && score > 0 { + tie = true; + } + } + best.filter(|_| !tie) + }; + match chosen.and_then(|file| matches.iter().find(|m| m.file_path == file)) { + Some(method) => TypedReceiverCall::Method(Arc::clone(method)), + None => TypedReceiverCall::Ambiguous, + } +} + +/// `name` without a trailing `.cfc` (any case). +fn strip_cfc_suffix(name: &str) -> &str { + if is_component_file(name) { + &name[..name.len() - 4] + } else { + name + } +} + +/// Key prefix of the CFML component rows (W4-CF-01): one `project_metadata` +/// row per `.cfc` file, written by every full index and migration and kept +/// current by sync. +pub const HEAD_ROW_KEY_PREFIX: &str = "cfml_heads:"; + +/// The `project_metadata` key of `file_path`'s component row. +pub fn head_row_key(file_path: &str) -> String { + format!("{HEAD_ROW_KEY_PREFIX}{file_path}") +} + +/// What the `extends` chain reads of a `.cfc` file: the classes it declares, +/// lowercased (the names a written path resolves to it by), and what its text +/// extends, as [`extends_of`] reads it. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeadRow { + pub components: Vec, + pub extends: Option, +} + +impl HeadRow { + /// The row of a `.cfc` file whose text is `source` and whose nodes are + /// `nodes`. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let components: BTreeSet = nodes + .iter() + .filter(|node| node.kind == NodeKind::Class) + .map(|node| node.name.to_ascii_lowercase()) + .collect(); + Self { + components: components.into_iter().collect(), + extends: extends_of(source), + } + } + + /// Nothing for the chain to read: no class and no `extends`. + pub fn is_empty(&self) -> bool { + self.components.is_empty() && self.extends.is_none() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("cfml head row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +/// The lowercased last segment of a written component path, the name +/// [`component_files`] looks it up by. +pub fn extended_name(dotted: &str) -> Option { + dotted + .replace('/', ".") + .split('.') + .rfind(|s| !s.is_empty()) + .map(str::to_ascii_lowercase) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extends_is_read_before_the_first_function_or_from_the_whole_text() { + assert_eq!( + extends_of("component extends=\"app.system.Base\" {\n function index() {}\n}\n") + .as_deref(), + Some("app.system.Base") + ); + assert_eq!( + extends_of("\n\n") + .as_deref(), + Some("Base") + ); + // After the first function it is not the component's. + assert_eq!( + extends_of("component {\n function x() { var y = { extends = \"Other\" }; }\n}\n"), + None + ); + // A text opening with `function`, or with none, is read whole (`0 || + // text.length` and `-1 >>> 0`). + assert_eq!( + extends_of("function x() {}\ncomponent extends=\"Late\" {}\n").as_deref(), + Some("Late") + ); + assert_eq!( + extends_of("component extends = models/Base {}\n").as_deref(), + Some("models/Base") + ); + // `\bfunction\b` is a whole word: `functional` does not cut the head. + assert_eq!( + extends_of("// functional\ncomponent extends=\"Base\" {}\n").as_deref(), + Some("Base") + ); + } + + #[test] + fn component_files_and_names_are_read_case_insensitively() { + assert!(is_component_file("system/Handler.cfc")); + assert!(is_component_file("views/Main.CFC")); + assert!(!is_component_file("views/main.cfm")); + assert_eq!(extended_name("app.system.Base").as_deref(), Some("base")); + assert_eq!(extended_name("models/User").as_deref(), Some("user")); + assert_eq!(extended_name(".."), None); + assert_eq!(directory_of("a/b/C.cfc"), "a/b/"); + assert_eq!(directory_of("C.cfc"), ""); + } + + #[test] + fn a_bare_cfml_name_has_no_receiver() { + assert!(is_bare_name("now")); + assert!(is_bare_name("_init2")); + assert!(!is_bare_name("server.keyExists")); + assert!(!is_bare_name("empty?")); + assert!(!is_bare_name("9x")); + } + + #[test] + fn head_rows_round_trip() { + let row = HeadRow { + components: vec!["handler".to_string()], + extends: Some("app.system.Base".to_string()), + }; + assert_eq!(HeadRow::decode(&row.encode()), Some(row)); + assert!(HeadRow::default().is_empty()); + } +} diff --git a/crates/codegraph-resolve/src/class_heads.rs b/crates/codegraph-resolve/src/class_heads.rs new file mode 100644 index 00000000..427088cd --- /dev/null +++ b/crates/codegraph-resolve/src/class_heads.rs @@ -0,0 +1,760 @@ +//! The supertypes a class declaration names in its own head, read from source +//! (upstream #2180 `declaredSupertypes` and the per-language head readers it +//! uses). Source is read rather than the graph's `extends` edges because a +//! first resolution pass has no edges yet, and a sync must see what a fresh +//! index sees. + +use std::sync::OnceLock; + +use codegraph_core::types::{Language, Node, NodeKind}; +use regex::Regex; + +use crate::source_facts::SourceFacts; +use crate::types::ResolutionContext; + +/// Lines `start_line - 1 .. start_line - 1 + count` of `node`'s file joined +/// by `separator`, clamped to the file; empty when the file is unreadable. +fn head_lines( + node: &Node, + count: usize, + separator: &str, + context: &dyn ResolutionContext, +) -> String { + let Some(facts) = context.source_facts(&node.file_path) else { + return String::new(); + }; + let start = usize::try_from(node.start_line) + .unwrap_or(1) + .saturating_sub(1); + let end = start.saturating_add(count).min(facts.line_count()); + if start >= end { + return String::new(); + } + facts.join_lines(start, end, separator) +} + +/// Lines `start_line - 1 .. start_line - 1 + count` of `node` in `facts` +/// joined by `separator`, clamped to the file ([`head_lines`] over facts +/// already read). +fn facts_lines(facts: &SourceFacts, node: &Node, count: usize, separator: &str) -> String { + let start = usize::try_from(node.start_line) + .unwrap_or(1) + .saturating_sub(1); + let end = start.saturating_add(count).min(facts.line_count()); + if start >= end { + return String::new(); + } + facts.join_lines(start, end, separator) +} + +/// `text` with `/* … */` and `// …` comments blanked. +fn strip_c_comments(text: &str) -> String { + static BLOCK: OnceLock = OnceLock::new(); + static LINE: OnceLock = OnceLock::new(); + let block = BLOCK.get_or_init(|| Regex::new(r"(?s)/\*.*?\*/").expect("block comment")); + let line = LINE.get_or_init(|| Regex::new(r"//[^\n]*").expect("line comment")); + line.replace_all(&block.replace_all(text, " "), " ") + .into_owned() +} + +/// Whether [`declared_supertypes`] of a `language` declaration reads only +/// that declaration's own head, so a class-head row's entry for it +/// ([`declaration_heads`]) is its answer: the Java family and the +/// three-line heads, not the languages that gather a name's declarations. +pub fn reads_own_head(language: Language) -> bool { + matches!( + language, + Language::Java + | Language::CSharp + | Language::Kotlin + | Language::Pascal + | Language::Python + | Language::Ruby + | Language::Php + | Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx + ) +} + +/// `project_metadata` key prefix of a file's class-head row (W5-07). +pub const ROW_KEY_PREFIX: &str = "class_heads:"; + +/// The `project_metadata` key of `path`'s class-head row. +pub fn row_key(path: &str) -> String { + format!("{ROW_KEY_PREFIX}{path}") +} + +/// One type declaration of a file: the supertypes its own head writes +/// ([`declaration_heads`]), as a sync's class-head row keeps it (W5-07). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HeadEntry { + pub language: Language, + pub name: String, + pub heads: Vec, +} + +impl Ord for HeadEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (self.language.as_str(), &self.name, &self.heads).cmp(&( + other.language.as_str(), + &other.name, + &other.heads, + )) + } +} + +impl PartialOrd for HeadEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// A file's class-head row: its entries, sorted. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HeadsRow { + pub entries: Vec, +} + +impl HeadsRow { + pub fn encode(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +/// The supertypes `class` names in its own head: Pascal `TX = class(TBase)`, +/// Python `class X(Base):`, Ruby `class X < Base`, PHP / TS / JS +/// `extends Base`, and the Java-family, Dart, Swift, Objective-C and Scala +/// heads. +pub(crate) fn declared_supertypes(class: &Node, context: &dyn ResolutionContext) -> Vec { + // A declaration whose own head is what it reads: the sync's row of its + // unchanged file holds that answer already (W5-08). + if reads_own_head(class.language) + && let Some(heads) = context.recorded_heads(class) + { + return heads; + } + match class.language { + Language::Java | Language::CSharp | Language::Kotlin => { + return class_head_supertypes(class, context); + } + Language::Dart => return dart_supertypes_of(&class.name, context), + Language::Swift => return swift_supertypes_of(&class.name, context), + Language::ObjC => { + return crate::objc_scope::objc_supertypes_of(&class.name, context).to_vec(); + } + Language::Scala => return scala_supertypes_of(&class.name, context), + _ => {} + } + three_line_supertypes(class.language, &head_lines(class, 3, " ", context)) +} + +/// The supertypes the first three lines of a Pascal, Python, Ruby, PHP or +/// TS/JS type declaration, `head`, name. +fn three_line_supertypes(language: Language, head: &str) -> Vec { + static PASCAL: OnceLock = OnceLock::new(); + static PYTHON: OnceLock = OnceLock::new(); + static PYTHON_KEYWORD: OnceLock = OnceLock::new(); + static RUBY: OnceLock = OnceLock::new(); + static EXTENDS: OnceLock = OnceLock::new(); + let clause = match language { + Language::Pascal => PASCAL + .get_or_init(|| Regex::new(r"(?i)=\s*class\s*\(([^)]*)\)").expect("pascal head")) + .captures(head) + .map(|c| c[1].to_string()), + Language::Python => PYTHON + .get_or_init(|| Regex::new(r"\bclass\s+\w+\s*\(([^)]*)\)").expect("python head")) + .captures(head) + .map(|c| { + PYTHON_KEYWORD + .get_or_init(|| Regex::new(r"\w+\s*=\s*[\w.]+").expect("python keyword")) + .replace_all(&c[1], "") + .into_owned() + }), + Language::Ruby => RUBY + .get_or_init(|| Regex::new(r"\bclass\s+[\w:]+\s*<\s*([\w:]+)").expect("ruby head")) + .captures(head) + .map(|c| c[1].to_string()), + Language::Php + | Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx => EXTENDS + .get_or_init(|| Regex::new(r"\bextends\s+([\w.\\]+)").expect("extends head")) + .captures(head) + .map(|c| c[1].to_string()), + _ => None, + }; + clause.map(|clause| head_names(&clause)).unwrap_or_default() +} + +/// A node an S1 walk starts from or steps to in the languages whose heads it +/// reads per declaration: a class, struct, union or interface. +fn is_method_owner_kind(node: &Node) -> bool { + matches!( + node.kind, + NodeKind::Class | NodeKind::Struct | NodeKind::Union | NodeKind::Interface + ) +} + +/// The supertypes `decl` writes in its own head: what one declaration adds +/// to [`declared_supertypes`] of its type, read from its own file's +/// `facts`, or `None` for a node no such read covers (the sync's +/// `class_heads` rows hold these, W5-07). In the Java-family, Pascal, +/// Python, Ruby, PHP and TS/JS heads a class, struct, union or interface is +/// read as itself; Dart, Swift, Objective-C and Scala read every declaration +/// of the type's name, one of which `decl` is, and [`name_supertypes`] adds +/// what the name alone brings. +pub fn declaration_heads(facts: &SourceFacts, decl: &Node) -> Option> { + match decl.language { + Language::Java | Language::CSharp | Language::Kotlin => { + is_method_owner_kind(decl).then(|| class_head_supertypes_in(facts, decl)) + } + Language::Dart => is_dart_type(decl).then(|| dart_declaration_supertypes(facts, decl)), + Language::Swift => crate::swift_scope::is_swift_type_kind(decl.kind) + .then(|| swift_head_in(facts, decl).supers), + Language::ObjC => (decl.kind == NodeKind::Class).then(|| { + crate::objc_scope::written_superclass_of(facts, decl) + .into_iter() + .collect() + }), + Language::Scala => is_scala_type(decl).then(|| scala_declaration_supertypes(facts, decl)), + Language::Pascal + | Language::Python + | Language::Ruby + | Language::Php + | Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx => is_method_owner_kind(decl) + .then(|| three_line_supertypes(decl.language, &facts_lines(facts, decl, 3, " "))), + _ => None, + } +} + +/// What [`declared_supertypes`] reads for a `language` type named `name` +/// beyond its declarations' own heads: a Swift standard collection's +/// refinements or a UIKit / AppKit class's superclass, and for +/// Objective-C that superclass when no declaration writes one +/// (`written` is false). +pub fn name_supertypes(language: Language, name: &str, written: bool) -> Vec { + match language { + Language::Swift => match crate::swift_scope::swift_std_supers(name) { + Some(supers) => supers.iter().map(|s| (*s).to_string()).collect(), + None => crate::swift_scope::objc_system_super(name) + .map(str::to_string) + .into_iter() + .collect(), + }, + Language::ObjC if !written => crate::objc_scope::system_superclass(name) + .map(str::to_string) + .into_iter() + .collect(), + _ => Vec::new(), + } +} + +/// The simple names in a supertype clause: each `[A-Za-z_][\w.:\\]*` by its +/// last `::` / `.` / `\` segment, without `metaclass` and `object`. +fn head_names(text: &str) -> Vec { + static NAME: OnceLock = OnceLock::new(); + NAME.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_.:\\]*").expect("head name")) + .find_iter(text) + .filter_map(|m| { + m.as_str() + .rsplit([':', '.', '\\']) + .next() + .filter(|last| !last.is_empty() && *last != "metaclass" && *last != "object") + .map(str::to_string) + }) + .collect() +} + +/// The simple names a Java / C# / Kotlin class declaration's head extends or +/// implements (upstream `classHeadSupertypes`): the head up to its body at +/// type-argument and parenthesis depth 0, then the `extends` / `implements` +/// clause or the part after a `:`, without a C# `where` constraint. +pub(crate) fn class_head_supertypes(class: &Node, context: &dyn ResolutionContext) -> Vec { + head_supertypes(&head_lines(class, 9, " ", context)) +} + +/// [`class_head_supertypes`] of the declaration starting on `class`'s line of +/// a file whose facts are `facts` (for the sync's Kotlin surface rows, which +/// read the same heads). +pub(crate) fn class_head_supertypes_in( + facts: &crate::source_facts::SourceFacts, + class: &Node, +) -> Vec { + let start = usize::try_from(class.start_line) + .unwrap_or(1) + .saturating_sub(1); + let end = start.saturating_add(9).min(facts.line_count()); + if start >= end { + return head_supertypes(""); + } + head_supertypes(&facts.join_lines(start, end, " ")) +} + +/// The supertypes the joined head lines `joined` name. +fn head_supertypes(joined: &str) -> Vec { + static COMMENTS: OnceLock = OnceLock::new(); + static KEYWORD_CLAUSE: OnceLock = OnceLock::new(); + static COLON_CLAUSE: OnceLock = OnceLock::new(); + static WHERE: OnceLock = OnceLock::new(); + let joined = COMMENTS + .get_or_init(|| Regex::new(r"//[^\n]*|/\*.*?\*/").expect("head comments")) + .replace_all(joined, " "); + let mut depth = 0usize; + let mut head = String::new(); + for ch in joined.chars() { + if ch == '{' && depth == 0 { + break; + } + match ch { + '<' | '(' => depth += 1, + '>' | ')' => depth = depth.saturating_sub(1), + _ if depth == 0 => head.push(ch), + _ => {} + } + } + let clause = KEYWORD_CLAUSE + .get_or_init(|| Regex::new(r"(?s)\b(?:extends|implements)\b(.*)$").expect("keyword clause")) + .captures(&head) + .or_else(|| { + COLON_CLAUSE + .get_or_init(|| { + Regex::new(r"(?s)\b(?:class|interface|struct|record|object)\s+\w+[^:]*:(.*)$") + .expect("colon clause") + }) + .captures(&head) + }) + .map(|c| c[1].to_string()) + .unwrap_or_default(); + let clause = WHERE + .get_or_init(|| Regex::new(r"(?s)\bwhere\b.*$").expect("where clause")) + .replace(&clause, ""); + clause_type_names(&clause) +} + +/// `([A-Z]\w*)\s*(?=,|$|\bimplements\b)` over `clause`: a capitalized word +/// followed by a comma, the end, or the word `implements`. +fn clause_type_names(clause: &str) -> Vec { + let bytes = clause.as_bytes(); + let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_'; + let mut names = Vec::new(); + let mut at = 0; + while at < bytes.len() { + if !bytes[at].is_ascii_uppercase() { + at += 1; + continue; + } + let mut end = at; + while end < bytes.len() && is_word(bytes[end]) { + end += 1; + } + let mut next = end; + while next < bytes.len() && bytes[next].is_ascii_whitespace() { + next += 1; + } + let rest = &clause[next..]; + let implements = next > end + && rest.starts_with("implements") + && !rest + .as_bytes() + .get("implements".len()) + .copied() + .is_some_and(is_word); + if next == bytes.len() || rest.starts_with(',') || implements { + names.push(clause[at..end].to_string()); + at = next; + } else { + at += 1; + } + } + names +} + +/// Upstream `DART_TYPE_KINDS`. +fn is_dart_type(node: &Node) -> bool { + node.language == Language::Dart + && matches!( + node.kind, + NodeKind::Class + | NodeKind::Interface + | NodeKind::Enum + | NodeKind::Struct + | NodeKind::Trait + ) +} + +/// The simple names a Dart type's declarations extend, mix in, implement or +/// sit `on` (upstream `dartSupertypesOf` / `dartHeadOf`). +fn dart_supertypes_of(type_name: &str, context: &dyn ResolutionContext) -> Vec { + let mut names = Vec::new(); + for decl in context.get_nodes_by_name_shared(type_name) { + if !is_dart_type(&decl) { + continue; + } + if let Some(facts) = context.source_facts(&decl.file_path) { + names.extend(dart_declaration_supertypes(&facts, &decl)); + } + } + names +} + +/// The simple names one Dart type declaration extends, mixes in, implements +/// or sits `on`, read from its own head (the per-declaration part of +/// [`dart_supertypes_of`]). +fn dart_declaration_supertypes(facts: &SourceFacts, decl: &Node) -> Vec { + static KEYWORD: OnceLock = OnceLock::new(); + static CLAUSE: OnceLock = OnceLock::new(); + static WORD: OnceLock = OnceLock::new(); + let keyword = KEYWORD + .get_or_init(|| Regex::new(r"\b(?:class|mixin|extension|enum)\b").expect("dart keyword")); + let clause_re = CLAUSE.get_or_init(|| { + Regex::new(r"(?s)(?:\b(?:extends|with|implements|on)\b|=)(.*)$").expect("dart clause") + }); + let word = WORD.get_or_init(|| Regex::new(r"[A-Za-z_$][A-Za-z0-9_$]*").expect("dart word")); + let span = usize::try_from(decl.end_line - decl.start_line + 1) + .unwrap_or(1) + .clamp(1, 41); + let text = strip_c_comments(&facts_lines(facts, decl, span, "\n")); + let mut depth = 0usize; + let mut flat = String::new(); + for ch in text.chars() { + match ch { + '<' => depth += 1, + '>' => depth = depth.saturating_sub(1), + '{' | ';' if depth == 0 => break, + _ if depth == 0 => flat.push(ch), + _ => {} + } + } + let head = keyword + .find(&flat) + .map_or(flat.as_str(), |m| &flat[m.start()..]); + let clause = clause_re + .captures(head) + .map(|c| c[1].to_string()) + .unwrap_or_default(); + word.find_iter(&clause) + .map(|m| m.as_str()) + .filter(|w| !matches!(*w, "extends" | "with" | "implements" | "on")) + .map(str::to_string) + .collect() +} + +/// A Swift declaration's head, read up to its body with comments, attribute +/// arguments and generic parameters dropped (upstream `swiftHeadOf`): +/// `@objc(ChartViewBase) open class ChartViewBase: NSUIView, +/// ChartDataProvider where T: Equatable {` names `NSUIView`, +/// `ChartDataProvider`; and whether the declaration is an `extension`. +pub(crate) struct SwiftHead { + pub(crate) supers: Vec, + pub(crate) extension: bool, +} + +pub(crate) fn swift_head_of(decl: &Node, context: &dyn ResolutionContext) -> SwiftHead { + match context.source_facts(&decl.file_path) { + Some(facts) => swift_head_in(&facts, decl), + None => SwiftHead { + supers: Vec::new(), + extension: false, + }, + } +} + +/// [`swift_head_of`] over a file's facts. +pub(crate) fn swift_head_in(facts: &crate::source_facts::SourceFacts, decl: &Node) -> SwiftHead { + static HEAD: OnceLock = OnceLock::new(); + static CLAUSE: OnceLock = OnceLock::new(); + static LAST_WORD: OnceLock = OnceLock::new(); + let head_re = HEAD.get_or_init(|| { + Regex::new(r"(?s)\b(class|struct|enum|protocol|extension|actor)\s+[\w.]+\s*(.*)$") + .expect("swift head") + }); + let clause_re = + CLAUSE.get_or_init(|| Regex::new(r"(?s)^:(.*?)(?:\bwhere\b|$)").expect("swift clause")); + let last_word = + LAST_WORD.get_or_init(|| Regex::new(r"([A-Za-z_]\w*)\s*$").expect("swift word")); + let span = usize::try_from(decl.end_line - decl.start_line + 1) + .unwrap_or(1) + .clamp(1, 21); + let start = usize::try_from(decl.start_line) + .unwrap_or(1) + .saturating_sub(1); + let end = start.saturating_add(span).min(facts.line_count()); + let lines = if start < end { + facts.join_lines(start, end, "\n") + } else { + String::new() + }; + let text = strip_c_comments(&lines); + let mut depth = 0usize; + let mut flat = String::new(); + for ch in text.chars() { + match ch { + '<' | '(' => depth += 1, + '>' | ')' => depth = depth.saturating_sub(1), + '{' if depth == 0 => break, + _ if depth == 0 => flat.push(ch), + _ => {} + } + } + let Some(head) = head_re.captures(&flat) else { + return SwiftHead { + supers: Vec::new(), + extension: false, + }; + }; + let rest = head[2].trim().to_string(); + let clause = clause_re + .captures(&rest) + .map(|c| c[1].to_string()) + .unwrap_or_default(); + SwiftHead { + supers: clause + .split(',') + .filter_map(|item| last_word.captures(item.trim()).map(|c| c[1].to_string())) + .collect(), + extension: &head[1] == "extension", + } +} + +/// What a Swift type's declarations inherit or conform to, the standard +/// collections' refinements and UIKit / AppKit ancestry first (upstream +/// `swiftDeclOf(…).supers`, #2170). +fn swift_supertypes_of(type_name: &str, context: &dyn ResolutionContext) -> Vec { + crate::swift_scope::swift_decl_of(type_name, context) + .supers + .clone() +} + +/// Upstream `SCALA_TYPE_KINDS`. +fn is_scala_type(node: &Node) -> bool { + node.language == Language::Scala + && matches!( + node.kind, + NodeKind::Class + | NodeKind::Trait + | NodeKind::Interface + | NodeKind::Enum + | NodeKind::Struct + | NodeKind::Module + | NodeKind::Namespace + ) +} + +/// The simple names a Scala type's declarations extend or mix in (upstream +/// `scalaSupertypesOf`). +pub(crate) fn scala_supertypes_of(type_name: &str, context: &dyn ResolutionContext) -> Vec { + let mut names = Vec::new(); + for decl in context.get_nodes_by_name_shared(type_name) { + if !is_scala_type(&decl) { + continue; + } + if let Some(facts) = context.source_facts(&decl.file_path) { + names.extend(scala_declaration_supertypes(&facts, &decl)); + } + } + names +} + +/// The simple names one Scala type declaration extends or mixes in, read +/// from the 13 lines from its start up to its body or `=` (the per-declaration +/// part of upstream `scalaSupertypesOf`). +pub(crate) fn scala_declaration_supertypes( + facts: &crate::source_facts::SourceFacts, + decl: &Node, +) -> Vec { + static CLAUSE: OnceLock = OnceLock::new(); + static NAME: OnceLock = OnceLock::new(); + let clause_re = + CLAUSE.get_or_init(|| Regex::new(r"(?s)\bextends\b(.*)$").expect("scala clause")); + let name_re = NAME.get_or_init(|| Regex::new(r"[A-Za-z_][\w.]*").expect("scala name")); + let start = usize::try_from(decl.start_line) + .unwrap_or(1) + .saturating_sub(1); + let end = start.saturating_add(13).min(facts.line_count()); + let text = if start < end { + facts.join_lines(start, end, " ") + } else { + String::new() + }; + let mut depth = 0usize; + let mut flat = String::new(); + for ch in text.chars() { + match ch { + '[' | '(' => depth += 1, + ']' | ')' => depth = depth.saturating_sub(1), + '{' | '=' if depth == 0 => break, + _ if depth == 0 => flat.push(ch), + _ => {} + } + } + let clause = clause_re + .captures(&flat) + .map(|c| c[1].to_string()) + .unwrap_or_default(); + name_re + .find_iter(&clause) + .filter_map(|m| { + let simple = m.as_str().rsplit('.').next().unwrap_or_default(); + (simple != "with" && simple != "derives" && simple != decl.name) + .then(|| simple.to_string()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{clause_type_names, declaration_heads, name_supertypes}; + use crate::source_facts::SourceFacts; + use codegraph_core::types::{Language, Node, NodeKind}; + + fn decl(language: Language, kind: NodeKind, name: &str, lines: (i64, i64)) -> Node { + Node { + id: format!("{}:{name}", kind.as_str()), + kind, + name: name.to_string(), + qualified_name: name.to_string(), + file_path: "f".to_string(), + language, + start_line: lines.0, + end_line: lines.1, + start_column: 0, + end_column: 0, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + } + } + + fn heads(text: &str, node: &Node) -> Option> { + declaration_heads(&SourceFacts::new(text.into()), node) + } + + /// W5-07: each language's head is read as `declared_supertypes` reads + /// it, one declaration at a time: the TS/JS head's first three lines, a + /// Java head up to its body, a Dart type's `extends`/`with`/ + /// `implements`, a Swift extension's conformances, an Objective-C + /// `@interface`'s superclass, a Scala object's `extends`; a node no walk + /// reads has none. + #[test] + fn declaration_heads_read_each_head_as_the_walk_does() { + use Language::*; + assert_eq!( + heads( + "class User(Base, metaclass=Meta):\n pass\n", + &decl(Python, NodeKind::Class, "User", (1, 2)) + ), + Some(vec!["Base".to_string()]) + ); + // Past the third line a TS/JS head is not read. + assert_eq!( + heads( + "export class User\n\n\nextends Base {}\n", + &decl(TypeScript, NodeKind::Class, "User", (1, 4)) + ), + Some(Vec::new()) + ); + assert_eq!( + heads( + "public class User\n extends Base\n implements Runnable {\n}\n", + &decl(Java, NodeKind::Class, "User", (1, 4)) + ), + Some(vec!["Base".to_string(), "Runnable".to_string()]) + ); + assert_eq!( + heads( + "class User extends Base with Mixin implements Api {}\n", + &decl(Dart, NodeKind::Class, "User", (1, 1)) + ), + Some(vec![ + "Base".to_string(), + "Mixin".to_string(), + "Api".to_string() + ]) + ); + assert_eq!( + heads( + "extension User: Codable {\n}\n", + &decl(Swift, NodeKind::Class, "User", (1, 2)) + ), + Some(vec!["Codable".to_string()]) + ); + assert_eq!( + heads( + "@interface User : Base \n@end\n", + &decl(ObjC, NodeKind::Class, "User", (1, 2)) + ), + Some(vec!["Base".to_string()]) + ); + assert_eq!( + heads( + "object User extends Base with Api\n", + &decl(Scala, NodeKind::Module, "User", (1, 1)) + ), + Some(vec!["Base".to_string(), "Api".to_string()]) + ); + assert_eq!( + heads( + "def make():\n pass\n", + &decl(Python, NodeKind::Function, "make", (1, 2)) + ), + None + ); + assert_eq!( + heads( + "package main\n", + &decl(Go, NodeKind::Struct, "User", (1, 1)) + ), + None + ); + } + + /// W5-07: what a walk reads for a name beyond its declarations' heads: a + /// Swift standard collection's refinements, a UIKit class's superclass, + /// and for Objective-C that superclass only when no declaration writes + /// one. + #[test] + fn name_supertypes_add_what_the_name_alone_brings() { + assert_eq!( + name_supertypes(Language::Swift, "Collection", true), + ["Sequence"] + ); + assert_eq!( + name_supertypes(Language::Swift, "UIViewController", true), + ["UIResponder"] + ); + assert_eq!( + name_supertypes(Language::ObjC, "UIViewController", false), + ["UIResponder"] + ); + assert!(name_supertypes(Language::ObjC, "UIViewController", true).is_empty()); + assert!(name_supertypes(Language::Python, "Collection", false).is_empty()); + } + + #[test] + fn a_clause_names_each_type_before_a_comma_the_end_or_implements() { + assert_eq!( + clause_type_names(" Base implements Runnable, Comparable"), + ["Base", "Runnable", "Comparable"] + ); + assert_eq!(clause_type_names(" B , C"), ["B", "C"]); + assert!(clause_type_names(" Base other").is_empty()); + } +} diff --git a/crates/codegraph-resolve/src/context.rs b/crates/codegraph-resolve/src/context.rs index 8aa550d6..43929e5f 100644 --- a/crates/codegraph-resolve/src/context.rs +++ b/crates/codegraph-resolve/src/context.rs @@ -7,6 +7,7 @@ //! per-resolver LRU caches the upstream uses (`index.ts:217-250`). Resolution is sync. use crate::lru_cache::LruCache; +use crate::package_manifests::{ManifestScope, read_manifest_scope}; use crate::path_aliases::{AliasMap, load_project_aliases}; use crate::source_facts::SourceFacts; use crate::types::{GoModule, ImportMapping, ReExport, ResolutionContext}; @@ -34,6 +35,9 @@ pub struct StoreResolutionContext<'a> { project_root: String, max_file_size: u64, caches: RefCell, + /// The files whose class-head rows are not to be read, when the rows are + /// trusted at all ([`Self::with_recorded_class_heads`]). + recorded_heads_stale: Option>>, } struct Caches { @@ -45,18 +49,68 @@ struct Caches { name_cache: LruCache>>, lower_name_cache: LruCache>>, qualified_name_cache: LruCache>>, + /// Each file's functions and methods by qualified name (W5-08). + function_scopes_cache: LruCache>, + /// Each file's methods by owner and name (W5-08). + file_methods_cache: LruCache>, + /// Each file's class-head row, as read (W5-08). + head_rows: HashMap>>, + /// The name matcher's memos for the pass (W5-08). + name_matcher_tables: Option>, project_aliases: Option>, go_module: Option>, go_modules: Option>, + rust_crates: Option>, workspace_packages: Option>, + /// Each directory's package.json / Deno import-map scope for the pass. + manifest_scopes: HashMap>, all_files: Option>>, files_by_basename: Option>>>, /// Existence answers for the pass: import resolution probes every extension /// of a specifier from every importing file, and neither the borrowed store /// nor the tree changes within a pass (upstream `fileExistsMemo`). file_exists: HashMap, + /// The Python write index for the pass (upstream #2332). + python_write_index: Option>, + /// The C/C++ namespace tables for the pass (upstream #2176). + cpp_namespace_tables: Option>, + /// The Kotlin tables for the pass (upstream #2172). + kotlin_tables: Option>, + scala_tables: Option>, + csharp_tables: Option>, + member_tables: Option>, + ruby_tables: Option>, + php_tables: Option>, + lua_tables: Option>, + dart_tables: Option>, + dart_libraries: Option>, + /// The last few compiled pattern sets, most recent last + /// ([`ResolutionContext::compiled_patterns`]). + patterns: std::collections::VecDeque, + /// The last few nodes read by id, most recent last. + nodes_by_id: std::collections::VecDeque<(String, Option>)>, + cfml_tables: Option>, + swift_tables: Option>, + objc_tables: Option>, + go_tables: Option>, + solidity_tables: Option>, } +/// A compiled pattern set by its key. +type CompiledPatterns = ( + String, + Arc>, +); + +/// The most compiled pattern sets a store context keeps: a set takes a +/// megabyte or more (CFML's eight about 3.3 MB), so a few, enough for the +/// receivers one file's references write in turn. +const PATTERN_MEMO_LIMIT: usize = 8; + +/// The most nodes a store context keeps by id: the gates one reference passes +/// read its target's node in turn, each a query of its own otherwise. +const NODE_BY_ID_MEMO_LIMIT: usize = 8; + /// Bound on memoized existence answers; the memo restarts past it, as upstream. pub(crate) const FILE_EXISTS_MEMO_LIMIT: usize = 200_000; @@ -74,13 +128,37 @@ impl Caches { name_cache: LruCache::new(limit), lower_name_cache: LruCache::new(limit), qualified_name_cache: LruCache::new(limit), + function_scopes_cache: LruCache::new(limit), + file_methods_cache: LruCache::new(limit), + head_rows: HashMap::new(), + name_matcher_tables: None, project_aliases: None, go_module: None, go_modules: None, + rust_crates: None, workspace_packages: None, + manifest_scopes: HashMap::new(), all_files: None, files_by_basename: None, file_exists: HashMap::new(), + python_write_index: None, + cpp_namespace_tables: None, + kotlin_tables: None, + scala_tables: None, + csharp_tables: None, + member_tables: None, + ruby_tables: None, + php_tables: None, + lua_tables: None, + dart_tables: None, + dart_libraries: None, + patterns: std::collections::VecDeque::new(), + nodes_by_id: std::collections::VecDeque::new(), + cfml_tables: None, + swift_tables: None, + objc_tables: None, + go_tables: None, + solidity_tables: None, } } } @@ -93,6 +171,7 @@ impl<'a> StoreResolutionContext<'a> { project_root: project_root.into(), max_file_size: codegraph_core::config::DEFAULT_MAX_FILE_SIZE, caches: RefCell::new(Caches::new()), + recorded_heads_stale: None, } } @@ -104,6 +183,19 @@ impl<'a> StoreResolutionContext<'a> { self } + /// Trust the store's class-head rows (`class_heads:`) for every + /// file but `stale`, the files a sync changed since they were written + /// (W5-08): a declaration's own head is then read from its file's row, + /// when the row holds one answer for it, instead of the file. + #[must_use] + pub fn with_recorded_class_heads( + mut self, + stale: Option>>, + ) -> Self { + self.recorded_heads_stale = stale; + self + } + /// Drop all caches (`clearCaches`, `index.ts:313-324`). Called between /// resolution passes so the conformance pass sees freshly persisted edges. pub fn clear_caches(&self) { @@ -119,6 +211,24 @@ impl<'a> StoreResolutionContext<'a> { c.all_files = None; c.files_by_basename = None; c.file_exists.clear(); + c.python_write_index = None; + c.cpp_namespace_tables = None; + c.kotlin_tables = None; + c.scala_tables = None; + c.csharp_tables = None; + c.member_tables = None; + c.ruby_tables = None; + c.php_tables = None; + c.lua_tables = None; + c.dart_tables = None; + c.dart_libraries = None; + c.patterns.clear(); + c.nodes_by_id.clear(); + c.cfml_tables = None; + c.swift_tables = None; + c.objc_tables = None; + c.go_tables = None; + c.solidity_tables = None; } fn cached_all_files(&self) -> Arc> { @@ -175,7 +285,7 @@ impl ResolutionContext for StoreResolutionContext<'_> { fn get_nodes_in_file_shared(&self, file_path: &str) -> Vec> { let mut c = self.caches.borrow_mut(); - if let Some(cached) = c.node_cache.get(&file_path.to_string()) { + if let Some(cached) = c.node_cache.get(file_path) { return cached; } let result: Vec> = self @@ -198,7 +308,7 @@ impl ResolutionContext for StoreResolutionContext<'_> { fn get_nodes_by_name_shared(&self, name: &str) -> Vec> { let mut c = self.caches.borrow_mut(); - if let Some(cached) = c.name_cache.get(&name.to_string()) { + if let Some(cached) = c.name_cache.get(name) { return cached; } let mut result = self.store.nodes_by_name(name).unwrap_or_default(); @@ -217,7 +327,7 @@ impl ResolutionContext for StoreResolutionContext<'_> { fn get_nodes_by_qualified_name_shared(&self, qualified_name: &str) -> Vec> { let mut c = self.caches.borrow_mut(); - if let Some(cached) = c.qualified_name_cache.get(&qualified_name.to_string()) { + if let Some(cached) = c.qualified_name_cache.get(qualified_name) { return cached; } let mut result = self @@ -273,6 +383,15 @@ impl ResolutionContext for StoreResolutionContext<'_> { exists } + fn is_indexed_file(&self, file_path: &str) -> bool { + let normalized = file_path.replace('\\', "/"); + self.store + .file_by_path(&normalized) + .ok() + .flatten() + .is_some() + } + fn read_file(&self, file_path: &str) -> Option { self.read_file_shared(file_path) .map(|content| content.to_string()) @@ -281,7 +400,7 @@ impl ResolutionContext for StoreResolutionContext<'_> { fn read_file_shared(&self, file_path: &str) -> Option> { let mut c = self.caches.borrow_mut(); if c.file_cache.has(&file_path.to_string()) { - return c.file_cache.get(&file_path.to_string()).flatten(); + return c.file_cache.get(file_path).flatten(); } let full_path = Path::new(&self.project_root).join(file_path); let content: Option> = @@ -294,7 +413,7 @@ impl ResolutionContext for StoreResolutionContext<'_> { { let mut c = self.caches.borrow_mut(); if c.source_facts_cache.has(&file_path.to_string()) { - return c.source_facts_cache.get(&file_path.to_string()).flatten(); + return c.source_facts_cache.get(file_path).flatten(); } } let facts = self @@ -332,7 +451,7 @@ impl ResolutionContext for StoreResolutionContext<'_> { fn get_nodes_by_lower_name_shared(&self, lower_name: &str) -> Vec> { let mut c = self.caches.borrow_mut(); - if let Some(cached) = c.lower_name_cache.get(&lower_name.to_string()) { + if let Some(cached) = c.lower_name_cache.get(lower_name) { return cached; } let mut result = self @@ -347,7 +466,27 @@ impl ResolutionContext for StoreResolutionContext<'_> { } fn get_node_by_id(&self, id: &str) -> Option { - self.store.node_by_id(id).ok().flatten() + self.get_node_by_id_shared(id) + .map(|node| node.as_ref().clone()) + } + + fn get_node_by_id_shared(&self, id: &str) -> Option> { + if let Some((_, node)) = self + .caches + .borrow() + .nodes_by_id + .iter() + .find(|(key, _)| key == id) + { + return node.clone(); + } + let node = self.store.node_by_id(id).ok().flatten().map(Arc::new); + let mut c = self.caches.borrow_mut(); + if c.nodes_by_id.len() >= NODE_BY_ID_MEMO_LIMIT { + c.nodes_by_id.pop_front(); + } + c.nodes_by_id.push_back((id.to_string(), node.clone())); + node } fn get_supertypes(&self, type_name: &str, language: Language) -> Vec { @@ -376,6 +515,17 @@ impl ResolutionContext for StoreResolutionContext<'_> { } let mut supertypes: std::collections::BTreeSet = std::collections::BTreeSet::new(); for tn in &type_nodes { + // A Swift conformance to a type the project only extends + // (SwiftUI's `View`) resolves to nothing, an extension being no + // type, yet the members its extensions add are still the + // conformer's (upstream #2107). + if language == Language::Swift { + for name in crate::swift_type_visibility::extended_conformances(tn, self).iter() { + if name != type_name { + supertypes.insert(name.clone()); + } + } + } for edge_kind in [EdgeKind::Implements, EdgeKind::Extends] { let edges = self .store @@ -395,7 +545,7 @@ impl ResolutionContext for StoreResolutionContext<'_> { fn get_import_mappings(&self, file_path: &str, language: Language) -> Vec { let mut c = self.caches.borrow_mut(); - if let Some(cached) = c.import_mapping_cache.get(&file_path.to_string()) { + if let Some(cached) = c.import_mapping_cache.get(file_path) { return cached; } drop(c); @@ -429,6 +579,305 @@ impl ResolutionContext for StoreResolutionContext<'_> { c.workspace_packages.clone().flatten() } + fn python_write_index(&self) -> Arc { + if let Some(index) = self.caches.borrow().python_write_index.clone() { + return index; + } + let index = Arc::new(crate::python_globals::PythonWriteIndex::build(self)); + self.caches.borrow_mut().python_write_index = Some(Arc::clone(&index)); + index + } + + fn cpp_namespace_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .cpp_namespace_tables + .get_or_insert_with(|| Arc::new(crate::cpp_namespaces::CppNamespaceTables::new())), + ) + } + + fn kotlin_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .kotlin_tables + .get_or_insert_with(|| Arc::new(crate::kotlin_scope::KotlinTables::new())), + ) + } + + fn scala_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .scala_tables + .get_or_insert_with(|| Arc::new(crate::scala_scope::ScalaTables::new())), + ) + } + + fn csharp_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .csharp_tables + .get_or_insert_with(|| Arc::new(crate::csharp_types::CsharpTables::new())), + ) + } + + fn member_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .member_tables + .get_or_insert_with(|| Arc::new(crate::member_types::MemberTables::new())), + ) + } + + fn ruby_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .ruby_tables + .get_or_insert_with(|| Arc::new(crate::ruby_scope::RubyTables::new())), + ) + } + + fn php_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .php_tables + .get_or_insert_with(|| Arc::new(crate::php_scope::PhpTables::new())), + ) + } + + fn lua_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .lua_tables + .get_or_insert_with(|| Arc::new(crate::lua_scope::LuaTables::new())), + ) + } + + fn dart_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .dart_tables + .get_or_insert_with(|| Arc::new(crate::dart_scope::DartTables::new())), + ) + } + + fn dart_libraries(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .dart_libraries + .get_or_insert_with(|| Arc::new(crate::dart_libraries::DartLibraries::new())), + ) + } + + fn compiled_patterns( + &self, + key: &str, + build: &dyn Fn() -> Vec<(regex::Regex, crate::name_matcher::PatternCheck)>, + ) -> Arc> { + { + let mut c = self.caches.borrow_mut(); + if let Some(at) = c.patterns.iter().position(|(k, _)| k == key) + && let Some(entry) = c.patterns.remove(at) + { + let set = Arc::clone(&entry.1); + c.patterns.push_back(entry); + return set; + } + } + let set = Arc::new(build()); + let mut c = self.caches.borrow_mut(); + while c.patterns.len() >= PATTERN_MEMO_LIMIT { + c.patterns.pop_front(); + } + c.patterns.push_back((key.to_string(), Arc::clone(&set))); + set + } + + fn cfml_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .cfml_tables + .get_or_insert_with(|| Arc::new(crate::cfml_scope::CfmlTables::new())), + ) + } + + fn swift_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .swift_tables + .get_or_insert_with(|| Arc::new(crate::swift_type_visibility::SwiftTables::new())), + ) + } + + fn objc_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .objc_tables + .get_or_insert_with(|| Arc::new(crate::objc_scope::ObjcTables::new())), + ) + } + + fn go_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .go_tables + .get_or_insert_with(|| Arc::new(crate::go_packages::GoTables::new())), + ) + } + + fn solidity_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .solidity_tables + .get_or_insert_with(|| Arc::new(crate::solidity_scope::SolidityTables::new())), + ) + } + + fn enclosing_function_lines( + &self, + file_path: &str, + qualified: &str, + start: i64, + end: i64, + ) -> Option<(i64, i64)> { + self.file_callables(file_path) + .first_enclosing(qualified, start, end) + } + + fn file_callables(&self, file_path: &str) -> Arc { + let cached = self + .caches + .borrow_mut() + .function_scopes_cache + .get(file_path); + if let Some(callables) = cached { + return callables; + } + // Only the file's functions and methods, by start line: the rows the + // index keeps (W5-08). + let nodes: Vec> = self + .store + .callables_by_file_path(file_path) + .unwrap_or_default() + .into_iter() + .map(Arc::new) + .collect(); + let callables = Arc::new(crate::name_matcher::FileCallables::of(&nodes)); + self.caches + .borrow_mut() + .function_scopes_cache + .set(file_path.to_string(), Arc::clone(&callables)); + callables + } + + fn file_methods(&self, file_path: &str) -> Arc { + let cached = self.caches.borrow_mut().file_methods_cache.get(file_path); + if let Some(methods) = cached { + return methods; + } + // Only the file's methods, in the order its every node is listed + // (the same index walk): the rows the methods keep (W5-08). + let nodes: Vec> = self + .store + .methods_by_file_path(file_path) + .unwrap_or_default() + .into_iter() + .map(Arc::new) + .collect(); + let methods = Arc::new(crate::name_matcher::FileMethods::of(&nodes)); + self.caches + .borrow_mut() + .file_methods_cache + .set(file_path.to_string(), Arc::clone(&methods)); + methods + } + + fn recorded_heads(&self, class: &Node) -> Option> { + let stale = self.recorded_heads_stale.as_ref()?; + if stale.contains(&class.file_path) { + return None; + } + let cached = self + .caches + .borrow() + .head_rows + .get(&class.file_path) + .cloned(); + let row = match cached { + Some(row) => row, + None => { + let row = self + .store + .get_project_metadata(&crate::class_heads::row_key(&class.file_path)) + .ok() + .flatten() + .and_then(|value| crate::class_heads::HeadsRow::decode(&value)) + .map(Arc::new); + self.caches + .borrow_mut() + .head_rows + .insert(class.file_path.clone(), row.clone()); + row + } + }?; + // The declaration's entries: one answer, or none trusted (two + // declarations of the name whose heads differ are told apart only + // by reading the file). + let mut entries = row + .entries + .iter() + .filter(|entry| entry.language == class.language && entry.name == class.name); + let first = entries.next()?; + entries + .all(|entry| entry.heads == first.heads) + .then(|| first.heads.clone()) + } + + fn has_callable_named(&self, qualified: &str) -> bool { + self.store.has_callable_named(qualified).unwrap_or(true) + } + + fn method_files(&self, name: &str) -> Option>> { + Some(crate::name_matcher::method_files_of(name, self)) + } + + fn name_matcher_tables(&self) -> Arc { + Arc::clone( + self.caches + .borrow_mut() + .name_matcher_tables + .get_or_insert_with(|| Arc::new(crate::name_matcher::NameMatcherTables::new())), + ) + } + + fn manifest_scope(&self, dir: &str) -> Arc { + if let Some(scope) = self.caches.borrow().manifest_scopes.get(dir) { + return Arc::clone(scope); + } + // Read without holding the cache: the parent's scope and the + // manifests' text come through this context too. + let scope = Arc::new(read_manifest_scope(dir, self)); + self.caches + .borrow_mut() + .manifest_scopes + .insert(dir.to_string(), Arc::clone(&scope)); + scope + } + fn get_go_module(&self) -> Option { let mut c = self.caches.borrow_mut(); if c.go_module.is_none() { @@ -446,9 +895,21 @@ impl ResolutionContext for StoreResolutionContext<'_> { modules } + fn get_rust_crates(&self) -> Arc { + if let Some(cached) = self.caches.borrow().rust_crates.clone() { + return cached; + } + let crates = Arc::new(crate::rust_scope::load_rust_crates( + &self.project_root, + &self.get_all_files(), + )); + self.caches.borrow_mut().rust_crates = Some(Arc::clone(&crates)); + crates + } + fn get_re_exports(&self, file_path: &str, language: Language) -> Vec { let mut c = self.caches.borrow_mut(); - if let Some(cached) = c.re_export_cache.get(&file_path.to_string()) { + if let Some(cached) = c.re_export_cache.get(file_path) { return cached; } drop(c); @@ -831,6 +1292,94 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + /// W5-09b: the files a Lua `require` suffix names come from one scan of + /// the file list per pass, the same files in the same order as scanning + /// for each `require`, kept apart per suffix. + #[test] + fn lua_require_matches_are_the_scan_of_each_suffix() { + let root = temp_root("lua-require"); + let store = open_store(&root); + for path in [ + "a/b.lua", + "lib/a/b.lua", + "a/b/init.lua", + "xa/b.lua", + "b.lua", + "a/b.luau", + ] { + store.upsert_file(&file_record(path, 0)).unwrap(); + } + let ctx = StoreResolutionContext::new(&store, root.to_str().unwrap()); + let files = ctx.get_all_files(); + let scan = |suffix: &str| -> Vec { + files + .iter() + .filter(|file| file.as_str() == suffix || file.ends_with(&format!("/{suffix}"))) + .cloned() + .collect() + }; + for round in 0..2 { + for suffix in ["a/b.lua", "b.lua", "a/b/init.lua", "a/b.luau", "c.lua"] { + assert_eq!( + *crate::lua_scope::lua_require_matches(suffix, &ctx), + scan(suffix), + "{suffix}, round {round}" + ); + } + } + assert_eq!(scan("a/b.lua"), ["a/b.lua", "lib/a/b.lua"]); + std::fs::remove_dir_all(&root).ok(); + } + + /// W5-08: the store context answers a declaration's enclosing function + /// from its per-file index exactly as the qualified-name lookup does, + /// before and after the index is cached. + #[test] + fn store_context_encloses_as_the_qualified_name_lookup_does() { + let root = temp_root("enclosing"); + let mut store = open_store(&root); + store.upsert_file(&file_record("a.ts", 5)).unwrap(); + store.upsert_file(&file_record("b.ts", 1)).unwrap(); + let scoped = |id: &str, kind, qualified: &str, file: &str, lines: (i64, i64), col| { + let name = qualified.rsplit("::").next().unwrap_or(qualified); + let mut n = node(id, name, kind, file, lines.0, col); + n.qualified_name = qualified.to_string(); + n.end_line = lines.1; + n + }; + store + .upsert_nodes(&[ + scoped("f-a", NodeKind::Function, "f", "a.ts", (3, 20), 0), + scoped("f-b", NodeKind::Function, "f", "a.ts", (3, 20), 4), + scoped("f-outer", NodeKind::Function, "f", "a.ts", (2, 30), 0), + scoped("m", NodeKind::Method, "C::m", "a.ts", (5, 9), 2), + scoped("c", NodeKind::Class, "C", "a.ts", (4, 10), 0), + scoped("f-other", NodeKind::Function, "f", "b.ts", (1, 100), 0), + ]) + .unwrap(); + let ctx = StoreResolutionContext::new(&store, root.to_str().unwrap()); + for _ in 0..2 { + for (file, qualified, start, end) in [ + ("a.ts", "f", 6, 7), + ("a.ts", "f", 25, 26), + ("a.ts", "f", 31, 31), + ("a.ts", "C::m", 6, 6), + ("a.ts", "C", 6, 6), + ("b.ts", "f", 50, 50), + ("c.ts", "f", 1, 1), + ] { + assert_eq!( + ctx.enclosing_function_lines(file, qualified, start, end), + crate::name_matcher::enclosing_by_qualified_name( + &ctx, file, qualified, start, end + ), + "{file} {qualified} {start}..={end}" + ); + } + } + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn store_context_file_exists_and_read_file() { let root = temp_root("files"); @@ -848,6 +1397,10 @@ mod tests { assert!(ctx.file_exists("known.ts")); assert!(ctx.file_exists("ondisk.ts")); assert!(!ctx.file_exists("nowhere.ts")); + // Only the index's own files are indexed; the disk fallback is not. + assert!(ctx.is_indexed_file("known.ts")); + assert!(!ctx.is_indexed_file("ondisk.ts")); + assert!(!ctx.is_indexed_file("nowhere.ts")); assert!( !ctx.file_exists(&format!( "../{}/secret.ts", diff --git a/crates/codegraph-resolve/src/cpp_includers.rs b/crates/codegraph-resolve/src/cpp_includers.rs new file mode 100644 index 00000000..d1f937a4 --- /dev/null +++ b/crates/codegraph-resolve/src/cpp_includers.rs @@ -0,0 +1,180 @@ +//! Which files include a C or C++ file: the files whose `#include` names it, +//! then the files that include those, out to the translation units (upstream +//! #2421 `cppIncluders`). +//! +//! Read from the `import` node extraction mints for every `#include`, each +//! resolved by the import resolver as the graph's include edge is, so +//! "includes" here is the include edge a reader sees. The test-suite rule asks +//! it whether a C or C++ file named like a test is part of a reference's +//! translation unit after all: protobuf's conformance framework lives in +//! `conformance/conformance_test.h`, which the suites and runners include. +//! +//! The walk starts from the included file, not from the reference's: a +//! handful of such files are asked about, by references from many files, and +//! a file nothing includes costs one map lookup. Every answer is a pure +//! function of the indexed files, so evaluation order cannot reach the output. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; + +use crate::import_resolver::resolve_via_import; +use crate::types::{RefView, ResolutionContext}; + +/// One `#include` directive, as its import node records it. +#[derive(Clone)] +struct Include { + id: String, + /// The path as written: `conformance/conformance_test.h`. + spec: String, + file: String, + language: Language, + line: i64, + column: i64, +} + +impl Include { + fn of(node: &Node) -> Self { + Self { + id: node.id.clone(), + spec: node.name.clone(), + file: node.file_path.clone(), + language: node.language, + line: node.start_line, + column: node.start_column, + } + } +} + +fn is_c_family(language: Language) -> bool { + matches!(language, Language::C | Language::Cpp) +} + +/// The last `/` or `\` segment of a path. +pub(crate) fn file_name(path: &str) -> &str { + path.rsplit(['/', '\\']).next().unwrap_or(path) +} + +/// A file name without its last extension (`conformance_test` of +/// `conformance/conformance_test.h`). +pub(crate) fn file_stem(path: &str) -> &str { + let name = file_name(path); + match name.rfind('.') { + Some(dot) if dot > 0 => &name[..dot], + _ => name, + } +} + +/// The per-pass include graph, filled on demand. +#[derive(Default)] +pub(crate) struct CppIncluders { + /// Every C / C++ `#include`, by the file name it spells (`a/b.h` → `b.h`). + by_name: Option>>, + /// Import node id → the indexed file its `#include` names (`None`: none). + targets: HashMap>, + /// File → the files that include it, directly or through other includes. + includers: HashMap>>, +} + +impl CppIncluders { + /// The includes spelling `name`. + fn includes_named(&mut self, name: &str, context: &dyn ResolutionContext) -> Vec { + let by_name = self.by_name.get_or_insert_with(|| { + let mut by_name: HashMap> = HashMap::new(); + for node in context.get_nodes_by_kind_shared(NodeKind::Import) { + if is_c_family(node.language) { + by_name + .entry(file_name(&node.name).to_string()) + .or_default() + .push(Include::of(&node)); + } + } + by_name + }); + by_name.get(name).cloned().unwrap_or_default() + } + + /// The indexed file an `#include` names, the way its include edge + /// resolves it. + fn included_file( + &mut self, + include: &Include, + context: &dyn ResolutionContext, + ) -> Option { + if let Some(hit) = self.targets.get(&include.id) { + return hit.clone(); + } + let reference = RefView { + row_id: None, + from_node_id: include.id.clone(), + reference_name: include.spec.clone(), + reference_kind: EdgeKind::Imports, + line: include.line, + column: include.column, + file_path: include.file.clone(), + language: include.language, + is_function_ref: false, + reference_subkind: None, + }; + let file = resolve_via_import(&reference, context) + .and_then(|resolved| context.get_node_by_id_shared(&resolved.target_node_id)) + .filter(|target| target.kind == NodeKind::File) + .map(|target| target.file_path.clone()); + self.targets.insert(include.id.clone(), file.clone()); + file + } + + /// The indexed file the `#include` an import node records names. + pub(crate) fn included_file_of( + &mut self, + include: &Node, + context: &dyn ResolutionContext, + ) -> Option { + (include.kind == NodeKind::Import && is_c_family(include.language)) + .then(|| self.included_file(&Include::of(include), context)) + .flatten() + } + + /// Every file that includes `file_path`, directly or through the files + /// that include it: the translation units (and headers) it is part of. + pub(crate) fn includers( + &mut self, + file_path: &str, + context: &dyn ResolutionContext, + ) -> Arc> { + if let Some(hit) = self.includers.get(file_path) { + return Arc::clone(hit); + } + let mut found = BTreeSet::new(); + let mut queue = vec![file_path.to_string()]; + let mut at = 0; + while at < queue.len() { + let target = queue[at].clone(); + at += 1; + let name = file_name(&target).to_string(); + // `#include "foo"` can name `foo.h`: the include resolver tries + // extensions. + let mut spellings = vec![name.clone()]; + if let Some(dot) = name.rfind('.').filter(|&dot| dot > 0) { + spellings.push(name[..dot].to_string()); + } + for spelled in spellings { + for include in self.includes_named(&spelled, context) { + if include.file == file_path || found.contains(&include.file) { + continue; + } + if self.included_file(&include, context).as_deref() != Some(target.as_str()) { + continue; + } + found.insert(include.file.clone()); + queue.push(include.file); + } + } + } + let found = Arc::new(found); + self.includers + .insert(file_path.to_string(), Arc::clone(&found)); + found + } +} diff --git a/crates/codegraph-resolve/src/cpp_namespaces.rs b/crates/codegraph-resolve/src/cpp_namespaces.rs new file mode 100644 index 00000000..5ec7f242 --- /dev/null +++ b/crates/codegraph-resolve/src/cpp_namespaces.rs @@ -0,0 +1,1028 @@ +//! C and C++ namespaces the index cannot see (upstream #2176, #2201, #2445). +//! +//! A library that opens its namespace with a macro (fmt's +//! `FMT_BEGIN_NAMESPACE`, `#define FMT_BEGIN_NAMESPACE namespace fmt { inline +//! namespace v12 {`; pybind11's `PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)`) +//! shows the extractor a bare macro line, so every declaration inside is +//! indexed without that namespace and `fmt::format(…)` finds nothing. The +//! project's namespace macros are read from its headers: object-like and +//! single-parameter function-like openers, through identifier macro aliases +//! (`#define RAPIDJSON_NAMESPACE rapidjson`), inline namespaces transparent, +//! and the closers. With them, each file's line ranges a macro opens +//! ([`Frame`]), and the project's namespace aliases (`namespace py = +//! pybind11;`), a qualified name reaches the declaration whose macro prefix +//! plus qualified name is the name written, as the last strategy for a +//! qualified C/C++ name ([`match_cpp_macro_namespaced`]). Among an overload +//! set the call's arguments pick the one they fit (#2201). +//! +//! A name written from the global scope (`::store::Repair(…)`, `::memset`) +//! is the declaration of exactly that qualified name, never a namesake +//! nested in another namespace or class, nor one inside a namespace a macro +//! opens, which only looks global in the index ([`match_cpp_global_name`], +//! #2445). +//! +//! The same facts are what a sync compares to find who must be re-resolved +//! (W4-CPP-07): [`facts_of`] reads one file's aliases and macro definitions +//! as written, [`CppNamespaceTables::usage_lines`] the lines that use a known +//! opener or closer, and [`NamespaceRow`] keeps both per file in +//! `project_metadata`. When the same alias or macro is defined in several +//! files the first by path wins, and within a file the first definition +//! (upstream keeps the last macro alias and closer). + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::name_matcher::prefer_call_site_file; +use crate::strip_comments::{CommentLang, strip_comments_for_regex}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// `project_metadata` key prefix of a file's namespace row. +pub const ROW_KEY_PREFIX: &str = "cpp_namespace_surface:"; + +/// The `project_metadata` key of `file_path`'s namespace row. +pub fn row_key(file_path: &str) -> String { + format!("{ROW_KEY_PREFIX}{file_path}") +} + +fn has_extension(path: &str, extensions: &[&str]) -> bool { + path.rsplit_once('.').is_some_and(|(stem, extension)| { + !stem.is_empty() + && !extension.contains('/') + && extensions + .iter() + .any(|known| extension.eq_ignore_ascii_case(known)) + }) +} + +/// A file whose namespace aliases the project table reads. +pub fn is_alias_file(path: &str) -> bool { + has_extension( + path, + &["h", "hh", "hpp", "hxx", "inl", "c", "cc", "cpp", "cxx"], + ) +} + +/// A header whose `#define`s the namespace macro table reads. +pub fn is_macro_file(path: &str) -> bool { + has_extension(path, &["h", "hh", "hpp", "hxx", "h++", "inl", "ipp", "tcc"]) +} + +/// One `#define` that opens, closes or names a namespace. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum MacroFact { + /// `#define FMT_BEGIN_NAMESPACE namespace fmt { inline namespace v12 {`, + /// the body as written. + Opener { name: String, body: String }, + /// `#define PYBIND11_NAMESPACE_BEGIN(name) namespace name {`. + OpenerFn { name: String }, + /// `#define FMT_END_NAMESPACE } }`, with the scopes its body closes. + Closer { name: String, scopes: usize }, + /// `#define RAPIDJSON_NAMESPACE rapidjson`. + Alias { name: String, target: String }, +} + +impl MacroFact { + /// The macro's name. + pub fn name(&self) -> &str { + match self { + Self::Opener { name, .. } + | Self::OpenerFn { name } + | Self::Closer { name, .. } + | Self::Alias { name, .. } => name, + } + } + + /// The text its definition writes besides the name. + pub fn body(&self) -> &str { + match self { + Self::Opener { body, .. } => body, + Self::Alias { target, .. } => target, + Self::OpenerFn { .. } | Self::Closer { .. } => "", + } + } +} + +/// What one C or C++ file writes that the namespace tables read: its +/// namespace aliases and its namespace macro definitions, in file order. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CppNamespaceFacts { + /// `namespace py = pybind11;` as `(py, pybind11)`; a leading `::` of the + /// target dropped. + pub aliases: Vec<(String, String)>, + pub macros: Vec, +} + +impl CppNamespaceFacts { + pub fn is_empty(&self) -> bool { + self.aliases.is_empty() && self.macros.is_empty() + } +} + +fn regex(pattern: &'static str, cell: &'static OnceLock) -> &'static Regex { + cell.get_or_init(|| Regex::new(pattern).expect("namespace regex")) +} + +/// A file's namespace aliases: `namespace py = pybind11;` at the start of a +/// line. +fn aliases_in(source: &str) -> Vec<(String, String)> { + static ALIAS: OnceLock = OnceLock::new(); + let alias = regex( + r"(?m)^[ \t]*namespace[ \t]+([A-Za-z_][0-9A-Za-z_]*)[ \t]*=[ \t]*(?:::)?([A-Za-z_][0-9A-Za-z_:]*)[ \t]*;", + &ALIAS, + ); + if !source.contains("namespace") { + return Vec::new(); + } + alias + .captures_iter(source) + .map(|found| (found[1].to_string(), found[2].to_string())) + .collect() +} + +/// A closing macro's body: `}` / `} }`, maybe beside a pragma macro +/// (`PYBIND11_WARNING_POP }`). +fn is_closer_body(body: &str) -> bool { + static CLOSER: OnceLock = OnceLock::new(); + regex( + r"^(?:[A-Za-z_][0-9A-Za-z_]*\s+)*\}(?:\s*\})*\s*;?$", + &CLOSER, + ) + .is_match(body) +} + +/// A header's namespace macro definitions. +fn macros_in(source: &str) -> Vec { + static DEFINE: OnceLock = OnceLock::new(); + static IDENTIFIER: OnceLock = OnceLock::new(); + static OPENER: OnceLock = OnceLock::new(); + if !(source.contains('#') && source.contains("define")) { + return Vec::new(); + } + let define = regex( + r"(?m)^[ \t]*#[ \t]*define[ \t]+([A-Za-z_][0-9A-Za-z_]*)(\(\s*([A-Za-z_][0-9A-Za-z_]*)?\s*\))?[ \t]+([^\n]*)$", + &DEFINE, + ); + let identifier = regex(r"^[A-Za-z_][0-9A-Za-z_]*$", &IDENTIFIER); + // An inline namespace (transparent, and often named by a macro call) + // may sit among the namespaces it opens. + let opener = regex( + r"^(?:inline\s+namespace\s+[^{}]*\{\s*|namespace\s+[A-Za-z_][0-9A-Za-z_]*\s*\{\s*)+[0-9A-Za-z_\s]*$", + &OPENER, + ); + let spliced = source.replace("\\\r\n", " ").replace("\\\n", " "); + let text = strip_comments_for_regex(&spliced, CommentLang::Cpp); + let mut out = Vec::new(); + for found in define.captures_iter(&text) { + let name = found[1].to_string(); + let body = found[4].trim(); + let closes = || body.matches('}').count(); + if found.get(2).is_some() { + // (a trailing pragma macro, `PYBIND11_WARNING_PUSH`, rides along) + let opens_its_parameter = found.get(3).is_some_and(|parameter| { + Regex::new(&format!( + r"^namespace\s+{}\s*\{{[0-9A-Za-z_\s]*$", + regex::escape(parameter.as_str()) + )) + .is_ok_and(|opens| opens.is_match(body)) + }); + if opens_its_parameter { + out.push(MacroFact::OpenerFn { name }); + } else if is_closer_body(body) { + out.push(MacroFact::Closer { + name, + scopes: closes(), + }); + } + continue; + } + if identifier.is_match(body) { + out.push(MacroFact::Alias { + name, + target: body.to_string(), + }); + } else if is_closer_body(body) { + out.push(MacroFact::Closer { + name, + scopes: closes(), + }); + } else if opener.is_match(body) { + out.push(MacroFact::Opener { + name, + body: body.to_string(), + }); + } + } + out +} + +/// What `path`, whose text is `source`, contributes to the namespace tables. +pub fn facts_of(path: &str, source: &str) -> CppNamespaceFacts { + CppNamespaceFacts { + aliases: if is_alias_file(path) { + aliases_in(source) + } else { + Vec::new() + }, + macros: if is_macro_file(path) { + macros_in(source) + } else { + Vec::new() + }, + } +} + +/// The project's namespace macros. +#[derive(Debug, Default)] +pub struct NamespaceMacros { + /// Each object-like opener and the namespaces it opens, macro aliases + /// followed, inline namespaces skipped. + openers: HashMap>, + opener_fns: HashSet, + closers: HashSet, + aliases: HashMap, +} + +impl NamespaceMacros { + /// The macros the facts of every file define, in path order. + pub fn from_facts<'f>(files: impl IntoIterator) -> Self { + static NAMESPACE: OnceLock = OnceLock::new(); + static INLINE: OnceLock = OnceLock::new(); + let namespace = regex(r"namespace\s+([A-Za-z_][0-9A-Za-z_]*)", &NAMESPACE); + let inline = regex(r"inline\s+namespace\s+[^{}]*\{", &INLINE); + let mut macros = Self::default(); + let mut bodies: Vec<(&str, &str)> = Vec::new(); + for facts in files { + for fact in &facts.macros { + match fact { + MacroFact::Opener { name, body } => bodies.push((name, body)), + MacroFact::OpenerFn { name } => { + macros.opener_fns.insert(name.clone()); + } + MacroFact::Closer { name, .. } => { + macros.closers.insert(name.clone()); + } + MacroFact::Alias { name, target } => { + macros + .aliases + .entry(name.clone()) + .or_insert_with(|| target.clone()); + } + } + } + } + for (name, body) in bodies { + if macros.openers.contains_key(name) { + continue; + } + let path: Vec = namespace + .captures_iter(&inline.replace_all(body, "")) + .map(|found| { + let written = &found[1]; + macros + .aliases + .get(written) + .cloned() + .unwrap_or_else(|| written.to_string()) + }) + .collect(); + if !path.is_empty() { + macros.openers.insert(name.to_string(), path); + } + } + macros + } + + fn knows(&self, token: &str) -> bool { + self.openers.contains_key(token) + || self.opener_fns.contains(token) + || self.closers.contains(token) + } + + /// The lines of `candidates` ([`candidate_usage_lines`]) that use one of + /// these openers or closers; none while no macro opens a namespace. + pub fn known_usage(&self, candidates: &[UsageLine]) -> Vec { + if self.openers.is_empty() && self.opener_fns.is_empty() { + return Vec::new(); + } + candidates + .iter() + .filter(|line| self.knows(&line.token)) + .cloned() + .collect() + } + + /// The lines of `lines` that use one of these openers or closers on + /// their own. + fn usage_lines<'l>(&self, lines: impl Iterator) -> Vec { + if self.openers.is_empty() && self.opener_fns.is_empty() { + return Vec::new(); + } + self.known_usage(&candidate_usage_lines(lines)) + } + + /// The namespaces a use of `token` (with `arg`) opens, if it opens any. + pub fn opened_path(&self, token: &str, arg: Option<&str>) -> Option> { + match arg { + Some(arg) if self.opener_fns.contains(token) => Some(vec![ + self.aliases + .get(arg) + .cloned() + .unwrap_or_else(|| arg.to_string()), + ]), + Some(_) => None, + None => self.openers.get(token).cloned(), + } + } + + /// The frames `usage` opens: an opener opens a frame, a closer closes + /// the innermost open one, and a frame the file never closes runs to its + /// end. + pub fn frames_of(&self, usage: &[UsageLine]) -> Vec { + let mut frames = Vec::new(); + let mut open: Vec<(i64, Vec)> = Vec::new(); + for line in usage { + if let Some(path) = self.opened_path(&line.token, line.arg.as_deref()) { + open.push((line.line, path)); + } else if self.closers.contains(&line.token) + && let Some((start, path)) = open.pop() + { + frames.push(Frame { + start, + end: line.line, + path, + }); + } + } + frames.extend(open.into_iter().map(|(start, path)| Frame { + start, + end: i64::MAX, + path, + })); + frames + } +} + +/// Every line of `lines` that uses an all-caps macro on its own, with at +/// most one identifier argument (`FMT_BEGIN_NAMESPACE`, +/// `PYBIND11_NAMESPACE_BEGIN(detail)`, a trailing `;` or comment allowed): +/// the lines a namespace opener or closer can be written on. +pub fn candidate_usage_lines<'l>(lines: impl Iterator) -> Vec { + static USE: OnceLock = OnceLock::new(); + let use_line = regex( + r"^[ \t]*([A-Z_][A-Z0-9_]*)(?:\(\s*([A-Za-z_][0-9A-Za-z_]*)?\s*\))?[ \t]*;?[ \t]*(?://.*|/\*.*\*/[ \t]*)?\r?$", + &USE, + ); + let mut out = Vec::new(); + for (index, text) in lines.enumerate() { + if let Some(found) = use_line.captures(text) { + out.push(UsageLine { + line: index as i64 + 1, + token: found[1].to_string(), + arg: found.get(2).map(|arg| arg.as_str().to_string()), + }); + } + } + out +} + +/// A line that uses a namespace opener or closer on its own. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageLine { + /// 1-based. + pub line: i64, + pub token: String, + /// The argument of a function-like use (`PYBIND11_NAMESPACE_BEGIN(detail)`). + pub arg: Option, +} + +/// The lines of a file a namespace macro opens, and the namespaces it opens +/// there. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + /// 1-based, the opening line. + pub start: i64, + /// 1-based, the closing line; `i64::MAX` when the file never closes it. + pub end: i64, + pub path: Vec, +} + +impl Frame { + fn holds(&self, line: i64) -> bool { + self.start <= line && self.end >= line + } +} + +/// The project's namespace aliases and namespace macros, read on first use +/// (or from a sync's rows), with each file's frames memoised. +#[derive(Debug, Default)] +pub struct CppNamespaceTables { + aliases: OnceLock>, + macros: OnceLock, + frames: Mutex>>>, +} + +impl CppNamespaceTables { + /// Tables read from `context`'s files when first asked. + pub fn new() -> Self { + Self::default() + } + + /// Tables over the facts of every file, in path order. + pub fn from_facts<'f>(files: impl IntoIterator + Clone) -> Self { + let mut aliases = HashMap::new(); + for facts in files.clone() { + for (alias, target) in &facts.aliases { + aliases + .entry(alias.clone()) + .or_insert_with(|| target.clone()); + } + } + let tables = Self::default(); + let _ = tables.aliases.set(aliases); + let _ = tables.macros.set(NamespaceMacros::from_facts(files)); + tables + } + + /// Every indexed file of `context` the tables read, in path order. + fn files(context: &dyn ResolutionContext, wanted: fn(&str) -> bool) -> Vec { + let mut files: Vec = context + .get_all_files_shared() + .iter() + .filter(|file| wanted(file)) + .cloned() + .collect(); + files.sort(); + files + } + + fn aliases(&self, context: &dyn ResolutionContext) -> &HashMap { + self.aliases.get_or_init(|| { + let mut aliases = HashMap::new(); + for file in Self::files(context, is_alias_file) { + let Some(source) = context.read_file_shared(&file) else { + continue; + }; + for (alias, target) in aliases_in(&source) { + aliases.entry(alias).or_insert(target); + } + } + aliases + }) + } + + fn macros(&self, context: &dyn ResolutionContext) -> &NamespaceMacros { + self.macros.get_or_init(|| { + let facts: Vec = Self::files(context, is_macro_file) + .iter() + .filter_map(|file| context.read_file_shared(file)) + .map(|source| CppNamespaceFacts { + aliases: Vec::new(), + macros: macros_in(&source), + }) + .collect(); + NamespaceMacros::from_facts(&facts) + }) + } + + /// The project's namespace alias `name` names (`py` → `pybind11`). + pub fn namespace_alias(&self, name: &str, context: &dyn ResolutionContext) -> Option { + self.aliases(context).get(name).cloned() + } + + /// The lines of `lines` that use a namespace opener or closer on their + /// own (a trailing `;` or comment allowed). + pub fn usage_lines<'l>( + &self, + lines: impl Iterator, + context: &dyn ResolutionContext, + ) -> Vec { + self.macros(context).usage_lines(lines) + } + + /// The frames `usage` opens: see [`NamespaceMacros::frames_of`]. + pub fn frames_of(&self, usage: &[UsageLine], context: &dyn ResolutionContext) -> Vec { + self.macros(context).frames_of(usage) + } + + /// The frames of `file`. + pub fn frames(&self, file: &str, context: &dyn ResolutionContext) -> Arc> { + if let Some(frames) = self.frames.lock().expect("frame memo").get(file).cloned() { + return frames; + } + let frames = Arc::new(match context.source_facts(file) { + Some(facts) => { + let usage = self.usage_lines( + (0..facts.line_count()).map(|index| facts.line(index)), + context, + ); + self.frames_of(&usage, context) + } + None => Vec::new(), + }); + self.frames + .lock() + .expect("frame memo") + .entry(file.to_string()) + .or_insert(frames) + .clone() + } + + /// The namespaces macros open around line `line` of `file`, outermost + /// first. + pub fn macro_prefix( + &self, + file: &str, + line: i64, + context: &dyn ResolutionContext, + ) -> Vec { + macro_prefix_in(&self.frames(file, context), line) + } +} + +/// The namespaces `frames` open around `line`, outermost first. +pub fn macro_prefix_in(frames: &[Frame], line: i64) -> Vec { + let mut holding: Vec<&Frame> = frames.iter().filter(|frame| frame.holds(line)).collect(); + holding.sort_by_key(|frame| frame.start); + holding + .into_iter() + .flat_map(|frame| frame.path.iter().cloned()) + .collect() +} + +/// `node`'s qualified name with the namespaces macros open around it +/// (`fmt::detail::buffer` for `detail::buffer`). +pub(crate) fn macro_qualified_name(node: &Node, context: &dyn ResolutionContext) -> String { + let prefix = + context + .cpp_namespace_tables() + .macro_prefix(&node.file_path, node.start_line, context); + if prefix.is_empty() { + node.qualified_name.clone() + } else { + format!("{}::{}", prefix.join("::"), node.qualified_name) + } +} + +/// Kinds a macro-namespaced name may name. +pub const MACRO_NAMESPACED_KINDS: [NodeKind; 9] = [ + NodeKind::Function, + NodeKind::Method, + NodeKind::Class, + NodeKind::Struct, + NodeKind::Enum, + NodeKind::TypeAlias, + NodeKind::Union, + NodeKind::Variable, + NodeKind::Constant, +]; + +fn is_c_family(node: &Node) -> bool { + matches!(node.language, Language::C | Language::Cpp) +} + +/// Resolve `fmt::format` / `fmt::detail::to_unsigned` to the declaration a +/// namespace macro puts there (upstream #2176 `matchCppMacroNamespaced`): a +/// node named `format` (qualified `format` or `detail::to_unsigned` in the +/// index) inside a `FMT_BEGIN_NAMESPACE` … `FMT_END_NAMESPACE` range, or, +/// through a namespace alias (`py::print` under `namespace py = +/// pybind11;`), a declaration whose qualified name is the aliased one. +pub(crate) fn match_cpp_macro_namespaced( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + static NAME: OnceLock = OnceLock::new(); + let written = reference.reference_name.as_str(); + let mut target = written.strip_prefix("::").unwrap_or(written).to_string(); + // `::memset` names no namespace for a macro or an alias to open. + let (head, _) = target.split_once("::")?; + let head = head.to_string(); + let tables = context.cpp_namespace_tables(); + let alias = tables.namespace_alias(&head, context); + if let Some(alias) = &alias { + target = format!("{alias}{}", &target[head.len()..]); + } + let name = target.rsplit_once("::").map(|(_, name)| name.to_string())?; + if !regex(r"^[A-Za-z_~][0-9A-Za-z_]*$", &NAME).is_match(&name) { + return None; + } + let mut matches: Vec> = Vec::new(); + for node in context.get_nodes_by_name_shared(&name) { + if !is_c_family(&node) || !MACRO_NAMESPACED_KINDS.contains(&node.kind) { + continue; + } + let prefix = tables.macro_prefix(&node.file_path, node.start_line, context); + let effective = if !prefix.is_empty() { + format!("{}::{}", prefix.join("::"), node.qualified_name) + } else if alias.is_some() { + node.qualified_name.clone() + } else { + String::new() + }; + if effective == target { + matches.push(node); + } + } + // A declaration outside the tests over one in them; among an overload + // set, the one the call's arguments fit: `fmt::format("{}", v)` is + // format.h's `format(format_string, T&&...)`, not color.h's + // `format(const text_style&, …)`. + let arguments = (matches.len() > 1 && reference.reference_kind == EdgeKind::Calls) + .then(|| cpp_call_arguments(reference, &name, context)) + .flatten(); + let mut best: Option<(&Arc, i64)> = None; + for node in &matches { + let score = if codegraph_core::file_class::is_test_path(&node.file_path) { + -10 + } else { + 0 + } + arguments.as_ref().map_or(0, |arguments| { + cpp_overload_fit(node, &name, arguments, context) + }); + if best.is_none_or(|(_, best_score)| score > best_score) { + best = Some((node, score)); + } + } + best.map(|(node, _)| ResolvedRef { + original: reference.clone(), + target_node_id: node.id.clone(), + confidence: 0.8, + resolved_by: ResolvedBy::QualifiedName, + }) +} + +/// A C or C++ name written from the global scope (`::hpb::CreateMessage`, +/// `::operator delete`) is the declaration of exactly that name (upstream +/// #2445): never one nested in another namespace or class (fmt's mock +/// `test::open` is not the `::open(…)` it wraps), nor one inside a namespace +/// a macro opens, which only looks global in the index (fmt's `struct +/// pipe`; [`match_cpp_macro_namespaced`] reads those). +pub(crate) fn match_cpp_global_name( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let name = reference.reference_name.strip_prefix("::")?; + let tables = context.cpp_namespace_tables(); + let global: Vec> = context + .get_nodes_by_qualified_name_shared(name) + .into_iter() + .filter(|node| { + is_c_family(node) + && !tables + .frames(&node.file_path, context) + .iter() + .any(|frame| frame.holds(node.start_line)) + }) + .collect(); + let count = global.len(); + let chosen = prefer_call_site_file(global, &reference.file_path) + .into_iter() + .next()?; + Some(ResolvedRef { + original: reference.clone(), + confidence: if count == 1 || chosen.file_path == reference.file_path { + 0.95 + } else { + 0.85 + }, + target_node_id: chosen.id.clone(), + resolved_by: ResolvedBy::QualifiedName, + }) +} + +/// Split `a, f(b, c), d` at its top-level commas. +pub(crate) fn split_cpp_top_level(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut depth = 0usize; + let mut current = String::new(); + let mut i = 0usize; + while i < chars.len() { + let c = chars[i]; + if c == '"' || c == '\'' { + let mut j = i + 1; + while j < chars.len() && chars[j] != c { + j += if chars[j] == '\\' { 2 } else { 1 }; + } + current.extend(&chars[i..chars.len().min(j + 1)]); + i = j + 1; + continue; + } + match c { + '(' | '[' | '{' | '<' => depth += 1, + ')' | ']' | '}' => depth = depth.saturating_sub(1), + '>' if i == 0 || chars[i - 1] != '-' => depth = depth.saturating_sub(1), + _ => {} + } + if c == ',' && depth == 0 { + out.push(current.trim().to_string()); + current.clear(); + } else { + current.push(c); + } + i += 1; + } + if !current.trim().is_empty() { + out.push(current.trim().to_string()); + } + out +} + +/// The balanced `( … )` after `name` from 1-based line `line`, byte +/// `column`, of `file` (up to a dozen lines). +fn cpp_paren_list_after( + file: &str, + line: i64, + column: usize, + name: &str, + context: &dyn ResolutionContext, +) -> Option { + let facts = context.source_facts(file)?; + let start = usize::try_from(line - 1).ok()?; + let end = (start + 12).min(facts.line_count()); + if start >= end { + return None; + } + let text = facts.join_lines(start, end, "\n"); + let rest = text.get(column..)?; + let at = Regex::new(&format!( + r"\b{}\s*(?:<[^<>()]*>)?\s*\(", + regex::escape(name) + )) + .ok()? + .find(rest)?; + let open = column + at.end() - 1; + let mut depth = 0usize; + for (offset, c) in text[open..].char_indices() { + if c == '(' { + depth += 1; + } else if c == ')' { + depth -= 1; + if depth == 0 { + return Some(text[open + 1..open + offset].to_string()); + } + } + } + None +} + +fn cpp_call_arguments( + reference: &RefView, + name: &str, + context: &dyn ResolutionContext, +) -> Option> { + let column = usize::try_from(reference.column.max(0)).ok()?; + cpp_paren_list_after(&reference.file_path, reference.line, column, name, context) + .map(|list| split_cpp_top_level(&list)) +} + +/// A parameter pack (`T&&... args`), not the `...` inside +/// `format_string`. +fn is_pack(parameter: &str) -> bool { + static ARGS: OnceLock = OnceLock::new(); + let args = regex(r"<[^<>]*>", &ARGS); + let mut flat = parameter.to_string(); + loop { + let next = args.replace_all(&flat, "").into_owned(); + if next == flat { + break; + } + flat = next; + } + flat.contains("...") +} + +/// How well a call's arguments fit an overload's parameters (upstream +/// #2201 `cppOverloadFit`): the count, and each string literal against its +/// parameter, a string type by name over a template parameter that might be +/// one (`const S&`), a narrow literal never a wide parameter nor a wide one +/// a narrow one. +fn cpp_overload_fit( + node: &Node, + name: &str, + arguments: &[String], + context: &dyn ResolutionContext, +) -> i64 { + static LITERAL: OnceLock = OnceLock::new(); + static WIDE: OnceLock = OnceLock::new(); + static STRINGISH: OnceLock = OnceLock::new(); + static STRING: OnceLock = OnceLock::new(); + static TEMPLATE_PARAMETER: OnceLock = OnceLock::new(); + if !matches!(node.kind, NodeKind::Function | NodeKind::Method) { + return -1; + } + let Some(list) = cpp_paren_list_after(&node.file_path, node.start_line, 0, name, context) + else { + return 0; + }; + let parameters: Vec = split_cpp_top_level(&list) + .into_iter() + .filter(|parameter| parameter != "void") + .collect(); + let variadic = parameters.iter().any(|parameter| is_pack(parameter)); + let required = parameters + .iter() + .filter(|parameter| !is_pack(parameter) && !parameter.contains('=')) + .count(); + let mut score = 0i64; + if arguments.len() < required || (!variadic && arguments.len() > parameters.len()) { + score -= 3; + } + let literal = regex(r#"^(?:u8|u|U|L)?"|^FMT_STRING\s*\("#, &LITERAL); + let wide = regex(r"\bw(?:string|char_t|format|string_view)|wchar_t", &WIDE); + let stringish = regex(r"string|char|Char|format", &STRINGISH); + let string = regex(r"string|char|Char|\bstr\b", &STRING); + let template_parameter = regex( + r"^(?:const\s+)?[A-Z][0-9A-Za-z_]{0,2}\s*[&*]{0,2}\s*[0-9A-Za-z_]*$", + &TEMPLATE_PARAMETER, + ); + for (argument, parameter) in arguments.iter().zip(¶meters) { + if is_pack(parameter) { + break; + } + if !literal.is_match(argument) { + continue; + } + let wide_argument = argument.starts_with("L\""); + let wide_parameter = wide.is_match(parameter); + if wide_argument != wide_parameter && stringish.is_match(parameter) { + score -= 2; + } else if string.is_match(parameter) { + score += 3; + } else if template_parameter.is_match(parameter) { + score += 1; + } else { + score -= 2; + } + } + score +} + +/// A file's namespace row: its facts as written, and the lines that use a +/// namespace opener or closer the tables know of. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct NamespaceRow { + pub facts: CppNamespaceFacts, + pub usage: Vec, +} + +impl NamespaceRow { + /// `path`'s row from its text, with the opener and closer names of + /// `tables`. + pub fn of( + path: &str, + source: &str, + tables: &CppNamespaceTables, + context: &dyn ResolutionContext, + ) -> Self { + Self { + facts: facts_of(path, source), + usage: tables.usage_lines(source.lines(), context), + } + } + + /// No fact: no row records it. + pub fn is_empty(&self) -> bool { + self.facts.is_empty() && self.usage.is_empty() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("namespace row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn macros_read_openers_closers_and_aliases_as_written() { + let facts = facts_of( + "include/fmt/base.h", + "#pragma once\n#define FMT_BEGIN_NAMESPACE \\\n namespace fmt { \\\n inline namespace v12 {\n#define FMT_END_NAMESPACE \\\n } \\\n }\n#define PYBIND11_NAMESPACE pybind11 // the namespace\n#define PYBIND11_NAMESPACE_BEGIN(name) namespace name { PYBIND11_WARNING_PUSH\n#define PYBIND11_NAMESPACE_END(name) PYBIND11_WARNING_POP }\n#define NOT_A_NAMESPACE (1 + 2)\nnamespace py = ::pybind11;\n", + ); + assert_eq!(facts.aliases, [("py".to_string(), "pybind11".to_string())]); + assert!( + matches!(&facts.macros[0], MacroFact::Opener { name, body } + if name == "FMT_BEGIN_NAMESPACE" + && body.split_whitespace().collect::>() + == ["namespace", "fmt", "{", "inline", "namespace", "v12", "{"]), + "{:?}", + facts.macros + ); + assert_eq!( + facts.macros[1..], + [ + MacroFact::Closer { + name: "FMT_END_NAMESPACE".to_string(), + scopes: 2, + }, + MacroFact::Alias { + name: "PYBIND11_NAMESPACE".to_string(), + target: "pybind11".to_string(), + }, + MacroFact::OpenerFn { + name: "PYBIND11_NAMESPACE_BEGIN".to_string(), + }, + MacroFact::Closer { + name: "PYBIND11_NAMESPACE_END".to_string(), + scopes: 1, + }, + ] + ); + // A source holds no macro the table reads; its aliases count. + let source = facts_of("src/use.cc", "#define X namespace x {\nnamespace a = b;\n"); + assert!(source.macros.is_empty()); + assert_eq!(source.aliases.len(), 1); + } + + #[test] + fn frames_follow_openers_and_closers_with_inline_namespaces_transparent() { + let header = facts_of( + "base.h", + "#define NS_ALIAS realns\n#define BEGIN namespace NS_ALIAS { inline namespace v1 {\n#define END } }\n#define BEGIN_FN(name) namespace name {\n#define END_FN(name) }\n", + ); + let macros = NamespaceMacros::from_facts([&header]); + let usage = macros.usage_lines( + [ + "BEGIN", + "int a;", + "BEGIN_FN(NS_ALIAS) // detail", + "int b;", + "END_FN(NS_ALIAS);", + "END", + "OTHER", + "BEGIN", + "int c;", + ] + .into_iter(), + ); + assert_eq!( + usage.iter().map(|line| line.line).collect::>(), + [1, 3, 5, 6, 8] + ); + let frames = macros.frames_of(&usage); + assert_eq!(macro_prefix_in(&frames, 2), ["realns"]); + assert_eq!(macro_prefix_in(&frames, 4), ["realns", "realns"]); + assert_eq!(macro_prefix_in(&frames, 7), Vec::::new()); + assert_eq!(macro_prefix_in(&frames, 9), ["realns"]); + } + + #[test] + fn the_first_definition_by_path_wins() { + let first = facts_of( + "a.h", + "#define NS one\n#define BEGIN namespace first {\nnamespace al = x;\n", + ); + let second = facts_of( + "b.h", + "#define NS two\n#define BEGIN namespace second {\nnamespace al = y;\n", + ); + let tables = CppNamespaceTables::from_facts([&first, &second]); + assert_eq!( + tables + .aliases + .get() + .and_then(|aliases| aliases.get("al")) + .map(String::as_str), + Some("x") + ); + let macros = tables.macros.get().expect("macros"); + assert_eq!(macros.aliases.get("NS").map(String::as_str), Some("one")); + assert_eq!( + macros.openers.get("BEGIN"), + Some(&vec!["first".to_string()]) + ); + } + + #[test] + fn top_level_commas_skip_nesting_strings_and_arrows() { + assert_eq!( + split_cpp_top_level(r#"a, f(b, c), d, "x,y", p->q, '\''"#), + ["a", "f(b, c)", "d", "\"x,y\"", "p->q", "'\\''"] + ); + assert!(is_pack("T&&... args")); + assert!(!is_pack("format_string fmt")); + } + + #[test] + fn a_row_round_trips() { + let row = NamespaceRow { + facts: facts_of("a.h", "#define BEGIN namespace a {\nnamespace b = c;\n"), + usage: vec![UsageLine { + line: 3, + token: "BEGIN".to_string(), + arg: None, + }], + }; + assert_eq!(NamespaceRow::decode(&row.encode()), Some(row)); + assert_eq!(row_key("a.h"), "cpp_namespace_surface:a.h"); + } +} diff --git a/crates/codegraph-resolve/src/cpp_supertypes.rs b/crates/codegraph-resolve/src/cpp_supertypes.rs new file mode 100644 index 00000000..ef49b742 --- /dev/null +++ b/crates/codegraph-resolve/src/cpp_supertypes.rs @@ -0,0 +1,531 @@ +//! A C or C++ base class (`class DynamicMessage final : public Message`) is +//! the class C++ name lookup finds for it (upstream #2407, +//! `cpp-supertypes.ts`). +//! +//! The base used to be matched by its name alone, so one named like a class +//! of another namespace, or of another language, bound to whichever namesake +//! ranked first: protobuf's `google::protobuf::DynamicMessage` derived from +//! `json_internal::ResolverPool::Message`, leveldb's iterators from +//! `SkipList::Iterator`. A wrong supertype reaches everything that walks the +//! hierarchy: the supertype walk that finds an inherited method and the type +//! hierarchy `codegraph_explore` prints. +//! +//! The name is looked up as C++ looks it up: +//! - from the scope the class is declared in (its own members are not in +//! scope in its base clause), outwards to the global scope; a class scope +//! includes its bases, a qualified name is resolved a segment at a time and +//! an alias is followed to the class it names (`cpp_type_aliases`); +//! `::Base` is the global one; +//! - then through what the file writes before the class: a namespace alias +//! (`namespace _pbi = ::google::protobuf::internal;`), a using-declaration +//! (`using leveldb::FilterPolicy;`), a using-directive (`using namespace +//! ROCKSDB_NAMESPACE;`); +//! - a class a source file (`.cc`, `.cpp`, `.c`) defines belongs to that +//! translation unit, so only that file sees it (leveldb's skiplist test +//! declares its own `leveldb::Comparator`); +//! - a template parameter (`template class X : public Base`) or +//! a member of one (`public T::Base`) is whatever a template argument makes +//! it: no edge. +//! +//! A namespace alias the file does not write is read from the project's +//! (`namespace py = pybind11;` in another header), and a qualified base is +//! also a class a namespace macro puts there (`fmt::detail::buffer`, where +//! `FMT_BEGIN_NAMESPACE` opens `namespace fmt` around `detail::buffer`; +//! `cpp_namespaces`, upstream #2176). +//! +//! When none of that finds the class, a class of that name that is the only +//! one the file can see is taken: the index loses a class's namespace in a few +//! shapes (`class PROTOBUF_EXPORT FieldDescriptor`, the classes after a +//! partial specialization), and the lookup from the wrong scope finds nothing. +//! Several such classes would be a guess, so there is no edge. Only C and C++ +//! declarations are candidates, and no other strategy sees these references. + +use std::sync::{Arc, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node}; +use regex::Regex; + +use crate::cpp_type_aliases::{ + CppNamedClass, cpp_class_named, cpp_type_segments, is_c_family, is_class_kind, parent_scope, + scopes_within, strip_template_arguments, template_parameters_around, +}; +use crate::source_facts::SourceFacts; +use crate::strip_comments::{CommentLang, strip_comments_for_regex}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// Is `reference` a C or C++ class's base? +pub(crate) fn is_cpp_supertype_ref(reference: &RefView) -> bool { + matches!(reference.language, Language::Cpp | Language::C) + && matches!( + reference.reference_kind, + EdgeKind::Extends | EdgeKind::Implements + ) +} + +/// A C or C++ source file, whose definitions belong to its own translation +/// unit. +fn is_source_file(path: &str) -> bool { + path.rsplit_once('.').is_some_and(|(_, extension)| { + ["c", "cc", "cpp", "cxx", "c++", "cu", "metal"] + .iter() + .any(|known| extension.eq_ignore_ascii_case(known)) + }) +} + +/// The base of `reference`, as C++ name lookup finds it; `None` when it names +/// no class (or several). +pub(crate) fn match_cpp_supertype( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let derived = context.get_node_by_id_shared(&reference.from_node_id)?; + if !is_c_family(&derived) || !is_class_kind(derived.kind) { + return None; + } + let written = written_base(reference, context); + let segments = cpp_type_segments(&written)?; + let global = written.starts_with("::"); + if !global && template_parameters_around(&derived, context).contains(&segments[0]) { + return None; + } + + let derived_file = derived.file_path.clone(); + let visible = + move |node: &Node| node.file_path == derived_file || !is_source_file(&node.file_path); + let scopes = scopes_within(parent_scope(&derived.qualified_name)); + let resolved = |target: Arc| ResolvedRef { + original: reference.clone(), + target_node_id: target.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::QualifiedName, + }; + let look_up = |spelled: &str, scopes: &[String]| { + class_node( + cpp_class_named(spelled, scopes, reference, context, Some(&visible)), + &derived, + context, + ) + }; + let global_scope = [String::new()]; + + // The scopes around the class, or only the global one for `::Base`. + match look_up(&written, if global { &global_scope } else { &scopes }) { + Lookup::Found(target) => return Some(resolved(target)), + Lookup::NoClass => return None, + Lookup::NotFound => {} + } + + // What the file writes before the class, the nearest first: each stands + // for the name's first segment, and the rest stays as written. + let path = segments.join("::"); + let after = after_first_segment(&written); + let usings = context + .source_facts(&derived.file_path) + .map(|facts| usings_in(&facts)) + .unwrap_or_default(); + let before: Vec<&CppUsing> = usings + .iter() + .filter(|using| using.line < reference.line) + .rev() + .collect(); + let spelled_from = |spelled: String| { + let scopes = if spelled.starts_with("::") { + &global_scope[..] + } else { + &scopes[..] + }; + match look_up(&spelled, scopes) { + Lookup::Found(target) => Some(target), + Lookup::NoClass | Lookup::NotFound => None, + } + }; + // `::_pbi::MessageGlobalsBase` under `namespace _pbi = ::google::protobuf::internal;`, + // the file's own alias or else the project's. + if segments.len() > 1 { + let target = before + .iter() + .find(|using| using.kind == UsingKind::Alias && using.name == segments[0]) + .map(|alias| alias.path.clone()) + .or_else(|| { + context + .cpp_namespace_tables() + .namespace_alias(&segments[0], context) + }); + if let Some(target) = target + && let Some(hit) = spelled_from(format!("{target}{after}")) + { + return Some(resolved(hit)); + } + } + if !global { + // `using leveldb::FilterPolicy;` … `public FilterPolicy`. + for using in &before { + if using.kind == UsingKind::Declaration + && using.name == segments[0] + && let Some(hit) = spelled_from(format!("{}{after}", using.path)) + { + return Some(resolved(hit)); + } + } + // `using namespace ROCKSDB_NAMESPACE;` … `public EventListener`. + for using in &before { + if using.kind == UsingKind::Directive + && let Some(hit) = spelled_from(format!("{}::{written}", using.path)) + { + return Some(resolved(hit)); + } + } + } + // `fmt::detail::buffer`, where a macro opens `namespace fmt` around + // `detail::buffer`. + if segments.len() > 1 + && let Some(hit) = pick( + named_classes(&segments, &derived, context, &visible) + .into_iter() + .filter(|node| crate::cpp_namespaces::macro_qualified_name(node, context) == path) + .collect(), + &derived, + ) + { + return Some(resolved(hit)); + } + // The only class of that name the file can see, by its qualified tail. + let suffix = format!("::{path}"); + let tail: Vec> = named_classes(&segments, &derived, context, &visible) + .into_iter() + .filter(|node| node.qualified_name == path || node.qualified_name.ends_with(&suffix)) + .collect(); + let own: Vec<&Arc> = tail + .iter() + .filter(|node| node.file_path == derived.file_path) + .collect(); + let sole: Vec<&Arc> = if own.is_empty() { + tail.iter().collect() + } else { + own + }; + match sole.as_slice() { + [only] => Some(ResolvedRef { + original: reference.clone(), + target_node_id: only.id.clone(), + confidence: 0.7, + resolved_by: ResolvedBy::ExactMatch, + }), + _ => None, + } +} + +/// `written` past its first segment: `::Inner` of `::_pbi::Inner`, `` of +/// `Base`. +fn after_first_segment(written: &str) -> &str { + static FIRST: OnceLock = OnceLock::new(); + let first = + FIRST.get_or_init(|| Regex::new(r"^\s*(?:::\s*)?[A-Za-z_]\w*").expect("first segment")); + first + .find(written) + .map_or(written, |found| &written[found.end()..]) +} + +/// The base as the base clause writes it, template arguments included +/// (`bool_constant`): the reference carries the name without +/// them, and an alias template is only ever named with them. The reference's +/// own name when the source there reads otherwise. +fn written_base(reference: &RefView, context: &dyn ResolutionContext) -> String { + let fallback = || reference.reference_name.trim().to_string(); + let Some(facts) = context.source_facts(&reference.file_path) else { + return fallback(); + }; + let start = usize::try_from(reference.line - 1).unwrap_or(0); + let end = start.saturating_add(12).min(facts.line_count()); + if start >= end { + return fallback(); + } + let joined = facts.join_lines(start, end, "\n"); + let column = usize::try_from(reference.column).unwrap_or(0); + let Some(text) = joined.get(column..) else { + return fallback(); + }; + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let mut depth = 0usize; + let mut stop = text.len(); + for (index, &(at, c)) in chars.iter().enumerate() { + let previous = index.checked_sub(1).map(|i| chars[i].1); + let next = chars.get(index + 1).map(|&(_, c)| c); + // A template's `<` follows its name; `sizeof(T) <= 8` and `1 << n` + // compare and shift. + let opens = c == '(' + || (c == '<' + && previous.is_some_and(|p| p == '_' || p.is_ascii_alphanumeric()) + && next != Some('=') + && next != Some('<')); + let closes = c == ')' || (c == '>' && previous != Some('-') && next != Some('=')); + if opens { + depth += 1; + } else if closes { + if depth == 0 { + stop = at; + break; + } + depth -= 1; + } else if depth == 0 && matches!(c, ',' | '{' | ';') { + stop = at; + break; + } + } + let written = text[..stop].trim(); + let squash = |text: &str| text.split_whitespace().collect::(); + if squash(&strip_template_arguments(written)) == squash(&reference.reference_name) { + written.to_string() + } else { + fallback() + } +} + +/// What a lookup made of a base. +enum Lookup { + Found(Arc), + /// The name names no particular class. + NoClass, + /// Keep looking. + NotFound, +} + +/// The node of the class a lookup found, as the deriving class's file sees +/// it. +fn class_node(named: CppNamedClass, derived: &Node, context: &dyn ResolutionContext) -> Lookup { + match named { + CppNamedClass::NoClass => Lookup::NoClass, + CppNamedClass::NotFound => Lookup::NotFound, + CppNamedClass::Alias(alias) => Lookup::Found(alias), + CppNamedClass::Class(class) => { + let classes: Vec> = context + .get_nodes_by_qualified_name_shared(&class) + .into_iter() + .filter(|node| { + is_c_family(node) && is_class_kind(node.kind) && node.id != derived.id + }) + .collect(); + // A lookup that ended on the class itself (its namespace lost) + // found nothing. + if classes.is_empty() { + return Lookup::NotFound; + } + let seen: Vec> = classes + .iter() + .filter(|node| { + node.file_path == derived.file_path || !is_source_file(&node.file_path) + }) + .cloned() + .collect(); + match pick(if seen.is_empty() { classes } else { seen }, derived) { + Some(target) => Lookup::Found(target), + None => Lookup::NotFound, + } + } + } +} + +/// One of several declarations of a class (separate translation units, +/// vendored copies): the deriving class's own file's, then its header's, then +/// the nearest by path. +fn pick(classes: Vec>, derived: &Node) -> Option> { + if classes.len() <= 1 { + return classes.into_iter().next(); + } + if let Some(own) = classes + .iter() + .find(|node| node.file_path == derived.file_path) + { + return Some(Arc::clone(own)); + } + let stem = |path: &str| -> String { + match path.rfind('.') { + Some(dot) if !path[dot + 1..].contains(['/', '\\']) && dot + 1 < path.len() => { + path[..dot].to_string() + } + _ => path.to_string(), + } + }; + let own_stem = stem(&derived.file_path); + if let Some(header) = classes + .iter() + .find(|node| stem(&node.file_path) == own_stem) + { + return Some(Arc::clone(header)); + } + let shared = |path: &str| -> usize { + let a: Vec<&str> = path.split('/').collect(); + let b: Vec<&str> = derived.file_path.split('/').collect(); + let mut i = 0usize; + while i + 1 < a.len() && i + 1 < b.len() && a[i] == b[i] { + i += 1; + } + i + }; + let mut best = Arc::clone(&classes[0]); + for node in &classes[1..] { + if shared(&node.file_path) > shared(&best.file_path) { + best = Arc::clone(node); + } + } + Some(best) +} + +/// C and C++ classes named like the last of `segments` that `derived`'s file +/// can see. +fn named_classes( + segments: &[String], + derived: &Node, + context: &dyn ResolutionContext, + visible: &dyn Fn(&Node) -> bool, +) -> Vec> { + context + .get_nodes_by_name_shared(&segments[segments.len() - 1]) + .into_iter() + .filter(|node| { + is_c_family(node) && is_class_kind(node.kind) && node.id != derived.id && visible(node) + }) + .collect() +} + +/// What a `using` line brings in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UsingKind { + /// `using leveldb::FilterPolicy;` + Declaration, + /// `using namespace rocksdb;` + Directive, + /// `namespace _pbi = ::google::protobuf::internal;` + Alias, +} + +/// A `using` declaration or directive, or a namespace alias, a C or C++ file +/// writes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CppUsing { + /// 1-based. + line: i64, + kind: UsingKind, + /// The name it brings in: the declared name, or the alias. + name: String, + /// What it names, as written: `leveldb::FilterPolicy`, `ROCKSDB_NAMESPACE`, + /// `::google::protobuf::internal`. + path: String, +} + +/// The `using` declarations and directives and namespace aliases of a file, +/// in order; read once per file. +fn usings_in(facts: &SourceFacts) -> Arc> { + facts.cpp_usings(|source| { + if !(source.contains("using") || source.contains("namespace")) { + return Vec::new(); + } + static USING: OnceLock = OnceLock::new(); + let using = USING.get_or_init(|| { + const PATH: &str = r"(?:::\s*)?[A-Za-z_]\w*(?:\s*::\s*[A-Za-z_]\w*)*"; + Regex::new(&format!( + r"\busing\s+namespace\s+({PATH})\s*;|\busing\s+({PATH}\s*::\s*[A-Za-z_]\w*)\s*;|\bnamespace\s+([A-Za-z_]\w*)\s*=\s*({PATH})\s*;" + )) + .expect("using regex") + }); + let text = strip_comments_for_regex(source, CommentLang::Cpp); + let squash = |text: &str| text.split_whitespace().collect::(); + let mut found = Vec::new(); + let mut line = 1i64; + let mut at = 0usize; + for captures in using.captures_iter(&text) { + let start = captures.get(0).expect("match").start(); + line += text.as_bytes()[at..start] + .iter() + .filter(|&&byte| byte == b'\n') + .count() as i64; + at = start; + if let Some(directive) = captures.get(1) { + found.push(CppUsing { + line, + kind: UsingKind::Directive, + name: String::new(), + path: squash(directive.as_str()), + }); + } else if let Some(declaration) = captures.get(2) { + let path = squash(declaration.as_str()); + let name = path.rsplit("::").next().unwrap_or_default().to_string(); + found.push(CppUsing { + line, + kind: UsingKind::Declaration, + name, + path, + }); + } else if let (Some(name), Some(path)) = (captures.get(3), captures.get(4)) { + found.push(CppUsing { + line, + kind: UsingKind::Alias, + name: name.as_str().to_string(), + path: squash(path.as_str()), + }); + } + } + found + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_file_reads_its_usings_and_namespace_aliases_in_order() { + let facts = SourceFacts::new(Arc::from( + "// using namespace commented;\nnamespace _pbi = ::google :: protobuf::internal;\nusing leveldb::FilterPolicy;\nusing namespace rocksdb;\nusing Alias = Type;\n", + )); + let usings = usings_in(&facts); + assert_eq!( + usings + .iter() + .map(|using| ( + using.line, + using.kind, + using.name.as_str(), + using.path.as_str() + )) + .collect::>(), + [ + (2, UsingKind::Alias, "_pbi", "::google::protobuf::internal"), + ( + 3, + UsingKind::Declaration, + "FilterPolicy", + "leveldb::FilterPolicy" + ), + (4, UsingKind::Directive, "", "rocksdb"), + ] + ); + } + + #[test] + fn a_name_past_its_first_segment_keeps_the_rest_as_written() { + assert_eq!( + after_first_segment("::_pbi::MessageGlobalsBase"), + "::MessageGlobalsBase" + ); + assert_eq!(after_first_segment("FilterPolicy"), ""); + assert_eq!(after_first_segment("Base::Inner"), "::Inner"); + } + + #[test] + fn source_files_are_their_own_translation_units() { + for path in [ + "db/skiplist_test.cc", + "a.C", + "b.cpp", + "k.cu", + "s.metal", + "x.c++", + ] { + assert!(is_source_file(path), "{path}"); + } + for path in ["include/leveldb/comparator.h", "a.hpp", "b.hxx", "noext"] { + assert!(!is_source_file(path), "{path}"); + } + } +} diff --git a/crates/codegraph-resolve/src/cpp_type_aliases.rs b/crates/codegraph-resolve/src/cpp_type_aliases.rs new file mode 100644 index 00000000..f8f2708b --- /dev/null +++ b/crates/codegraph-resolve/src/cpp_type_aliases.rs @@ -0,0 +1,1246 @@ +//! C++ type aliases in a declared type: `typedef X Y;` and `using Y = X;` +//! (upstream #2399, `cpp-type-aliases.ts`). +//! +//! The extractor indexes an alias as a `type_alias` node and records nothing +//! about the type it names, so a receiver declared through one used to be +//! looked up by the alias's own name. google/leveldb's `MemTable::Add` calls +//! `table_.Insert(buf)` on a member `Table table_;`, with +//! `typedef SkipList Table;` declared beside it in +//! the class. No class `Table` has an `Insert` (the one class `Table` is the +//! unrelated `leveldb::Table`), so the call fell through to a guess by the +//! receiver's name and reached `HandleTable::Insert`. +//! +//! A name is looked up the way C++ looks it up: from the innermost scope of +//! the code that uses it outwards. That is an alias declared earlier in the +//! calling function, then the caller's class and that class's bases, then the +//! classes and namespaces around it, and the global scope. The first scope +//! that declares the name decides; a class there is the type itself. An alias +//! there is read from its own declaration and followed from the alias's own +//! scope, for a few hops at most. A qualified name is resolved a segment at a +//! time, so `Table::Iterator` is `SkipList::Iterator` and `MemTable::Table` is +//! `SkipList`. Template arguments are dropped. An alias of what only a +//! template argument decides (`using Type = GenericType;` in the template that +//! declares `GenericType`, `typename Traits::Field`) or of a `decltype(…)` +//! names no particular class, and is reported as such. +//! +//! Every answer is a pure function of the files and the node snapshot of the +//! pass; the per-file memos live in [`SourceFacts`](crate::source_facts::SourceFacts). + +use std::collections::{BTreeSet, HashSet}; +use std::sync::Arc; + +use codegraph_core::types::{Language, Node, NodeKind}; +use regex::Regex; + +use crate::source_facts::SourceFacts; +use crate::types::{RefView, ResolutionContext}; + +/// How many aliases one name is followed through (`A` → `B` → `C`). +const MAX_HOPS: u32 = 4; +/// How many base classes deep a member is looked for. +const MAX_BASE_DEPTH: u32 = 4; +/// How far past a class's first line its base clause may run. +const HEAD_LINES: usize = 12; + +/// What a type named through an alias resolved to: the aliased type as written +/// in `scope` (`["SkipList"]`), then the rest of the name that followed the +/// alias (`["Iterator"]` for `Table::Iterator`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CppAliasedType { + pub target: Vec, + pub rest: Vec, + /// The qualified scope `target` is written in (`""` for the global scope). + pub scope: String, + /// An alias on the way named a pointer (`using Field = const FieldDescriptor*;`). + pub pointer: bool, + /// The type is a class template of the project (or a class nested in one), + /// whose specializations can declare members the primary template doesn't. + pub class_template: bool, +} + +/// What a declared C++ type names once the aliases in it are followed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CppAliased { + /// The type is not an alias; the caller keeps its own reading. + NotAnAlias, + /// An alias that names no particular class: a template parameter's type + /// (`using Type = GenericType;`, `typename Traits::Field`), a + /// `decltype(…)` or a function type, or a chain of aliases too long to + /// follow. + Unreadable, + /// The type the alias names. + Followed(CppAliasedType), +} + +/// A written type's `::` segments, each with whether template arguments +/// followed it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct TypeName { + names: Vec, + templated: Vec, +} + +/// `SkipList>::Iterator` → `SkipList::Iterator`. +pub(crate) fn strip_template_arguments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut depth = 0usize; + for c in text.chars() { + match c { + '<' => depth += 1, + '>' => depth = depth.saturating_sub(1), + _ if depth == 0 => out.push(c), + _ => {} + } + } + out +} + +fn is_identifier(text: &str) -> bool { + let mut chars = text.chars(); + chars + .next() + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) + && chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + +/// The `::` segments of a written type, or `None` when it is not a plain +/// (possibly qualified) type name. +fn cpp_type_name(raw: &str) -> Option { + static NOISE: std::sync::OnceLock = std::sync::OnceLock::new(); + let noise = NOISE.get_or_init(|| { + Regex::new( + r"\b(?:const|volatile|mutable|typename|template|class|struct|union|enum)\b|[&*]+", + ) + .expect("type noise") + }); + let text = noise.replace_all(raw, " "); + let mut names = Vec::new(); + let mut templated = Vec::new(); + let mut depth = 0usize; + let mut part = String::new(); + let mut args = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == '<' { + depth += 1; + args = true; + } else if c == '>' { + depth = depth.saturating_sub(1); + } else if depth > 0 { + continue; + } else if c == ':' && chars.peek() == Some(&':') { + chars.next(); + names.push(part.trim().to_string()); + templated.push(args); + part.clear(); + args = false; + } else { + part.push(c); + } + } + names.push(part.trim().to_string()); + templated.push(args); + if names.first().is_some_and(String::is_empty) { + // A leading `::` names the global scope. + names.remove(0); + templated.remove(0); + } + if names.is_empty() || !names.iter().all(|name| is_identifier(name)) { + return None; + } + Some(TypeName { names, templated }) +} + +/// Is a written type a pointer (`Table*`, `const Foo* const`), not counting +/// its template arguments? +pub(crate) fn is_cpp_pointer_type(raw: &str) -> bool { + strip_template_arguments(raw).contains('*') +} + +/// The `::` segments of a written type: `const ns::Foo&` → `["ns", "Foo"]`. +/// `None` when what is written is not a plain (possibly qualified) type name: +/// `unsigned int`, `decltype(…)`, a function type. +pub(crate) fn cpp_type_segments(raw: &str) -> Option> { + cpp_type_name(raw).map(|name| name.names) +} + +/// C and C++ declare the names a C++ file can see (a header may be either). +pub(crate) fn is_c_family(node: &Node) -> bool { + matches!(node.language, Language::Cpp | Language::C) +} + +/// Kinds that declare a type name in a scope, besides an alias. +fn is_declaring_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Class + | NodeKind::Struct + | NodeKind::Union + | NodeKind::Enum + | NodeKind::Interface + | NodeKind::Namespace + | NodeKind::Module + ) +} + +pub(crate) fn is_class_kind(kind: NodeKind) -> bool { + matches!(kind, NodeKind::Class | NodeKind::Struct | NodeKind::Union) +} + +/// Scopes visible from inside the scope `qualified`, innermost first, ending +/// with the global scope. +pub(crate) fn scopes_within(qualified: &str) -> Vec { + let parts: Vec<&str> = if qualified.is_empty() { + Vec::new() + } else { + qualified.split("::").collect() + }; + (0..=parts.len()) + .rev() + .map(|end| parts[..end].join("::")) + .collect() +} + +/// The scope a declaration sits in: `leveldb::MemTable` for +/// `leveldb::MemTable::Table`. +pub(crate) fn parent_scope(qualified: &str) -> &str { + qualified.rsplit_once("::").map_or("", |(scope, _)| scope) +} + +/// The scope the code that makes a reference is written in: its class for a +/// method, itself for a class. +fn caller_scope(caller: Option<&Node>) -> String { + match caller { + None => String::new(), + Some(caller) if caller.kind == NodeKind::File => String::new(), + Some(caller) if is_declaring_kind(caller.kind) => caller.qualified_name.clone(), + Some(caller) => parent_scope(&caller.qualified_name).to_string(), + } +} + +/// A node's last line, its first when the end is unknown. +fn end_line(node: &Node) -> i64 { + if node.end_line != 0 { + node.end_line + } else { + node.start_line + } +} + +/// `line[from..to]` in byte columns, clamped to the line and to character +/// boundaries, as upstream's `slice` clamps. +fn columns(line: &str, from: usize, to: usize) -> &str { + let floor = |mut at: usize| { + at = at.min(line.len()); + while !line.is_char_boundary(at) { + at -= 1; + } + at + }; + let to = floor(to); + &line[floor(from).min(to)..to] +} + +fn column(value: i64) -> usize { + usize::try_from(value).unwrap_or(0) +} + +/// 0-based line index of a 1-based line number. +fn line_index(line: i64) -> usize { + usize::try_from(line).unwrap_or(1).saturating_sub(1) +} + +fn word_regex(pattern: &str) -> Option { + Regex::new(pattern).ok() +} + +/// The text of `node`'s declaration, cut to its columns when they fit the +/// lines. +fn declaration_text(node: &Node, context: &dyn ResolutionContext) -> Option { + let facts = context.source_facts(&node.file_path)?; + let start = line_index(node.start_line); + let end = usize::try_from(end_line(node)) + .unwrap_or(0) + .min(facts.line_count()); + if start >= end { + return None; + } + let span: Vec<&str> = (start..end).map(|index| facts.line(index)).collect(); + let whole = span.join("\n"); + let mut cut: Vec<&str> = span.clone(); + let last = cut.len() - 1; + if last == 0 { + cut[0] = columns(span[0], column(node.start_column), column(node.end_column)); + } else { + cut[0] = columns(span[0], column(node.start_column), usize::MAX); + cut[last] = columns(span[last], 0, column(node.end_column)); + } + let cut = cut.join("\n"); + static DECLARES: std::sync::OnceLock = std::sync::OnceLock::new(); + let declares = + DECLARES.get_or_init(|| Regex::new(r"\b(?:typedef|using)\b").expect("alias keyword")); + Some(if declares.is_match(&cut) { cut } else { whole }) +} + +/// Split `text` on commas outside `<>`, `()`, `[]` and `{}`. +fn split_top_level(text: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + for (at, c) in text.char_indices() { + match c { + '<' | '(' | '[' | '{' => depth += 1, + '>' | ')' | ']' | '}' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(&text[start..at]); + start = at + 1; + } + _ => {} + } + } + parts.push(&text[start..]); + parts +} + +/// What an alias of `name` declares in `text`, as written: `SkipList` for `typedef SkipList +/// Table;` and for `using Table = SkipList;`. +/// `None` when `text` declares no alias of that name. +fn aliased_type_in(text: &str, name: &str) -> Option { + let escaped = regex::escape(name); + // Each pattern compiles only when `text` writes what it needs. + if let Some(using) = (text.contains("using") && text.contains(name)) + .then(|| { + word_regex(&format!( + r"\busing\s+{escaped}\s*(?:\[\[[^\]]*\]\]\s*)?=\s*([^;]+)" + )) + }) + .flatten() + .and_then(|pattern| pattern.captures(text)) + { + return Some(using[1].trim().to_string()); + } + static TYPEDEF: std::sync::OnceLock = std::sync::OnceLock::new(); + let typedef = TYPEDEF.get_or_init(|| Regex::new(r"\btypedef\b([^;]+)").expect("typedef")); + let typedef = typedef.captures(text)?; + // `typedef Foo* FooPtr, FooObj;` indexes its first declarator; the type is + // what precedes that declarator's name. + let first = split_top_level(typedef.get(1)?.as_str())[0]; + if !first.trim_end().ends_with(name) { + return None; + } + let declared = word_regex(&format!(r"^([\s\S]*?)\b{escaped}\s*$"))?.captures(first)?; + let written = declared[1].trim(); + (!written.is_empty()).then(|| written.to_string()) +} + +/// Lines `start_line - 4 ..= start_line - 1` of `facts`' text, the last cut +/// before `start_column`, joined by `separator`: the text a declaration's +/// `template <…>` header ends. +fn text_before(facts: &SourceFacts, start_line: i64, start_column: i64, separator: &str) -> String { + let at = line_index(start_line); + let mut parts: Vec<&str> = (at.saturating_sub(3)..at.min(facts.line_count())) + .map(|index| facts.line(index)) + .collect(); + let own = if at < facts.line_count() { + facts.line(at) + } else { + "" + }; + parts.push(columns(own, 0, column(start_column))); + parts.join(separator) +} + +/// Is `alias` an alias template (`template using Vec = +/// std::vector;`)? +fn is_alias_template(alias: &Node, context: &dyn ResolutionContext) -> bool { + let Some(facts) = context.source_facts(&alias.file_path) else { + return false; + }; + static HEADER: std::sync::OnceLock = std::sync::OnceLock::new(); + let header = + HEADER.get_or_init(|| Regex::new(r"\btemplate\s*<[^;{}]*>\s*$").expect("template header")); + header.is_match(&text_before( + &facts, + alias.start_line, + alias.start_column, + " ", + )) +} + +/// The lines of a function's body that declare an alias (`typedef …;`, +/// `using X = …;`), last first. +fn local_alias_lines(caller: &Node, facts: &SourceFacts) -> Arc> { + facts.cpp_local_alias_lines(&caller.id, |facts| { + static DECLARES: std::sync::OnceLock = std::sync::OnceLock::new(); + let declares = DECLARES + .get_or_init(|| Regex::new(r"\b(?:typedef\b|using\s+\w+\s*=)").expect("local alias")); + let last = usize::try_from(end_line(caller)) + .unwrap_or(0) + .min(facts.line_count()); + let first = line_index(caller.start_line); + (first..last) + .rev() + .filter(|&index| declares.is_match(facts.line(index))) + .collect() + }) +} + +/// The type an alias declared earlier in the calling function names, as +/// written. Those aliases are not indexed, so the function's own lines are +/// read, from the call back to the function's first line. `None` when no +/// such alias declares `name`; `Some(None)` when one does but its type can't +/// be read. +fn local_aliased_type( + name: &str, + caller: Option<&Node>, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + let caller = caller?; + if !matches!(caller.kind, NodeKind::Function | NodeKind::Method) + || caller.file_path != reference.file_path + { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + let escaped = regex::escape(name); + // Compiled once a line writes the name: most functions declare no alias. + let declares: std::cell::OnceCell> = std::cell::OnceCell::new(); + let call_line = line_index(reference.line); + for &index in local_alias_lines(caller, &facts).iter() { + if index > call_line { + continue; + } + let line = facts.line(index); + if !line.contains(name) { + continue; + } + let declares = declares + .get_or_init(|| { + word_regex(&format!( + r"\b(?:using\s+{escaped}\s*=|typedef\b[^;]*\b{escaped}\s*[;,])" + )) + }) + .as_ref()?; + if declares.is_match(line) { + return Some(aliased_type_in(line, name)); + } + } + None +} + +/// What a scope declares under a name. +#[derive(Debug, Clone)] +enum Declared { + Alias(Arc), + /// A class, struct or union, by its qualified name. + Class(String), + /// An enum, namespace or other non-class type. + Other, + /// Aliases naming different types, none of them the calling file's: + /// rocksdb's `using DBWithTTLImplBase = …;` in each arm of an `#if`. + Ambiguous(Vec>), +} + +/// Which declarations a lookup can see; every one unless a caller narrows it +/// (upstream #2407). +pub(crate) type Visible<'v> = Option<&'v dyn Fn(&Node) -> bool>; + +/// C and C++ declarations named `name` that can stand for a type or scope. +fn type_declarations( + name: &str, + context: &dyn ResolutionContext, + visible: Visible<'_>, +) -> Vec> { + context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| { + is_c_family(node) + && (node.kind == NodeKind::TypeAlias || is_declaring_kind(node.kind)) + && visible.is_none_or(|visible| visible(node)) + }) + .collect() +} + +fn declared_in( + scope: &str, + name: &str, + named: &[Arc], + templated: bool, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let qualified = if scope.is_empty() { + name.to_string() + } else { + format!("{scope}::{name}") + }; + let here: Vec<&Arc> = named + .iter() + .filter(|node| node.qualified_name == qualified) + // An alias template is only ever named with template arguments. + .filter(|node| { + templated || node.kind != NodeKind::TypeAlias || !is_alias_template(node, context) + }) + .collect(); + if here.is_empty() { + return None; + } + // `typedef struct Node Node;` declares the struct as well. + if here.iter().any(|node| is_class_kind(node.kind)) { + return Some(Declared::Class(qualified)); + } + if here.iter().any(|node| node.kind != NodeKind::TypeAlias) { + return Some(Declared::Other); + } + Some(pick_alias(&here, reference, context).map_or_else( + || Declared::Ambiguous(here.iter().map(|alias| Arc::clone(alias)).collect()), + Declared::Alias, + )) +} + +/// One of several aliases a scope declares under one name (a translation unit +/// sees one of them): the one in the calling file, then in its header, then +/// any of them when they all name the same type. +fn pick_alias( + aliases: &[&Arc], + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + if let [only] = aliases { + return Some(Arc::clone(only)); + } + let same_file: Vec<&&Arc> = aliases + .iter() + .filter(|alias| alias.file_path == reference.file_path) + .collect(); + if let [only] = same_file.as_slice() { + return Some(Arc::clone(only)); + } + let stem = |path: &str| -> String { + match path.rfind('.') { + Some(dot) if !path[dot + 1..].contains(['/', '\\']) && dot + 1 < path.len() => { + path[..dot].to_string() + } + _ => path.to_string(), + } + }; + let own_stem = stem(&reference.file_path); + let header: Vec<&&Arc> = aliases + .iter() + .filter(|alias| stem(&alias.file_path) == own_stem) + .collect(); + if let [only] = header.as_slice() { + return Some(Arc::clone(only)); + } + let named: HashSet> = aliases + .iter() + .map(|alias| { + let written = declaration_text(alias, context) + .and_then(|text| aliased_type_in(&text, &alias.name))?; + Some(cpp_type_name(&written).map_or(written, |name| name.names.join("::"))) + }) + .collect(); + (named.len() == 1 && !named.contains(&None)).then(|| Arc::clone(aliases[0])) +} + +/// Add the parameter names of the `template <…>` header that ends right where +/// a declaration starts. +fn collect_template_header( + facts: &SourceFacts, + start_line: i64, + start_column: i64, + names: &mut BTreeSet, +) { + let before = text_before(facts, start_line, start_column, "\n"); + let Some(at) = before.rfind("template") else { + return; + }; + static HEADER: std::sync::OnceLock = std::sync::OnceLock::new(); + let header = + HEADER.get_or_init(|| Regex::new(r"^template\s*<([\s\S]*)>\s*$").expect("template list")); + let Some(list) = header.captures(&before[at..]) else { + return; + }; + static LAST_NAME: std::sync::OnceLock = std::sync::OnceLock::new(); + let last_name = + LAST_NAME.get_or_init(|| Regex::new(r"([A-Za-z_]\w*)\s*$").expect("parameter name")); + for item in split_top_level(&list[1]) { + // `typename T = int`, `template class Policy`, `typename... Ts`, `int N`. + let stripped = strip_template_arguments(item); + let declared = stripped.split('=').next().unwrap_or_default(); + if let Some(name) = last_name.captures(declared).map(|caps| caps[1].to_string()) + && name != "typename" + && name != "class" + { + names.insert(name); + } + } +} + +/// The template parameters in scope where `node` is declared: its own +/// `template <…>` header and those of the class templates around it. +pub(crate) fn template_parameters_around( + node: &Node, + context: &dyn ResolutionContext, +) -> Arc> { + let Some(facts) = context.source_facts(&node.file_path) else { + return Arc::default(); + }; + facts.cpp_template_parameters(&node.id, |facts| { + let mut names = BTreeSet::new(); + collect_template_header(facts, node.start_line, node.start_column, &mut names); + let mut scope = parent_scope(&node.qualified_name); + while !scope.is_empty() { + for class in context.get_nodes_by_qualified_name_shared(scope) { + if !is_c_family(&class) + || !is_class_kind(class.kind) + || class.file_path != node.file_path + || node.start_line < class.start_line + || node.start_line > end_line(&class) + { + continue; + } + collect_template_header(facts, class.start_line, class.start_column, &mut names); + } + scope = parent_scope(scope); + } + names + }) +} + +/// The first `:` of `text` that is not half of a `::`. +fn single_colon(text: &str) -> Option { + let bytes = text.as_bytes(); + (0..bytes.len()).find(|&at| { + bytes[at] == b':' && (at == 0 || bytes[at - 1] != b':') && bytes.get(at + 1) != Some(&b':') + }) +} + +/// The qualified names of the classes `class` derives from, read from the +/// base clause of each of its own declarations (`struct ParseProto2Descriptor +/// : Proto2Descriptor {`) and looked up from the scope the class is declared +/// in. +fn base_classes_of(class: &str, context: &dyn ResolutionContext) -> Vec { + let mut bases: Vec = Vec::new(); + for declaration in context.get_nodes_by_qualified_name_shared(class) { + if !is_c_family(&declaration) || !is_class_kind(declaration.kind) { + continue; + } + let Some(facts) = context.source_facts(&declaration.file_path) else { + continue; + }; + let own = facts.cpp_class_bases(&declaration.id, |facts| { + declared_bases(&declaration, facts, context) + }); + for base in own.iter() { + if base != class && !bases.contains(base) { + bases.push(base.clone()); + } + } + } + bases +} + +/// The bases one class declaration's head names, looked up from the scope the +/// class is declared in. +fn declared_bases( + declaration: &Node, + facts: &SourceFacts, + context: &dyn ResolutionContext, +) -> Vec { + let start = line_index(declaration.start_line); + let end = start.saturating_add(HEAD_LINES).min(facts.line_count()); + if start >= end { + return Vec::new(); + } + let mut head: Vec<&str> = (start..end).map(|index| facts.line(index)).collect(); + head[0] = columns(head[0], column(declaration.start_column), usize::MAX); + let text = head.join("\n"); + let Some(brace) = text.find('{') else { + return Vec::new(); + }; + // A forward declaration (`class Foo;`) has no base clause of its own. + if text[..brace].contains(';') { + return Vec::new(); + } + let Some(colon) = single_colon(&text[..brace]) else { + return Vec::new(); + }; + static ACCESS: std::sync::OnceLock = std::sync::OnceLock::new(); + let access = ACCESS.get_or_init(|| { + Regex::new(r"\b(?:public|protected|private|virtual)\b").expect("access specifier") + }); + let scopes = scopes_within(parent_scope(&declaration.qualified_name)); + let mut bases = Vec::new(); + for part in split_top_level(&text[colon + 1..brace]) { + let Some(name) = cpp_type_name(&access.replace_all(part, " ")) else { + continue; + }; + if let Some(base) = class_named(&name.names, &scopes, context) + && !bases.contains(&base) + { + bases.push(base); + } + } + bases +} + +/// The qualified name of the class `names` spells, looked up from `scopes`. +fn class_named( + names: &[String], + scopes: &[String], + context: &dyn ResolutionContext, +) -> Option { + let spelled = names.join("::"); + scopes.iter().find_map(|scope| { + let qualified = if scope.is_empty() { + spelled.clone() + } else { + format!("{scope}::{spelled}") + }; + context + .get_nodes_by_qualified_name_shared(&qualified) + .iter() + .any(|node| is_c_family(node) && is_class_kind(node.kind)) + .then_some(qualified) + }) +} + +/// `name` declared in class `class` or one of its bases. +#[allow(clippy::too_many_arguments)] +fn member_of( + class: &str, + name: &str, + named: &[Arc], + templated: bool, + reference: &RefView, + context: &dyn ResolutionContext, + depth: u32, + seen: &mut HashSet, +) -> Option { + let own = declared_in(class, name, named, templated, reference, context); + if own.is_some() || depth >= MAX_BASE_DEPTH { + return own; + } + seen.insert(class.to_string()); + for base in base_classes_of(class, context) { + if seen.contains(&base) { + continue; + } + let inherited = member_of( + &base, + name, + named, + templated, + reference, + context, + depth + 1, + seen, + ); + if inherited.is_some() { + return inherited; + } + } + None +} + +/// `name` looked up unqualified from `scopes`, innermost first; a class scope +/// includes its bases. +fn lookup_unqualified( + name: &str, + templated: bool, + scopes: &[String], + reference: &RefView, + context: &dyn ResolutionContext, + visible: Visible<'_>, +) -> Option { + let named = type_declarations(name, context, visible); + if named.is_empty() { + return None; + } + scopes.iter().find_map(|scope| { + if scope.is_empty() { + declared_in("", name, &named, templated, reference, context) + } else { + member_of( + scope, + name, + &named, + templated, + reference, + context, + 0, + &mut HashSet::new(), + ) + } + }) +} + +/// The type a C++ declared type names once the aliases in it are followed, +/// looked up from the code that makes `reference`. +pub(crate) fn resolve_cpp_aliased_type( + raw_type: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> CppAliased { + let Some(name) = cpp_type_name(raw_type) else { + return CppAliased::NotAnAlias; + }; + let caller = context.get_node_by_id_shared(&reference.from_node_id); + let scope = caller_scope(caller.as_deref()); + let resolved = match local_aliased_type(&name.names[0], caller.as_deref(), reference, context) { + Some(local) => { + let parameters = caller.map_or_else(Arc::default, |caller| { + template_parameters_around(&caller, context) + }); + match expand( + local.as_deref(), + &name.names[1..], + &name.templated[1..], + &scope, + reference, + context, + 1, + Some(¶meters), + ) { + Some(followed) => CppAliased::Followed(followed), + None => CppAliased::Unreadable, + } + } + None => follow(&name, &scope, reference, context, 0), + }; + // `Table* table` is a pointer whatever `Table` names. + match resolved { + CppAliased::Followed(mut followed) if is_cpp_pointer_type(raw_type) => { + followed.pointer = true; + CppAliased::Followed(followed) + } + other => other, + } +} + +/// The name receiver inference gives a type resolved through aliases: the +/// aliased type's last segment (`SkipList`), as for any declared type, with +/// the nested part of a name reached through the alias kept on it +/// (`SkipList::Iterator`), since that owner is what names the nested type. +pub(crate) fn cpp_aliased_type_name(resolved: &CppAliasedType) -> String { + resolved + .target + .last() + .into_iter() + .chain(resolved.rest.iter()) + .cloned() + .collect::>() + .join("::") +} + +/// What a C++ type name names when it is looked up for a class (upstream +/// #2407 `CppNamedClass`). +#[derive(Debug, Clone)] +pub(crate) enum CppNamedClass { + /// A class, by its qualified name. + Class(String), + /// An alias that leads to none of the project's classes: one of a type + /// outside the project (`template using bool_constant = + /// std::integral_constant;`), of what a template argument + /// decides, or one each `#if` arm declares differently (the first + /// declaration). + Alias(Arc), + /// No particular class: an enum, a member of a type outside the project. + NoClass, + /// Nothing in the scopes looked in declares it. + NotFound, +} + +/// The class a C++ type written as `written` names, looked up from `scopes` +/// (innermost first) as a declared type is (a class scope with its bases, a +/// qualified name a segment at a time) and followed through an alias it +/// reaches: rocksdb's `InternalIterator` under `using InternalIterator = +/// InternalIteratorBase;` names `InternalIteratorBase` (upstream +/// #2407 `cppClassNamed`). `visible` narrows the declarations the lookup can +/// see. +pub(crate) fn cpp_class_named( + written: &str, + scopes: &[String], + reference: &RefView, + context: &dyn ResolutionContext, + visible: Visible<'_>, +) -> CppNamedClass { + let Some(name) = cpp_type_name(written) else { + return CppNamedClass::NoClass; + }; + let (found, next) = lookup_segments(&name, scopes, reference, context, visible); + let whole = next == name.names.len(); + match found { + None => CppNamedClass::NotFound, + Some(Declared::Other) => CppNamedClass::NoClass, + Some(Declared::Class(class)) => CppNamedClass::Class(class), + Some(Declared::Ambiguous(mut aliases)) => { + // An alias each `#if` arm declares differently: the first + // declaration (by path, then line). + aliases.sort_by(|a, b| { + a.file_path + .cmp(&b.file_path) + .then(a.start_line.cmp(&b.start_line)) + }); + match aliases.into_iter().next() { + Some(first) if whole => CppNamedClass::Alias(first), + _ => CppNamedClass::NoClass, + } + } + Some(Declared::Alias(alias)) => { + let written = declaration_text(&alias, context) + .and_then(|text| aliased_type_in(&text, &alias.name)); + // The aliased type is written in the alias's own scope. + let parameters = template_parameters_around(&alias, context); + let aliased = expand( + written.as_deref(), + &name.names[next..], + &name.templated[next..], + parent_scope(&alias.qualified_name), + reference, + context, + 1, + Some(¶meters), + ); + if let Some(aliased) = aliased.filter(|aliased| !aliased.pointer) { + let names: Vec = aliased + .target + .iter() + .chain(&aliased.rest) + .cloned() + .collect(); + if let Some(class) = class_named(&names, &scopes_within(&aliased.scope), context) { + return CppNamedClass::Class(class); + } + } + // A member of a type outside the project (`Alias::Inner`) is not + // the alias. + if whole { + CppNamedClass::Alias(alias) + } else { + CppNamedClass::NoClass + } + } + } +} + +/// What a written name declares, looked up from `scopes` (innermost first), +/// and how many of its segments that took: a class's member is looked up in +/// the class, and an alias ends the walk (the rest of the name starts at the +/// returned index). `visible` narrows the declarations the lookup can see. +fn lookup_segments( + name: &TypeName, + scopes: &[String], + reference: &RefView, + context: &dyn ResolutionContext, + visible: Visible<'_>, +) -> (Option, usize) { + let mut found = lookup_unqualified( + &name.names[0], + name.templated[0], + scopes, + reference, + context, + visible, + ); + let mut next = 1usize; + if found.is_none() && name.names.len() > 1 { + // Namespaces are not indexed: `detail::buffer_t` from inside `fmt` is + // found by its spelling in an enclosing scope, longest prefix first. + let mut end = name.names.len(); + while end >= 2 && found.is_none() { + let spelled = &name.names[..end]; + let last = &spelled[end - 1]; + let named = type_declarations(last, context, visible); + let prefix = spelled[..end - 1].join("::"); + found = scopes.iter().find_map(|outer| { + let within = if outer.is_empty() { + prefix.clone() + } else { + format!("{outer}::{prefix}") + }; + declared_in( + &within, + last, + &named, + name.templated[end - 1], + reference, + context, + ) + }); + next = end; + end -= 1; + } + } + // `MemTable::Table`, `Table::Iterator`: one segment at a time. + while next < name.names.len() { + let Some(Declared::Class(class)) = &found else { + break; + }; + let class = class.clone(); + let segment = &name.names[next]; + found = member_of( + &class, + segment, + &type_declarations(segment, context, visible), + name.templated[next], + reference, + context, + 0, + &mut HashSet::new(), + ); + next += 1; + } + (found, next) +} + +fn follow( + name: &TypeName, + scope: &str, + reference: &RefView, + context: &dyn ResolutionContext, + hops: u32, +) -> CppAliased { + let (found, next) = lookup_segments(name, &scopes_within(scope), reference, context, None); + let Some(Declared::Alias(alias)) = found else { + return CppAliased::NotAnAlias; + }; + if hops >= MAX_HOPS { + return CppAliased::Unreadable; + } + let written = + declaration_text(&alias, context).and_then(|text| aliased_type_in(&text, &alias.name)); + // The aliased type is written in the alias's own scope. + let parameters = template_parameters_around(&alias, context); + match expand( + written.as_deref(), + &name.names[next..], + &name.templated[next..], + parent_scope(&alias.qualified_name), + reference, + context, + hops + 1, + Some(¶meters), + ) { + Some(followed) => CppAliased::Followed(followed), + None => CppAliased::Unreadable, + } +} + +/// Continue from the type an alias names, as written (`rest` followed the +/// alias in the name; `parameters` are the template parameters where it is +/// written). `None` when that names no particular class. +#[allow(clippy::too_many_arguments)] +fn expand( + written: Option<&str>, + rest: &[String], + rest_templated: &[bool], + scope: &str, + reference: &RefView, + context: &dyn ResolutionContext, + hops: u32, + parameters: Option<&BTreeSet>, +) -> Option { + // `typename Traits::Field`, `PolicyTemplate` inside the template + // that declares `PolicyTemplate`: whatever a template argument makes it. + let written = written?; + static TYPENAME: std::sync::OnceLock = std::sync::OnceLock::new(); + if TYPENAME + .get_or_init(|| Regex::new(r"\btypename\b").expect("typename")) + .is_match(written) + { + return None; + } + let target = cpp_type_name(written)?; + if parameters.is_some_and(|parameters| parameters.contains(&target.names[0])) { + return None; + } + let pointer = is_cpp_pointer_type(written); + let names: Vec = target.names.iter().chain(rest).cloned().collect(); + let templated: Vec = target + .templated + .iter() + .chain(rest_templated) + .copied() + .collect(); + match follow( + &TypeName { + names: names.clone(), + templated, + }, + scope, + reference, + context, + hops, + ) { + CppAliased::Unreadable => return None, + CppAliased::Followed(further) => { + return Some(CppAliasedType { + pointer: further.pointer || pointer, + ..further + }); + } + CppAliased::NotAnAlias => {} + } + let class = class_named(&names, &scopes_within(scope), context); + let class_template = class.is_some_and(|class| { + context + .get_nodes_by_qualified_name_shared(&class) + .iter() + .any(|node| { + is_c_family(node) + && is_class_kind(node.kind) + && !template_parameters_around(node, context).is_empty() + }) + }); + Some(CppAliasedType { + target: target.names, + rest: rest.to_vec(), + scope: scope.to_string(), + pointer, + class_template, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn segments(raw: &str) -> Option> { + cpp_type_segments(raw) + } + + fn strings(names: &[&str]) -> Option> { + Some(names.iter().map(|name| (*name).to_string()).collect()) + } + + #[test] + fn a_written_type_reads_as_its_segments_without_qualifiers_or_template_arguments() { + assert_eq!( + segments("SkipList"), + strings(&["SkipList"]) + ); + assert_eq!( + segments("const ::leveldb::SkipList>::Iterator&"), + strings(&["leveldb", "SkipList", "Iterator"]) + ); + assert_eq!( + segments("typename Base::Iter"), + strings(&["Base", "Iter"]) + ); + assert_eq!(segments("struct Node*"), strings(&["Node"])); + assert_eq!(segments("unsigned int"), None); + assert_eq!(segments("void (*)(int)"), None); + } + + /// Upstream #2443's `stripCppTemplateArguments` cases. + #[test] + fn template_arguments_go_with_the_ones_nested_in_them() { + assert_eq!( + strip_template_arguments("autovector>"), + "autovector" + ); + assert_eq!( + strip_template_arguments("Striped>"), + "Striped" + ); + assert_eq!( + strip_template_arguments("const std::map >&"), + "const std::map&" + ); + assert_eq!( + strip_template_arguments("SkipList>::Iterator"), + "SkipList::Iterator" + ); + } + + #[test] + fn templated_segments_are_marked() { + let name = cpp_type_name("ns::Table::Iterator").expect("type name"); + assert_eq!(name.names, ["ns", "Table", "Iterator"]); + assert_eq!(name.templated, [false, true, false]); + } + + #[test] + fn an_alias_declaration_reads_as_the_type_it_names() { + assert_eq!( + aliased_type_in( + "typedef SkipList Table;", + "Table" + ) + .as_deref(), + Some("SkipList") + ); + assert_eq!( + aliased_type_in( + "using Table = SkipList;", + "Table" + ) + .as_deref(), + Some("SkipList") + ); + assert_eq!( + aliased_type_in( + "using Field [[deprecated]] = const FieldDescriptor*;", + "Field" + ) + .as_deref(), + Some("const FieldDescriptor*") + ); + // The first declarator of a typedef is the indexed one. + assert_eq!( + aliased_type_in("typedef Foo* FooPtr, FooObj;", "FooPtr").as_deref(), + Some("Foo*") + ); + assert_eq!( + aliased_type_in("typedef Foo* FooPtr, FooObj;", "FooObj"), + None + ); + assert_eq!(aliased_type_in("int Table = 3;", "Table"), None); + } + + #[test] + fn pointers_ignore_template_arguments() { + assert!(is_cpp_pointer_type("const Foo* const")); + assert!(is_cpp_pointer_type("Table*")); + assert!(!is_cpp_pointer_type("std::vector")); + } + + #[test] + fn commas_split_only_at_the_top_level() { + assert_eq!( + split_top_level("A, D(e, f), G"), + ["A", " D(e, f)", " G"] + ); + } + + #[test] + fn scopes_run_from_the_innermost_to_the_global_scope() { + assert_eq!(scopes_within("a::b"), ["a::b", "a", ""]); + assert_eq!(scopes_within(""), [""]); + assert_eq!( + parent_scope("leveldb::MemTable::Table"), + "leveldb::MemTable" + ); + assert_eq!(parent_scope("Table"), ""); + } + + #[test] + fn a_single_colon_is_not_half_of_a_scope_operator() { + assert_eq!(single_colon("struct A : B"), Some(9)); + assert_eq!(single_colon("struct ns::A : ns::B"), Some(13)); + assert_eq!(single_colon("struct ns::A"), None); + } + + #[test] + fn columns_clamp_to_the_line_and_character_boundaries() { + assert_eq!(columns("typedef A B;", 0, 99), "typedef A B;"); + assert_eq!(columns("ab", 5, 9), ""); + // A column inside a character moves back to its start. + assert_eq!(columns("aé", 0, 2), "a"); + } +} diff --git a/crates/codegraph-resolve/src/csharp_extensions.rs b/crates/codegraph-resolve/src/csharp_extensions.rs new file mode 100644 index 00000000..e63f7f65 --- /dev/null +++ b/crates/codegraph-resolve/src/csharp_extensions.rs @@ -0,0 +1,285 @@ +//! A C# call on a type the project does not declare reaches the project's +//! extension method for that type (KEEP-RUST, owner 2026-10-11, W3-05c). +//! +//! Upstream #2169 stops name guessing when a receiver's type is an outside +//! one, which also stopped Newtonsoft.Json's `objectType.IsEnum()` (a +//! `System.Type`) from reaching its own `TypeExtensions.IsEnum(this Type)`. +//! At that stop, a C# method is the target when it is the one candidate that +//! takes the receiver's type as its `this` parameter (written without a +//! namespace qualifier, and not one of the method's own type parameters), is +//! in scope at the call (its namespace is the caller's, an enclosing one, or +//! named by a `using` in scope there; see [`crate::csharp_scopes`]), and +//! takes the call's arguments. No candidate, or several, keeps the stop. + +use std::sync::Arc; + +use codegraph_core::types::{Language, Node, NodeKind}; + +use crate::overloads::{is_pack, paren_list_after, split_top_level}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// The project extension method a C# call `reference` on a receiver of the +/// outside type `type_name` reaches, if exactly one fits. +pub(crate) fn extension_method_target( + type_name: &str, + method_name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + if reference.language != Language::CSharp { + return None; + } + let receiver_type = simple_type_name(type_name)?; + let call_facts = context.source_facts(&reference.file_path)?; + let call_offset = offset_of(&call_facts, reference.line, reference.column)?; + let call_scopes = call_facts.csharp_scopes(); + let call_namespace = call_scopes.namespace_at(call_offset); + let arguments = paren_list_after( + &reference.file_path, + reference.line, + usize::try_from(reference.column).unwrap_or(0), + method_name, + context, + )?; + let argument_count = if arguments.trim().is_empty() { + 0 + } else { + split_top_level(&arguments).len() + }; + let fitting: Vec> = context + .get_nodes_by_name_shared(method_name) + .into_iter() + .filter(|candidate| { + candidate.kind == NodeKind::Method + && candidate.language == Language::CSharp + && extension_fits(candidate, &receiver_type, argument_count, context) + && namespace_of(candidate, context).is_some_and(|namespace| { + namespace.is_empty() + || call_namespace == namespace + || call_namespace.starts_with(&format!("{namespace}.")) + || call_scopes.imports_at(call_offset, &namespace) + }) + }) + .collect(); + let chosen = match fitting.as_slice() { + [only] => only.id.clone(), + // Overloads of one type that all take the call: C#'s overload rules + // may still pick one (`format.FormatWith(provider, new object?[] { + // … })` takes the `params` overload); a pick by position never. + [first, rest @ ..] + if rest.iter().all(|other| { + owner_of(&other.qualified_name) == owner_of(&first.qualified_name) + }) => + { + let arguments = if arguments.trim().is_empty() { + Vec::new() + } else { + split_top_level(&arguments) + }; + crate::overloads::csharp_extension_unique_choice(&fitting, &arguments, context)? + } + _ => return None, + }; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: chosen, + confidence: 0.9, + resolved_by: ResolvedBy::InstanceMethod, + }) +} + +/// What a qualified name is declared in: all but its last `::` segment. +fn owner_of(qualified_name: &str) -> &str { + qualified_name + .rsplit_once("::") + .map_or("", |(owner, _)| owner) +} + +/// Whether `method` declares a `this` parameter of `receiver_type` and takes +/// `argument_count` arguments besides it. +fn extension_fits( + method: &Node, + receiver_type: &str, + argument_count: usize, + context: &dyn ResolutionContext, +) -> bool { + let Some(list) = paren_list_after( + &method.file_path, + method.start_line, + 0, + &method.name, + context, + ) else { + return false; + }; + let params = split_top_level(&list); + let Some(this_type) = params.first().and_then(|first| this_parameter_type(first)) else { + return false; + }; + if this_type != receiver_type || is_own_type_parameter(method, &this_type, context) { + return false; + } + let rest = ¶ms[1..]; + let min = rest + .iter() + .filter(|p| !p.contains('=') && !is_pack(p)) + .count(); + let variadic = rest.iter().any(|p| is_pack(p)); + argument_count >= min && (variadic || argument_count <= rest.len()) +} + +/// The type of a `this` parameter (`this Type type`, `[NotNull] this +/// IList? list`, `this in Span s`) as a simple name, or `None` for a +/// parameter without `this` or a type written with a namespace qualifier. +pub(crate) fn this_parameter_type(param: &str) -> Option { + let mut rest = after_this(param)?; + for modifier in ["scoped", "ref", "in", "readonly"] { + if let Some(after) = rest.strip_prefix(modifier) + && after.starts_with(char::is_whitespace) + { + rest = after.trim_start(); + } + } + let declared = rest.split('=').next().unwrap_or(rest).trim(); + let bare = strip_type_arguments(declared); + let (written, _name) = bare.trim().rsplit_once(char::is_whitespace)?; + let written = written.trim(); + if written.contains('.') || written.contains("::") { + return None; + } + simple_type_name(written) +} + +/// Whether a declared parameter is a C# extension method's `this` one, +/// attributes before it allowed (`[NotNull] this PropertyInfo p`). +/// A `this` parameter's declaration without its name, comments dropped and +/// whitespace runs collapsed (`IList`, `ref Span`), or `None` when +/// `param` is no `this` parameter. +pub(crate) fn this_parameter_text(param: &str) -> Option { + let declared = crate::csharp_scopes::blank_non_code(after_this(param)?); + let (written, _name) = declared.trim_end().rsplit_once(char::is_whitespace)?; + Some(written.split_whitespace().collect::>().join(" ")) +} + +pub(crate) fn is_this_parameter(param: &str) -> bool { + after_this(param).is_some() +} + +/// What follows the `this` modifier of a parameter, past its attributes. +fn after_this(param: &str) -> Option<&str> { + let mut rest = param.trim(); + while let Some(after) = rest.strip_prefix('[') { + rest = after.split_once(']')?.1.trim_start(); + } + let rest = rest.strip_prefix("this")?; + rest.starts_with(char::is_whitespace) + .then(|| rest.trim_start()) +} + +/// A type's simple name: type arguments, `?` and a trailing `[]` kept out +/// (`IList?` → `IList`); `None` for a qualified or empty name. +fn simple_type_name(written: &str) -> Option { + let bare = strip_type_arguments(written); + let name = bare.trim().trim_end_matches('?').trim(); + let name = name.rsplit('.').next().unwrap_or(name); + (!name.is_empty() + && name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '[' || c == ']')) + .then(|| name.to_string()) +} + +/// `text` without its `<…>` groups, nested ones with them. +fn strip_type_arguments(text: &str) -> String { + let mut depth = 0usize; + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '<' => depth += 1, + '>' => depth = depth.saturating_sub(1), + _ if depth == 0 => out.push(c), + _ => {} + } + } + out +} + +/// Whether `name` is one of `method`'s own type parameters +/// (`static T Twice(this T value)`). +fn is_own_type_parameter(method: &Node, name: &str, context: &dyn ResolutionContext) -> bool { + let Some(facts) = context.source_facts(&method.file_path) else { + return false; + }; + let Some(start) = usize::try_from(method.start_line) + .ok() + .and_then(|line| line.checked_sub(1)) + else { + return false; + }; + let end = (start + 12).min(facts.line_count()); + if start >= end { + return false; + } + let head = facts.join_lines(start, end, " "); + let Some(at) = head.find(&format!("{}<", method.name)) else { + return false; + }; + let list = &head[at + method.name.len() + 1..]; + let list = list.split('>').next().unwrap_or_default(); + list.split(',').any(|param| param.trim() == name) +} + +/// The namespace `method` is declared in, read from its file's source. +fn namespace_of(method: &Node, context: &dyn ResolutionContext) -> Option { + let facts = context.source_facts(&method.file_path)?; + let offset = offset_of(&facts, method.start_line, method.start_column)?; + Some(facts.csharp_scopes().namespace_at(offset)) +} + +/// The byte offset of 1-based `line`, `column` in a file's source. +fn offset_of(facts: &crate::source_facts::SourceFacts, line: i64, column: i64) -> Option { + let index = usize::try_from(line).ok()?.checked_sub(1)?; + let start = facts.lines().raw_line_start(index)?; + Some(start + usize::try_from(column).unwrap_or(0)) +} + +#[cfg(test)] +mod tests { + use super::{simple_type_name, this_parameter_type}; + + #[test] + fn a_this_parameter_names_its_type() { + assert_eq!( + this_parameter_type("this Type type").as_deref(), + Some("Type") + ); + assert_eq!( + this_parameter_type("this ILGenerator generator").as_deref(), + Some("ILGenerator") + ); + assert_eq!( + this_parameter_type("[NotNull] this IList? types").as_deref(), + Some("IList") + ); + assert_eq!( + this_parameter_type("this in Span span").as_deref(), + Some("Span") + ); + assert_eq!( + this_parameter_type("this Type type = null").as_deref(), + Some("Type") + ); + assert_eq!(this_parameter_type("Type type"), None); + assert_eq!(this_parameter_type("thisType type"), None); + assert_eq!(this_parameter_type("this System.Timers.Timer timer"), None); + assert_eq!(this_parameter_type("this global::System.Type type"), None); + } + + #[test] + fn a_receiver_type_is_read_as_a_simple_name() { + assert_eq!(simple_type_name("Type").as_deref(), Some("Type")); + assert_eq!(simple_type_name("List?").as_deref(), Some("List")); + assert_eq!(simple_type_name("System.Type").as_deref(), Some("Type")); + assert_eq!(simple_type_name(""), None); + } +} diff --git a/crates/codegraph-resolve/src/csharp_scopes.rs b/crates/codegraph-resolve/src/csharp_scopes.rs new file mode 100644 index 00000000..120a41d8 --- /dev/null +++ b/crates/codegraph-resolve/src/csharp_scopes.rs @@ -0,0 +1,450 @@ +//! The namespace blocks and `using` directives of a C# file, read from its +//! source by range (W3-05c). +//! +//! The C# extraction files every symbol of a file under its first root +//! namespace (`Nested.cs`'s `Outer.Inner` and sibling `Second` types are all +//! `Outer::…` until W4-CS-01), so whether an extension method is in scope at a +//! call is read here instead: comments, strings, characters and the inactive +//! branches of `#if` blocks are blanked, then `namespace X { … }` blocks are +//! paired by their braces (a nested block's name joins its parents': +//! `Outer.Inner`) and a file-scoped `namespace X;` covers the rest of the +//! file. Each `using X;` belongs to the namespace block it is written in, or +//! to the whole file outside any; `using static`, an alias `using A = X;` and +//! a `using` statement in a body import no namespace. + +/// One file's namespace blocks and `using` directives. +#[derive(Debug, Default)] +pub(crate) struct CsharpScopes { + /// Namespace blocks: the byte range of the body braces and the full name. + blocks: Vec, + /// A file-scoped namespace: where it is declared and its name. + file_namespace: Option<(usize, String)>, + /// `using X;` directives: the namespace named and the block holding it + /// (`None` for file level, a `global using` included). + usings: Vec<(String, Option)>, +} + +#[derive(Debug)] +struct Block { + start: usize, + end: usize, + name: String, +} + +impl CsharpScopes { + /// Read `source`'s namespace blocks and `using` directives. + pub(crate) fn read(source: &str) -> Self { + let code = blank_non_code(source); + let tokens = tokenize(&code); + let mut scopes = Self::default(); + // Open braces: `Some(block)` for a namespace body, `None` otherwise. + let mut open: Vec> = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let at_namespace_level = open.last().is_none_or(Option::is_some); + let enclosing = open.last().copied().flatten(); + match tokens[i].text { + "{" => open.push(None), + "}" => { + if let Some(Some(block)) = open.pop() { + scopes.blocks[block].end = tokens[i].at; + } + } + "namespace" if at_namespace_level => { + let (name, next) = qualified_name(&tokens, i + 1); + match tokens.get(next).map(|t| t.text) { + Some("{") if !name.is_empty() => { + let full = match enclosing { + Some(parent) => format!("{}.{name}", scopes.blocks[parent].name), + None => match &scopes.file_namespace { + Some((_, file)) => format!("{file}.{name}"), + None => name, + }, + }; + scopes.blocks.push(Block { + start: tokens[next].at, + end: code.len(), + name: full, + }); + open.push(Some(scopes.blocks.len() - 1)); + i = next + 1; + continue; + } + Some(";") if !name.is_empty() && enclosing.is_none() => { + scopes.file_namespace = Some((tokens[i].at, name)); + i = next + 1; + continue; + } + _ => {} + } + } + "using" if at_namespace_level => { + let global = i > 0 && tokens[i - 1].text == "global"; + let (name, next) = qualified_name(&tokens, i + 1); + if !name.is_empty() + && name != "static" + && tokens.get(next).map(|t| t.text) == Some(";") + { + let name = name.strip_prefix("global::").unwrap_or(&name).to_string(); + scopes + .usings + .push((name, if global { None } else { enclosing })); + i = next + 1; + continue; + } + } + _ => {} + } + i += 1; + } + scopes + } + + /// The namespace the code at byte `offset` is in (`""` for the global + /// namespace). + pub(crate) fn namespace_at(&self, offset: usize) -> String { + self.innermost(offset) + .map(|block| self.blocks[block].name.clone()) + .or_else(|| { + self.file_namespace + .as_ref() + .filter(|(at, _)| *at <= offset) + .map(|(_, name)| name.clone()) + }) + .unwrap_or_default() + } + + /// Whether a `using namespace;` is in scope at byte `offset`: one at + /// file level, or one in a namespace block around `offset`. + pub(crate) fn imports_at(&self, offset: usize, namespace: &str) -> bool { + self.usings.iter().any(|(name, block)| { + name == namespace + && block.is_none_or(|block| { + let block = &self.blocks[block]; + block.start <= offset && offset <= block.end + }) + }) + } + + fn innermost(&self, offset: usize) -> Option { + self.blocks + .iter() + .enumerate() + .filter(|(_, block)| block.start <= offset && offset <= block.end) + .min_by_key(|(_, block)| block.end - block.start) + .map(|(index, _)| index) + } +} + +#[derive(Debug)] +struct Token<'a> { + at: usize, + text: &'a str, +} + +/// Identifiers (with a verbatim `@`) and single punctuation characters. +fn tokenize(code: &str) -> Vec> { + let bytes = code.as_bytes(); + let mut tokens = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b.is_ascii_whitespace() { + i += 1; + } else if b.is_ascii_alphanumeric() || b == b'_' || b == b'@' || b >= 0x80 { + let start = i; + i += 1; + while i < bytes.len() + && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] >= 0x80) + { + i += 1; + } + tokens.push(Token { + at: start, + text: &code[start..i], + }); + } else { + tokens.push(Token { + at: i, + text: &code[i..=i], + }); + i += 1; + } + } + tokens +} + +/// The dotted (or `global::`-prefixed) name starting at token `i`, and the +/// index of the token after it. +fn qualified_name(tokens: &[Token<'_>], mut i: usize) -> (String, usize) { + let mut name = String::new(); + if tokens.get(i).map(|t| t.text) == Some("global") + && tokens.get(i + 1).map(|t| t.text) == Some(":") + && tokens.get(i + 2).map(|t| t.text) == Some(":") + { + name.push_str("global::"); + i += 3; + } + while let Some(token) = tokens.get(i) { + if !token + .text + .starts_with(|c: char| c.is_alphanumeric() || c == '_' || c == '@') + { + break; + } + name.push_str(token.text.trim_start_matches('@')); + i += 1; + if tokens.get(i).map(|t| t.text) == Some(".") { + name.push('.'); + i += 1; + } else { + break; + } + } + (name, i) +} + +/// `source` with every comment, string and character literal (verbatim, +/// interpolated and raw ones included), preprocessor line and inactive +/// `#elif` / `#else` branch replaced by spaces (newlines kept), so only code +/// is left at the same offsets. +pub(crate) fn blank_non_code(source: &str) -> String { + let bytes = source.as_bytes(); + let mut out = bytes.to_vec(); + let blank = |out: &mut Vec, from: usize, to: usize| { + for byte in &mut out[from..to.min(bytes.len())] { + if *byte != b'\n' { + *byte = b' '; + } + } + }; + let mut i = 0; + // `#if` nesting, and the depth whose inactive branch is being skipped. + let mut if_depth = 0usize; + let mut skipping: Option = None; + let mut line_start = true; + while i < bytes.len() { + let b = bytes[i]; + if line_start && (b == b' ' || b == b'\t') { + i += 1; + continue; + } + if line_start && b == b'#' { + let end = line_end(bytes, i); + let directive = source[i + 1..end].trim_start(); + let word: String = directive + .chars() + .take_while(char::is_ascii_alphabetic) + .collect(); + match word.as_str() { + "if" => if_depth += 1, + "elif" | "else" => { + if skipping.is_none() { + skipping = Some(if_depth); + } + } + "endif" => { + if skipping == Some(if_depth) { + skipping = None; + } + if_depth = if_depth.saturating_sub(1); + } + _ => {} + } + blank(&mut out, i, end); + i = end; + continue; + } + line_start = false; + if b == b'\n' { + line_start = true; + i += 1; + continue; + } + if skipping.is_some() { + let end = line_end(bytes, i); + blank(&mut out, i, end); + i = end; + continue; + } + let end = match b { + b'/' if bytes.get(i + 1) == Some(&b'/') => line_end(bytes, i), + b'/' if bytes.get(i + 1) == Some(&b'*') => source[i + 2..] + .find("*/") + .map_or(bytes.len(), |at| i + 2 + at + 2), + b'\'' => char_literal_end(bytes, i), + b'"' | b'@' | b'$' => match string_end(bytes, i) { + Some(end) => end, + None => { + i += 1; + continue; + } + }, + _ => { + i += 1; + continue; + } + }; + blank(&mut out, i, end); + i = end; + } + String::from_utf8(out).unwrap_or_default() +} + +fn line_end(bytes: &[u8], from: usize) -> usize { + bytes[from..] + .iter() + .position(|&b| b == b'\n') + .map_or(bytes.len(), |at| from + at) +} + +/// The end of the character literal opening at `start`. +fn char_literal_end(bytes: &[u8], start: usize) -> usize { + let mut i = start + 1; + while i < bytes.len() && bytes[i] != b'\'' && bytes[i] != b'\n' { + i += if bytes[i] == b'\\' { 2 } else { 1 }; + } + (i + 1).min(bytes.len()) +} + +/// The end of the string literal starting at `start` (`"…"`, `@"…"`, +/// `$"…"`, `$@"…"`, `@$"…"`, `"""…"""`), or `None` when no string starts +/// there. Interpolation holes are skipped as code, strings in them included. +fn string_end(bytes: &[u8], start: usize) -> Option { + let mut i = start; + let mut verbatim = false; + let mut interpolated = 0usize; + while i < bytes.len() && (bytes[i] == b'@' || bytes[i] == b'$') { + if bytes[i] == b'@' { + verbatim = true; + } else { + interpolated += 1; + } + i += 1; + } + if bytes.get(i) != Some(&b'"') { + return None; + } + let quotes = bytes[i..].iter().take_while(|&&b| b == b'"').count(); + if quotes >= 3 { + // A raw string: closed by as many quotes as opened it. + let mut j = i + quotes; + while j < bytes.len() { + if bytes[j] == b'"' && bytes[j..].iter().take_while(|&&b| b == b'"').count() >= quotes { + return Some(j + quotes); + } + j += 1; + } + return Some(bytes.len()); + } + let mut j = i + 1; + while j < bytes.len() { + match bytes[j] { + b'\\' if !verbatim => j += 2, + b'"' if verbatim && bytes.get(j + 1) == Some(&b'"') => j += 2, + b'"' => return Some(j + 1), + b'\n' if !verbatim => return Some(j), + b'{' if interpolated > 0 && bytes.get(j + 1) == Some(&b'{') => j += 2, + b'{' if interpolated > 0 => j = hole_end(bytes, j + 1), + _ => j += 1, + } + } + Some(bytes.len()) +} + +/// The index after the `}` closing an interpolation hole whose code starts at +/// `from`. +fn hole_end(bytes: &[u8], from: usize) -> usize { + let mut depth = 1usize; + let mut i = from; + while i < bytes.len() { + match bytes[i] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return i + 1; + } + } + b'\'' => { + i = char_literal_end(bytes, i); + continue; + } + b'"' | b'@' | b'$' => { + if let Some(end) = string_end(bytes, i) { + i = end; + continue; + } + } + _ => {} + } + i += 1; + } + bytes.len() +} + +#[cfg(test)] +mod tests { + use super::CsharpScopes; + + fn at(source: &str, needle: &str) -> usize { + source.find(needle).expect("needle in source") + } + + #[test] + fn nested_and_sibling_blocks_and_their_usings() { + let source = "using System;\nnamespace Outer\n{\n using Outer.Extras;\n namespace Inner\n {\n class Deep { void M() { } }\n }\n class Mid { }\n}\nnamespace Second\n{\n using Local.Only;\n class Two { }\n}\nnamespace Third\n{\n class Three { }\n}\n"; + let scopes = CsharpScopes::read(source); + assert_eq!(scopes.namespace_at(at(source, "Deep")), "Outer.Inner"); + assert_eq!(scopes.namespace_at(at(source, "Mid")), "Outer"); + assert_eq!(scopes.namespace_at(at(source, "Two")), "Second"); + assert_eq!(scopes.namespace_at(at(source, "Three")), "Third"); + assert_eq!(scopes.namespace_at(0), ""); + assert!(scopes.imports_at(at(source, "Three"), "System")); + assert!(scopes.imports_at(at(source, "Deep"), "Outer.Extras")); + assert!(scopes.imports_at(at(source, "Two"), "Local.Only")); + assert!(!scopes.imports_at(at(source, "Three"), "Local.Only")); + assert!(!scopes.imports_at(at(source, "Mid"), "Local.Only")); + } + + #[test] + fn a_file_scoped_namespace_covers_the_rest_of_the_file() { + let source = "using System.Reflection;\nnamespace App.Utilities;\nusing App.Helpers;\npublic static class TypeExtensions { }\n"; + let scopes = CsharpScopes::read(source); + assert_eq!(scopes.namespace_at(at(source, "public")), "App.Utilities"); + assert!(scopes.imports_at(at(source, "public"), "System.Reflection")); + assert!(scopes.imports_at(at(source, "public"), "App.Helpers")); + } + + #[test] + fn static_alias_and_statement_usings_import_no_namespace() { + let source = "using static System.Math;\nusing Alias = System.Text;\nglobal using System.Linq;\nnamespace App\n{\n class C\n {\n void M() { using (var s = Open()) { } using var t = Open(); }\n }\n}\n"; + let scopes = CsharpScopes::read(source); + let inside = at(source, "void M"); + assert!(!scopes.imports_at(inside, "System.Math")); + assert!(!scopes.imports_at(inside, "System.Text")); + assert!(!scopes.imports_at(inside, "static")); + assert!(scopes.imports_at(inside, "System.Linq")); + assert_eq!(scopes.namespace_at(inside), "App"); + } + + #[test] + fn a_comment_marker_inside_a_raw_string_is_text() { + let declaration = "Note(this Box box, U value, string note = \"\"\"x\" // literal text\"\"\") where U : struct { return box; }"; + let blanked = super::blank_non_code(declaration); + assert!( + blanked.contains(") where U : struct { return box; }"), + "{blanked}" + ); + assert!(!blanked.contains("literal"), "{blanked}"); + assert_eq!(blanked.len(), declaration.len()); + } + + #[test] + fn braces_in_strings_comments_characters_and_inactive_branches_do_not_count() { + let source = "namespace A\n{\n class C\n {\n string a = \"{\";\n string b = @\"C:\\path\\\"\"{\";\n string c = $\"{(x ? \"}\" : \"{\")}{{\";\n string d = \"\"\"\n { raw }}}\n \"\"\";\n char e = '{';\n // }\n /* } */\n#if NET\n void F() {\n#else\n void F(int x) {\n#endif\n }\n }\n}\nnamespace B\n{\n class D { }\n}\n"; + let scopes = CsharpScopes::read(source); + assert_eq!(scopes.namespace_at(at(source, "class C")), "A"); + assert_eq!(scopes.namespace_at(at(source, "class D")), "B"); + } +} diff --git a/crates/codegraph-resolve/src/csharp_types.rs b/crates/codegraph-resolve/src/csharp_types.rs new file mode 100644 index 00000000..ff3e48d5 --- /dev/null +++ b/crates/codegraph-resolve/src/csharp_types.rs @@ -0,0 +1,1259 @@ +//! C# type names read at their site (W4-CS-01, upstream #2121's C# half, +//! #2216 and #2220 in their `b635dd46` shape). +//! +//! A bare C# name (`TestContext`, `Easing`, `Helper()`) can mean a member +//! only of the types around it, of their bases, or of a type a static using +//! brings in (upstream #2151). +//! +//! A reference in a type position (`Type name`, `List`, `Type?`, +//! `Type[]`, a generic argument, `new X`) names a type, never a member that +//! shares the name. A bare type name of namespace `N` is in reach of a file +//! whose code runs in `N` or a namespace inside it, or that imports `N`: its +//! own `using N;`, the `global using N;` of its project's files (every file's, +//! outside any project), the `` of the project files +//! above it, the SDK's implicit usings, or an alias naming the type. A nested +//! type is reached by bare name only from inside its owner, or from a type +//! deriving from it through any partial part. + +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use std::path::Path; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::strip_comments::{CommentLang, strip_comments_for_regex}; +use crate::types::{RefView, ResolutionContext}; + +/// Upstream `CSHARP_TYPE_KINDS` (`record` has no kind of its own here). +pub fn is_csharp_type_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Struct + ) +} + +/// JavaScript's `\s`, `U+FEFF` included: a C# file often opens with a byte +/// order mark, which upstream's `^\s*namespace` / `^\s*using` read past. +const JS_SPACE: &str = r"[\t\n\x0B\x0C\r \x{A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]"; + +/// A pattern with every `\s` read as JavaScript's (the C# readers, and the +/// Ruby head reader of W4-RB-01). +pub(crate) fn js_regex(pattern: &str) -> Regex { + Regex::new(&pattern.replace(r"\s", JS_SPACE)).expect("JavaScript-whitespace reader pattern") +} + +/// JavaScript's `\w`. +fn is_word(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +/// `^[A-Za-z_]\w*$`. +pub(crate) fn is_simple_identifier(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(is_word) +} + +/// Segment `line` (1-based) of `file.split('\n')`. +fn raw_line(file: &str, line: i64, context: &dyn ResolutionContext) -> Option { + let facts = context.source_facts(file)?; + let index = usize::try_from(line).ok()?.checked_sub(1)?; + facts.raw_line(index).map(str::to_string) +} + +/// Whether a C# reference's site is a type position (upstream #2121 +/// `isDotNetTypeRef`, its C# half): `Type sourceType`, `List`, +/// `new TypeMap()`, `Exception? e`, `Dictionary`. Read from the +/// source at the reference's column; a member read (`Builder.Services`), a +/// method group (`MapGet("/x", GetItems)`) or a route's handler is not one. +pub(crate) fn is_csharp_type_ref(reference: &RefView, context: &dyn ResolutionContext) -> bool { + static NEW: OnceLock = OnceLock::new(); + static DECLARES: OnceLock = OnceLock::new(); + static OPERATOR: OnceLock = OnceLock::new(); + static SHAPE: OnceLock = OnceLock::new(); + static ARGUMENT: OnceLock = OnceLock::new(); + static OPEN_GENERIC: OnceLock = OnceLock::new(); + if reference.language != Language::CSharp + || !matches!( + reference.reference_kind, + EdgeKind::References | EdgeKind::Instantiates | EdgeKind::TypeOf | EdgeKind::Returns + ) + { + return false; + } + let name = reference.reference_name.as_str(); + if !is_simple_identifier(name) { + return false; + } + // `new TypeMap()` constructs a type; the reference's column is the `new`. + if reference.reference_kind == EdgeKind::Instantiates { + return true; + } + let Some(line) = raw_line(&reference.file_path, reference.line, context) else { + return false; + }; + let column = usize::try_from(reference.column).unwrap_or(usize::MAX); + if !line + .get(column..) + .is_some_and(|rest| rest.starts_with(name)) + { + return false; + } + let before = &line[..column]; + let after = &line[column + name.len()..]; + let new = NEW.get_or_init(|| js_regex(r"(?-u:\b)new\s+$")); + if new.is_match(before) { + return true; + } + // `Type name`: a declaration names its type first. + let declares = DECLARES.get_or_init(|| js_regex(r"^\s+@?[A-Za-z_]")); + if declares.is_match(after) { + let operator = OPERATOR + .get_or_init(|| js_regex(r"^\s+(?:is|as|and|or|not|when|in|switch|with)(?-u:\b)")); + return !operator.is_match(after); + } + // `List`, `Type?` (not `?.`, `??`, `?[`), `Type[]`, and the + // arguments of a generic. + let shape = SHAPE.get_or_init(|| js_regex(r"^<|^\[\s*[,\]]")); + if shape.is_match(after) + || after + .strip_prefix('?') + .is_some_and(|rest| !rest.starts_with(['.', '?', '['])) + { + return true; + } + let argument = ARGUMENT.get_or_init(|| js_regex(r"^\s*[,>]")); + let open = OPEN_GENERIC.get_or_init(|| Regex::new(r"<[^<>()]*$").expect("open generic")); + argument.is_match(after) && open.is_match(before) +} + +/// Whether a candidate can be what a C# type position names: a property, a +/// method (a constructor is one), an enum case, a field or a constant (the +/// kind a `const` / `static readonly` field gets) shares the type's name, not +/// its meaning (upstream #2121 `canNameInTypePosition`, constants since +/// #2348: `new Version(5, 18)` beside a `const string Version`, and `static +/// readonly Meter Meter = new(…)` instantiating itself). +pub(crate) fn can_name_in_type_position(candidate: &Node) -> bool { + !matches!( + candidate.kind, + NodeKind::Property + | NodeKind::Method + | NodeKind::EnumMember + | NodeKind::Field + | NodeKind::Constant + ) +} + +/// The namespaces `enable` imports into every +/// file (Microsoft.NET.Sdk; upstream `CSHARP_IMPLICIT_USINGS`). +const CSHARP_IMPLICIT_USINGS: [&str; 7] = [ + "System", + "System.Collections.Generic", + "System.IO", + "System.Linq", + "System.Net.Http", + "System.Threading", + "System.Threading.Tasks", +]; + +/// The namespaces a C# file's code runs in and the ones it imports +/// (upstream `csharpNamespaceScope`). +#[derive(Debug, Default)] +pub struct CsharpNamespaceScope { + pub namespaces: Vec, + pub usings: HashSet, + pub aliases: HashMap, +} + +/// What the project files above a directory bring in (upstream +/// `csharpProjectUsings`): their ``s, the SDK's implicit +/// usings, and the `global using`s of the outermost project's own files; +/// `project` says whether a `.csproj` sits there or above. +#[derive(Debug, Clone, Default)] +struct ProjectUsings { + usings: HashSet, + project: bool, +} + +/// The ancestor names of a C# type, by its qualified name and the walk's +/// depth. +type AncestorMemo = HashMap<(String, usize), Arc>>; + +/// The project-wide C# facts a resolution pass reads. +#[derive(Debug, Default)] +pub struct CsharpTables { + project_usings: Mutex>>, + global_usings: Mutex>>>, + ancestors: Mutex, + /// The bases every declaration of a type name lists (#2151), by name. + supertypes: Mutex>>>, + /// The types a file's static usings bring in (#2151), by file. + static_usings: Mutex>>>, + /// The types the static usings of the project files from a directory up + /// and every `global using static` bring in (#2151), by directory. + project_static_usings: Mutex>>>, + /// The types a file's `using Name = …;` aliases name (#2171), by file. + aliases: Mutex>>>, +} + +impl CsharpTables { + pub fn new() -> Self { + Self::default() + } +} + +/// `using X;` / `global using X;` / `using A = X;`, not `using static`, as +/// upstream's `^\s*(?:global\s+)?using\s+(?!static\b)(?:([A-Za-z_]\w*)\s*=\s*)?([\w.]+)\s*;` +/// reads them: `(alias, target)`. +pub fn using_directives(text: &str) -> Vec<(Option, String)> { + static USING: OnceLock = OnceLock::new(); + let using = USING.get_or_init(|| { + js_regex( + r"(?m)^\s*(?:global\s+)?using\s+(?:([A-Za-z_][A-Za-z0-9_]*)\s*=\s*)?([A-Za-z0-9_.]+)\s*;", + ) + }); + using + .captures_iter(text) + .filter(|caps| !starts_with_static(text, caps)) + .map(|caps| { + ( + caps.get(1).map(|m| m.as_str().to_string()), + caps[2].to_string(), + ) + }) + .collect() +} + +/// Whether a `using` match is `using static …` (upstream's `(?!static\b)` +/// right after `using\s+`): the text at the first name after the keyword's +/// blanks is `static` and no word character follows it. +fn starts_with_static(text: &str, caps: ®ex::Captures<'_>) -> bool { + caps.get(1) + .or_else(|| caps.get(2)) + .is_some_and(|m| is_static_at(text, m.start())) +} + +fn is_static_at(text: &str, at: usize) -> bool { + text[at..] + .strip_prefix("static") + .is_some_and(|rest| !rest.starts_with(is_word)) +} + +/// The `global using X;` (not `static`) of `text`, as upstream's +/// `^\s*global\s+using\s+(?!static\b)([\w.]+)\s*;` reads them. +pub fn global_usings(text: &str) -> Vec { + static GLOBAL: OnceLock = OnceLock::new(); + let global = GLOBAL.get_or_init(|| js_regex(r"(?m)^\s*global\s+using\s+([A-Za-z0-9_.]+)\s*;")); + global + .captures_iter(text) + .filter(|caps| caps.get(1).is_some_and(|m| !is_static_at(text, m.start()))) + .map(|caps| caps[1].to_string()) + .collect() +} + +/// The `using static X;` and `global using static X;` of `text`, as +/// upstream's `^\s*(?:global\s+)?using\s+static\s+([\w.]+)\s*;` reads them. +pub fn file_static_usings(text: &str) -> Vec { + static STATIC: OnceLock = OnceLock::new(); + STATIC + .get_or_init(|| js_regex(r"(?m)^\s*(?:global\s+)?using\s+static\s+([A-Za-z0-9_.]+)\s*;")) + .captures_iter(text) + .map(|caps| caps[1].to_string()) + .collect() +} + +/// The `global using static X;` of `text`, as upstream's +/// `^\s*global\s+using\s+static\s+([\w.]+)\s*;` reads them. +pub fn global_static_usings(text: &str) -> Vec { + static GLOBAL: OnceLock = OnceLock::new(); + GLOBAL + .get_or_init(|| js_regex(r"(?m)^\s*global\s+using\s+static\s+([A-Za-z0-9_.]+)\s*;")) + .captures_iter(text) + .map(|caps| caps[1].to_string()) + .collect() +} + +/// The `` of a project file's text, as +/// upstream's `]*\bStatic\s*=\s*"true"` +/// reads them. +pub fn project_file_static_usings(text: &str) -> Vec { + static STATIC: OnceLock = OnceLock::new(); + STATIC + .get_or_init(|| { + js_regex( + r#"(?i)]*(?-u:\b)Static\s*=\s*"true""#, + ) + }) + .captures_iter(text) + .map(|caps| caps[1].to_string()) + .collect() +} + +/// The `using Name = Some.Namespace.Type;` (or `global using`) aliases of +/// `text` and the simple name of the type each names, as upstream #2171's +/// `^\s*(?:global\s+)?using\s+([A-Za-z_]\w*)\s*=\s*(?:global::)?([\w.]+)\s*(?:<[^;>]*>)?\s*;` +/// reads them. +pub fn using_alias_types(text: &str) -> Vec<(String, String)> { + static ALIAS: OnceLock = OnceLock::new(); + ALIAS + .get_or_init(|| { + js_regex( + r"(?m)^\s*(?:global\s+)?using\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:global::)?([A-Za-z0-9_.]+)\s*(?:<[^;>]*>)?\s*;", + ) + }) + .captures_iter(text) + .map(|caps| (caps[1].to_string(), last_segment(&caps[2]).to_string())) + .collect() +} + +/// The type a C# file's `using` alias names `name` as, by its simple name +/// (upstream #2171 `csharpUsingAlias`), read once per file and pass. +pub(crate) fn csharp_using_alias( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + if !is_simple_identifier(name) { + return None; + } + let tables = context.csharp_tables(); + let cached = tables + .aliases + .lock() + .ok() + .and_then(|memo| memo.get(&reference.file_path).cloned()); + let aliases = match cached { + Some(hit) => hit, + None => { + let aliases: HashMap = context + .source_facts(&reference.file_path) + .map(|facts| using_alias_types(facts.source()).into_iter().collect()) + .unwrap_or_default(); + let aliases = Arc::new(aliases); + if let Ok(mut memo) = tables.aliases.lock() { + memo.insert(reference.file_path.clone(), Arc::clone(&aliases)); + } + aliases + } + }; + aliases.get(name).cloned() +} + +/// The last `.` segment of a dotted name (`split('.').pop()`). +fn last_segment(name: &str) -> &str { + name.rsplit('.').next().unwrap_or(name) +} + +/// The namespaces a C# file declares with `namespace X` at a line's start +/// (upstream reads every one, block or file-scoped, after comments are +/// blanked). +pub fn namespace_declarations(stripped: &str) -> Vec { + static NAMESPACE: OnceLock = OnceLock::new(); + NAMESPACE + .get_or_init(|| js_regex(r"(?m)^\s*namespace\s+([A-Za-z0-9_.]+)")) + .captures_iter(stripped) + .map(|caps| caps[1].to_string()) + .collect() +} + +/// `path.posix.dirname`. +fn dirname(path: &str) -> &str { + match path.rfind('/') { + Some(0) => "/", + Some(cut) => &path[..cut], + None => ".", + } +} + +fn is_root_dir(dir: &str) -> bool { + matches!(dir, "." | "" | "/") +} + +/// The `.csproj` and `.props` files of `dir` (a directory of the project, +/// `.` for its root), by name: regular files only, a symlink is not followed. +fn project_files(dir: &str, context: &dyn ResolutionContext) -> Vec { + project_files_in(Path::new(context.get_project_root()), dir) +} + +/// [`project_files`] under the project `root`. +fn project_files_in(root: &Path, dir: &str) -> Vec { + let full = if is_root_dir(dir) { + root.to_path_buf() + } else { + root.join(dir) + }; + let Ok(entries) = std::fs::read_dir(full) else { + return Vec::new(); + }; + let mut names: Vec = entries + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".csproj") || lower.ends_with(".props") + }) + .collect(); + names.sort(); + names +} + +/// What [`csharp_project_usings`] reads for the C# files `files` (project +/// paths of the files the index holds as C#): each directory from a file's +/// up to the root, whose `.csproj` / `.props` names it lists (`.` for the +/// root), and each project file listed, by project path. Sorted, each once; +/// what a sync compares to know when the project usings may have moved +/// (W5-06). +pub fn csharp_project_inputs( + root: &Path, + files: &[String], +) -> (Vec<(String, Vec)>, Vec) { + let mut dirs: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for file in files { + let mut dir = dirname(file).to_string(); + loop { + let at_root = is_root_dir(&dir); + dirs.insert(if at_root { + ".".to_string() + } else { + dir.clone() + }); + if at_root { + break; + } + dir = dirname(&dir).to_string(); + } + } + let mut listings = Vec::new(); + let mut project_paths = Vec::new(); + for dir in dirs { + let names = project_files_in(root, &dir); + for name in &names { + project_paths.push(if dir == "." { + name.clone() + } else { + format!("{dir}/{name}") + }); + } + listings.push((dir, names)); + } + project_paths.sort(); + (listings, project_paths) +} + +/// The `` (not `Static="true"`) of a project file's +/// text, and whether it turns the SDK's implicit usings on. +pub fn project_file_usings(text: &str) -> (Vec, bool) { + static USING: OnceLock = OnceLock::new(); + static STATIC: OnceLock = OnceLock::new(); + static IMPLICIT: OnceLock = OnceLock::new(); + let using = + USING.get_or_init(|| js_regex(r#"(?i)]*)>"#)); + let is_static = STATIC.get_or_init(|| js_regex(r#"(?i)(?-u:\b)Static\s*=\s*"true""#)); + let implicit = IMPLICIT + .get_or_init(|| js_regex(r"(?i)\s*(?:enable|true)\s*")); + let usings = using + .captures_iter(text) + .filter(|caps| !is_static.is_match(&caps[2])) + .map(|caps| caps[1].to_string()) + .collect(); + (usings, implicit.is_match(text)) +} + +/// The `global using X;` of the `.cs` files under `dir` (`.` = the whole +/// repository; upstream `csharpGlobalUsings`), once per pass. +fn csharp_global_usings(dir: &str, context: &dyn ResolutionContext) -> Arc> { + let tables = context.csharp_tables(); + if let Some(hit) = tables + .global_usings + .lock() + .ok() + .and_then(|memo| memo.get(dir).cloned()) + { + return hit; + } + let prefix = if dir == "." { + String::new() + } else { + format!("{dir}/") + }; + let mut usings = HashSet::new(); + for file in context.get_all_files_shared().iter() { + if !file.ends_with(".cs") || !file.starts_with(&prefix) { + continue; + } + let Some(facts) = context.source_facts(file) else { + continue; + }; + if !facts.source().contains("global using") { + continue; + } + usings.extend(global_usings(facts.source())); + } + let usings = Arc::new(usings); + if let Ok(mut memo) = tables.global_usings.lock() { + memo.insert(dir.to_string(), Arc::clone(&usings)); + } + usings +} + +/// What the project files from `dir` up bring in (upstream +/// `csharpProjectUsings`), once per pass: the outermost `.csproj` on the way +/// marks the project, whose own files' `global using`s join; each project +/// file adds its ``s and, when on, the implicit usings. +fn csharp_project_usings(dir: &str, context: &dyn ResolutionContext) -> Arc { + let tables = context.csharp_tables(); + if let Some(hit) = tables + .project_usings + .lock() + .ok() + .and_then(|memo| memo.get(dir).cloned()) + { + return hit; + } + let root = is_root_dir(dir); + let mut result = if root { + ProjectUsings::default() + } else { + csharp_project_usings(dirname(dir), context) + .as_ref() + .clone() + }; + for entry in project_files(dir, context) { + let relative = if root { + entry.clone() + } else { + format!("{dir}/{entry}") + }; + let Some(text) = context.read_file_shared(&relative) else { + continue; + }; + if entry.to_ascii_lowercase().ends_with(".csproj") && !result.project { + result.project = true; + let at = if root { "." } else { dir }; + result + .usings + .extend(csharp_global_usings(at, context).iter().cloned()); + } + let (usings, implicit) = project_file_usings(&text); + result.usings.extend(usings); + if implicit { + result + .usings + .extend(CSHARP_IMPLICIT_USINGS.iter().map(|ns| ns.to_string())); + } + } + let result = Arc::new(result); + if let Ok(mut memo) = tables.project_usings.lock() { + memo.insert(dir.to_string(), Arc::clone(&result)); + } + result +} + +/// `file`'s namespace scope (upstream `csharpNamespaceScope`), read once. +pub(crate) fn csharp_namespace_scope( + file: &str, + context: &dyn ResolutionContext, +) -> Arc { + let Some(facts) = context.source_facts(file) else { + return Arc::new(CsharpNamespaceScope::default()); + }; + facts.csharp_namespace_scope(|| { + let text = strip_comments_for_regex(facts.source(), CommentLang::Java); + let project = csharp_project_usings(dirname(file), context); + let mut usings = project.usings.clone(); + if !project.project { + usings.extend(csharp_global_usings(".", context).iter().cloned()); + } + let mut aliases = HashMap::new(); + for (alias, target) in using_directives(&text) { + match alias { + Some(alias) => { + aliases.insert(alias, target); + } + None => { + usings.insert(target); + } + } + } + CsharpNamespaceScope { + namespaces: namespace_declarations(&text), + usings, + aliases, + } + }) +} + +/// Whether a bare C# type name can mean `n` (upstream #2216 +/// `isCsharpTypeVisible`): a type in namespace `N` is seen from `N` and the +/// namespaces inside it, and through a `using N;`; an alias names one type; +/// a type in the global namespace is seen everywhere. +pub(crate) fn is_csharp_type_visible( + n: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let Some(cut) = n.qualified_name.find("::") else { + return true; + }; + let namespace = &n.qualified_name[..cut]; + let scope = csharp_namespace_scope(&reference.file_path, context); + if let Some(aliased) = scope.aliases.get(&reference.reference_name) { + return *aliased + == format!( + "{namespace}.{}", + n.qualified_name[cut + 2..].replace("::", ".") + ); + } + if scope + .namespaces + .iter() + .any(|own| own == namespace || own.starts_with(&format!("{namespace}."))) + { + return true; + } + scope.usings.contains(namespace) +} + +/// The simple names one C# type declaration's base list names, read from +/// its head (up to seven lines, to the first `{`, a `where` clause dropped; +/// upstream `csharpAncestorNames`' reader). +pub fn csharp_declared_bases(facts: &crate::source_facts::SourceFacts, decl: &Node) -> Vec { + static LIST: OnceLock = OnceLock::new(); + static WHERE: OnceLock = OnceLock::new(); + static NAME: OnceLock = OnceLock::new(); + let list_re = LIST.get_or_init(|| js_regex(r":\s*([^{;]*)")); + let where_re = + WHERE.get_or_init(|| Regex::new(r"(?s)(?-u:\b)where(?-u:\b).*$").expect("where")); + let name_re = NAME.get_or_init(|| js_regex(r"([A-Za-z_][A-Za-z0-9_]*)\s*(?:<.*)?$")); + let Ok(start) = usize::try_from(decl.start_line) else { + return Vec::new(); + }; + let mut header = String::new(); + let mut index = start.saturating_sub(1); + let end = facts.line_count().min(start + 6); + while index < end && !header.contains('{') { + header.push_str(facts.line(index)); + header.push(' '); + index += 1; + } + let head = header.split('{').next().unwrap_or_default(); + let head = where_re.replace(head, ""); + let Some(list) = list_re.captures(&head).map(|caps| caps[1].to_string()) else { + return Vec::new(); + }; + crate::cpp_namespaces::split_cpp_top_level(&list) + .iter() + .filter_map(|base| { + name_re + .captures(base.trim()) + .map(|caps| caps[1].to_string()) + }) + .collect() +} + +/// The simple names of the C# types `qualified` derives from, through every +/// partial part and base, four levels up (upstream `csharpAncestorNames`; +/// memoized by name and depth, so a pass's order cannot change a result). +fn csharp_ancestor_names( + qualified: &str, + depth: usize, + context: &dyn ResolutionContext, +) -> Arc> { + let tables = context.csharp_tables(); + let key = (qualified.to_string(), depth); + if let Some(hit) = tables + .ancestors + .lock() + .ok() + .and_then(|memo| memo.get(&key).cloned()) + { + return hit; + } + let mut names: HashSet = HashSet::new(); + for decl in context.get_nodes_by_qualified_name_shared(qualified) { + if decl.language != Language::CSharp || !is_csharp_type_kind(decl.kind) { + continue; + } + if let Some(facts) = context.source_facts(&decl.file_path) { + names.extend(csharp_declared_bases(&facts, &decl)); + } + } + if depth < 4 { + let bases: Vec = names.iter().cloned().collect(); + for base in bases { + for t in context.get_nodes_by_name_shared(&base) { + if t.language != Language::CSharp + || !is_csharp_type_kind(t.kind) + || t.qualified_name == qualified + { + continue; + } + names.extend( + csharp_ancestor_names(&t.qualified_name, depth + 1, context) + .iter() + .cloned(), + ); + } + } + } + let names = Arc::new(names); + if let Ok(mut memo) = tables.ancestors.lock() { + memo.insert(key, Arc::clone(&names)); + } + names +} + +/// Whether a bare C# name can reach `n` as a nested type (upstream #2220 +/// `isCsharpNestedTypeInScope`): only from inside the type declaring it (any +/// partial part, any depth) or a class deriving from it. +pub(crate) fn is_csharp_nested_type_in_scope( + n: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let Some(owner) = csharp_owner_name(&n.qualified_name) else { + return true; + }; + let Some(owner_type) = context + .get_nodes_in_file_shared(&n.file_path) + .into_iter() + .find(|p| is_csharp_owner_type(p, owner)) + else { + // Declared in a namespace, not a type. + return true; + }; + let enclosing: Vec> = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|p| { + is_csharp_type_kind(p.kind) + && p.start_line <= reference.line + && p.end_line >= reference.line + }) + .collect(); + let nested = format!("{owner}::"); + if enclosing + .iter() + .any(|p| p.qualified_name == owner || p.qualified_name.starts_with(&nested)) + { + return true; + } + enclosing + .iter() + .any(|p| csharp_ancestor_names(&p.qualified_name, 0, context).contains(&owner_type.name)) +} + +/// The types a C# file sees through static usings (upstream #2151 +/// `csharpStaticUsings`): its own `using static`s, every file's `global using +/// static`, and the `` of the project files above it, +/// by their last segment. +fn csharp_static_usings(file: &str, context: &dyn ResolutionContext) -> Arc> { + let tables = context.csharp_tables(); + if let Some(hit) = tables + .static_usings + .lock() + .ok() + .and_then(|memo| memo.get(file).cloned()) + { + return hit; + } + let mut owners = csharp_project_static_usings(dirname(file), context) + .as_ref() + .clone(); + if let Some(facts) = context.source_facts(file) { + owners.extend( + file_static_usings(facts.source()) + .iter() + .map(|name| last_segment(name).to_string()), + ); + } + let owners = Arc::new(owners); + if let Ok(mut memo) = tables.static_usings.lock() { + memo.insert(file.to_string(), Arc::clone(&owners)); + } + owners +} + +/// The static usings that apply to every file under `dir` (upstream +/// `csharpProjectStaticUsings`): every `global using static` of the +/// repository, and the `` of the project files on the +/// way down to `dir`. +fn csharp_project_static_usings( + dir: &str, + context: &dyn ResolutionContext, +) -> Arc> { + let tables = context.csharp_tables(); + if let Some(hit) = tables + .project_static_usings + .lock() + .ok() + .and_then(|memo| memo.get(dir).cloned()) + { + return hit; + } + let root = is_root_dir(dir); + let mut owners: HashSet = if root { + let mut owners = HashSet::new(); + for file in context.get_all_files_shared().iter() { + if !file.ends_with(".cs") { + continue; + } + let Some(facts) = context.source_facts(file) else { + continue; + }; + if !facts.source().contains("global using static") { + continue; + } + owners.extend( + global_static_usings(facts.source()) + .iter() + .map(|name| last_segment(name).to_string()), + ); + } + owners + } else { + csharp_project_static_usings(dirname(dir), context) + .as_ref() + .clone() + }; + for entry in project_files(dir, context) { + let relative = if root { + entry + } else { + format!("{dir}/{entry}") + }; + let Some(text) = context.read_file_shared(&relative) else { + continue; + }; + owners.extend( + project_file_static_usings(&text) + .iter() + .map(|name| last_segment(name).to_string()), + ); + } + let owners = Arc::new(owners); + if let Ok(mut memo) = tables.project_static_usings.lock() { + memo.insert(dir.to_string(), Arc::clone(&owners)); + } + owners +} + +/// The simple names one C# type declaration derives from as upstream #2151's +/// `csharpSupertypesOf` reads them: its head (up to nine lines, to the first +/// `{`) with everything inside `<…>` and `(…)` dropped, the list after +/// `Name :` up to a `where`, each dotted name by its last segment. +pub fn csharp_declared_supers( + facts: &crate::source_facts::SourceFacts, + decl: &Node, +) -> Vec { + static NAME: OnceLock = OnceLock::new(); + let Ok(start) = usize::try_from(decl.start_line) else { + return Vec::new(); + }; + let from = start.saturating_sub(1); + let to = (start + 8).min(facts.line_count()); + let head = if from < to { + facts.join_lines(from, to, " ") + } else { + String::new() + }; + let head = &head[..head.find('{').unwrap_or(head.len())]; + let mut depth = 0usize; + let mut flat = String::new(); + for ch in head.chars() { + match ch { + '<' | '(' => depth += 1, + '>' | ')' => depth = depth.saturating_sub(1), + _ if depth == 0 => flat.push(ch), + _ => {} + } + } + // JavaScript's `.` stops at a line terminator. + let Ok(list) = Regex::new(&format!( + r"(?-u:\b){}{}*:{}*([^\n\r\x{{2028}}\x{{2029}}]*?)(?:(?-u:\b)where(?-u:\b)|$)", + regex::escape(&decl.name), + JS_SPACE, + JS_SPACE + )) else { + return Vec::new(); + }; + let bases = list + .captures(&flat) + .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())) + .unwrap_or_default(); + let name = NAME.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_.]*").expect("base name")); + name.find_iter(&bases) + .map(|m| last_segment(m.as_str()).to_string()) + .collect() +} + +/// The bases every C# declaration named `type_name` lists, by #2151's +/// reader (upstream `csharpSupertypesOf`), once per pass. +fn csharp_supertypes_of(type_name: &str, context: &dyn ResolutionContext) -> Arc> { + let tables = context.csharp_tables(); + if let Some(hit) = tables + .supertypes + .lock() + .ok() + .and_then(|memo| memo.get(type_name).cloned()) + { + return hit; + } + let mut names = Vec::new(); + for decl in context.get_nodes_by_name_shared(type_name) { + if decl.language != Language::CSharp || !is_csharp_type_kind(decl.kind) { + continue; + } + if let Some(facts) = context.source_facts(&decl.file_path) { + names.extend(csharp_declared_supers(&facts, &decl)); + } + } + let names = Arc::new(names); + if let Ok(mut memo) = tables.supertypes.lock() { + memo.insert(type_name.to_string(), Arc::clone(&names)); + } + names +} + +/// A bare C# name: a `calls` or `references` reference named like +/// `^[A-Za-z_]\w*$` (upstream #2151 `csharpBare`). +pub(crate) fn is_csharp_bare_name(reference: &RefView) -> bool { + reference.language == Language::CSharp + && matches!( + reference.reference_kind, + EdgeKind::Calls | EdgeKind::References + ) + && is_simple_identifier(&reference.reference_name) +} + +/// Whether a bare C# name, whose line shows no receiver, can mean `n` +/// (upstream #2151 `isCsharpMemberInScope`): a member only of the types +/// around the name (outer types included), of the bases their heads list +/// (every partial declaration, by name, at most 40 types), or of a type a +/// static using brings in. A name outside every type is not judged, nor is +/// anything but a member (no `event` kind here). +pub(crate) fn is_csharp_member_in_scope( + n: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if !matches!( + n.kind, + NodeKind::Method + | NodeKind::Property + | NodeKind::Field + | NodeKind::EnumMember + | NodeKind::Constant + ) { + return true; + } + let Some(owner) = csharp_owner_name(&n.qualified_name) else { + return true; + }; + let owner = last_segment(owner.rsplit("::").next().unwrap_or(owner)); + if csharp_static_usings(&reference.file_path, context).contains(owner) { + return true; + } + let mut queue: VecDeque = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|t| { + is_csharp_type_kind(t.kind) + && t.start_line <= reference.line + && t.end_line >= reference.line + }) + .map(|t| t.name.clone()) + .collect(); + // No type around the name: its declaration was not recovered. + if queue.is_empty() { + return true; + } + let mut seen: HashSet = HashSet::new(); + while seen.len() < 40 { + let Some(name) = queue.pop_front() else { + break; + }; + if !seen.insert(name.clone()) { + continue; + } + if name == owner { + return true; + } + queue.extend(csharp_supertypes_of(&name, context).iter().cloned()); + } + false +} + +/// The C# visibility rule of exact matching and of the cross-file veto +/// (upstream #2216, #2220): a C# type, by a bare C# name. +pub(crate) fn is_csharp_bare_type_candidate(n: &Node, reference: &RefView) -> bool { + reference.language == Language::CSharp + && n.language == Language::CSharp + && is_csharp_type_kind(n.kind) + && is_simple_identifier(&reference.reference_name) +} + +/// The `project_metadata` key prefix of a C# file's surface row. +pub const SURFACE_ROW_KEY_PREFIX: &str = "csharp_surface:"; + +/// The `project_metadata` key of `file_path`'s surface row. +pub fn surface_row_key(file_path: &str) -> String { + format!("{SURFACE_ROW_KEY_PREFIX}{file_path}") +} + +/// A C# type declaration's head as the two hierarchy walks read it: the +/// declaration's name and qualified name, the bases [`csharp_ancestor_names`] +/// reads (#2220) and those [`csharp_supertypes_of`] reads (#2151). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct CsharpHead { + pub name: String, + pub qualified_name: String, + pub bases: Vec, + pub supers: Vec, +} + +/// What other files' C# resolution reads of one C# file, by the readers +/// resolution uses: the namespaces its `global using`s import and the types +/// its `global using static`s name (each sorted), and the heads of its type +/// declarations that list a base to either reader (node order). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CsharpSurface { + pub globals: Vec, + pub global_statics: Vec, + pub heads: Vec, +} + +impl CsharpSurface { + /// The row of the C# file whose text is `source` and whose nodes are + /// `nodes`. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let facts = crate::source_facts::SourceFacts::new(Arc::from(source)); + let mut ordered: Vec<&Node> = nodes + .iter() + .filter(|node| node.language == Language::CSharp && is_csharp_type_kind(node.kind)) + .collect(); + ordered.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + let heads = ordered + .into_iter() + .filter_map(|node| { + let bases = csharp_declared_bases(&facts, node); + let supers = csharp_declared_supers(&facts, node); + (!bases.is_empty() || !supers.is_empty()).then(|| CsharpHead { + name: node.name.clone(), + qualified_name: node.qualified_name.clone(), + bases, + supers, + }) + }) + .collect(); + let globals: BTreeSet = if source.contains("global using") { + global_usings(source).into_iter().collect() + } else { + BTreeSet::new() + }; + let global_statics: BTreeSet = if source.contains("global using static") { + global_static_usings(source).into_iter().collect() + } else { + BTreeSet::new() + }; + Self { + globals: globals.into_iter().collect(), + global_statics: global_statics.into_iter().collect(), + heads, + } + } + + /// No fact: no row records it. + pub fn is_empty(&self) -> bool { + self.globals.is_empty() && self.global_statics.is_empty() && self.heads.is_empty() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("C# surface row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +/// The type a C# declaration named `qualified_name` is nested in, by its +/// qualified name: what comes before its last `::` (`None` at the top). +pub fn csharp_owner_name(qualified_name: &str) -> Option<&str> { + qualified_name.rfind("::").map(|cut| &qualified_name[..cut]) +} + +/// Whether `candidate` is the C# type a nested declaration's owner name +/// `owner` names (upstream `isCsharpNestedTypeInScope`'s `ownerType`). +pub fn is_csharp_owner_type(candidate: &Node, owner: &str) -> bool { + is_csharp_type_kind(candidate.kind) && candidate.qualified_name == owner +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn csharp_project_inputs_list_each_folder_up_from_a_file() { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!("cg-csharp-inputs-{nanos}")); + std::fs::create_dir_all(root.join("App/src")).unwrap(); + std::fs::write(root.join("Directory.Build.props"), "").unwrap(); + std::fs::write(root.join("README.md"), "app\n").unwrap(); + std::fs::write(root.join("App/App.CSPROJ"), "").unwrap(); + std::fs::write(root.join("App/src/Program.cs"), "class Program {}\n").unwrap(); + let (listings, project_files) = + csharp_project_inputs(&root, &["App/src/Program.cs".to_string()]); + assert_eq!( + listings, + [ + (".".to_string(), vec!["Directory.Build.props".to_string()]), + ("App".to_string(), vec!["App.CSPROJ".to_string()]), + ("App/src".to_string(), Vec::new()), + ] + ); + assert_eq!(project_files, ["App/App.CSPROJ", "Directory.Build.props"]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn usings_skip_static_and_read_aliases() { + let text = "using System;\nusing static System.Math;\nglobal using App.Support;\nusing Task = App.Tests.Schema.Task;\n using staticky.Lib;\nusing static;\n"; + assert_eq!( + using_directives(text), + [ + (None, "System".to_string()), + (None, "App.Support".to_string()), + ( + Some("Task".to_string()), + "App.Tests.Schema.Task".to_string() + ), + (None, "staticky.Lib".to_string()), + ] + ); + assert_eq!( + global_usings("global using Unit.Support;\nglobal using static X.Y;\n"), + ["Unit.Support"] + ); + } + + #[test] + fn project_files_read_usings_and_implicit_usings() { + let (usings, implicit) = project_file_usings( + "\n\n\n\n\n\nenable\n\n", + ); + assert_eq!(usings, ["App.Shared", "Lower.Case"]); + assert!(implicit); + assert!(!project_file_usings("disable").1); + } + + #[test] + fn namespaces_are_read_at_line_starts() { + assert_eq!( + namespace_declarations( + "namespace Alpha\n{\n}\n namespace Beta.Inner;\nvar namespaceX = 1;\n" + ), + ["Alpha", "Beta.Inner"] + ); + } + + #[test] + fn a_byte_order_mark_is_blank_as_upstream_reads_it() { + let text = "\u{feff}using System.Threading.Tasks;\n\u{feff}global using App.Support;\n\u{feff}namespace App.Tests\n{\n}\n"; + assert_eq!( + using_directives(text), + [ + (None, "System.Threading.Tasks".to_string()), + (None, "App.Support".to_string()), + ] + ); + assert_eq!(global_usings(text), ["App.Support"]); + assert_eq!(namespace_declarations(text), ["App.Tests"]); + // U+0085 is Rust's `\s`, not JavaScript's. + assert!(namespace_declarations("\u{85}namespace Alpha\n").is_empty()); + } + + #[test] + fn static_usings_read_files_globals_and_project_files() { + let text = "using static System.Math;\nglobal using static App.Execution.ExpressionBuilder;\nusing System;\n global using static Lib.Statics ;\n"; + assert_eq!( + file_static_usings(text), + [ + "System.Math", + "App.Execution.ExpressionBuilder", + "Lib.Statics" + ] + ); + assert_eq!( + global_static_usings(text), + ["App.Execution.ExpressionBuilder", "Lib.Statics"] + ); + assert_eq!( + project_file_static_usings( + "\n\n\n\n\n" + ), + ["A.B.Builder", "A.Lower"] + ); + assert_eq!(last_segment("A.B.Builder"), "Builder"); + } + + fn type_node(name: &str, start_line: i64) -> Node { + Node { + id: format!("class:{name}"), + kind: NodeKind::Class, + name: name.to_string(), + qualified_name: format!("App::{name}"), + file_path: "A.cs".to_string(), + language: Language::CSharp, + start_line, + end_line: start_line + 12, + start_column: 0, + end_column: 0, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + } + } + + #[test] + fn supers_read_nine_lines_with_generics_and_arguments_dropped() { + let source = "public class Store(int seed) : Base, System.IDisposable where T : class\n{\n}\npublic class Long\n\n\n\n\n\n\n : Deep.Inner\n{\n}\npublic class Plain\n{\n}\n"; + let facts = crate::source_facts::SourceFacts::new(Arc::from(source)); + assert_eq!( + csharp_declared_supers(&facts, &type_node("Store", 1)), + ["Base", "IDisposable"] + ); + assert_eq!( + csharp_declared_supers(&facts, &type_node("Long", 4)), + ["Inner"] + ); + assert!(csharp_declared_supers(&facts, &type_node("Plain", 14)).is_empty()); + // The #2220 reader stops at seven lines. + assert!(csharp_declared_bases(&facts, &type_node("Long", 4)).is_empty()); + } + + #[test] + fn dirname_follows_posix() { + assert_eq!(dirname("src/Lib/Guard.cs"), "src/Lib"); + assert_eq!(dirname("Program.cs"), "."); + assert_eq!(dirname("src"), "."); + } +} diff --git a/crates/codegraph-resolve/src/dart_annotations.rs b/crates/codegraph-resolve/src/dart_annotations.rs new file mode 100644 index 00000000..6baefaf6 --- /dev/null +++ b/crates/codegraph-resolve/src/dart_annotations.rs @@ -0,0 +1,317 @@ +//! A Dart annotation is a constant expression (upstream #2390): a `const` +//! variable (`@riverpod`, `@meta.immutable`, a type's static constant +//! `@Riverpod.scoped` or enum value `@Retry.always`) or a call of a `const` +//! constructor (`@Riverpod(keepAlive: true)`, `@Riverpod.forever()`). It +//! was ranked like a Python decorator, which favours a function or method of +//! that name over a class and scores a constant not at all, so riverpod's +//! `@riverpod` annotations went to riverpod_analyzer_utils' extension getter +//! `riverpod`, not riverpod_annotation's `const riverpod = Riverpod();`. +//! +//! An annotation links, as it is written from its `@`, a constant or (called) +//! a class its library can see, through the import prefix it is written +//! with; a static constant, an enum value or (called) a named constructor of +//! the type it is written through; or, on a member, a static constant of the +//! type around it. Never a method, a getter or a function. + +use std::sync::Arc; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; + +use crate::dart_libraries::{ + dart_import_prefixes, is_dart_library_decl, is_dart_top_level_visible, prefer_own_dart_library, +}; +use crate::dart_scope::{dart_extension_named, in_order, is_dart_type_kind}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// Whether a reference is a Dart annotation, which the extractor records by +/// its last name, at the `@` (upstream `isDartAnnotation`). +pub(crate) fn is_dart_annotation(reference: &RefView) -> bool { + reference.language == Language::Dart && reference.reference_kind == EdgeKind::Decorates +} + +/// What a Dart annotation links (upstream `matchDartAnnotation`). +pub(crate) fn match_dart_annotation( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let name = reference.reference_name.as_str(); + if !context + .get_nodes_by_name_shared(name) + .iter() + .any(|node| node.language == Language::Dart) + { + return None; + } + let written = dart_annotation_at(reference, context)?; + let names = &written.names; + let prefixes = dart_import_prefixes(&reference.file_path, context); + let prefixed = |count: usize| names.len() == count && prefixes.contains(&names[0]); + let (pool, resolved_by) = if names.len() == 1 || prefixed(2) { + let prefix = (names.len() == 2).then(|| names[0].as_str()); + if prefix.is_none() + && !written.call + && let Some(own) = dart_enclosing_static(name, reference, context) + { + return Some(resolved(reference, &own, 0.9, ResolvedBy::ExactMatch)); + } + let mut pool: Vec> = context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| { + is_dart_library_decl(node) + && if written.call { + is_annotation_class(node, context) + } else { + node.kind == NodeKind::Constant + } + && is_dart_top_level_visible(node, reference, context, prefix) + }) + .collect(); + if prefix.is_none() { + pool = prefer_own_dart_library(pool, reference, context); + } + (pool, ResolvedBy::ExactMatch) + } else if names.len() == 2 || prefixed(3) { + let prefix = (names.len() == 3).then(|| names[0].as_str()); + let mut owners: Vec> = context + .get_nodes_by_name_shared(&names[names.len() - 2]) + .into_iter() + .filter(|owner| { + is_dart_library_decl(owner) + && is_dart_type_kind(owner.kind) + && is_dart_top_level_visible(owner, reference, context, prefix) + }) + .collect(); + if prefix.is_none() { + owners = prefer_own_dart_library(owners, reference, context); + } + let pool: Vec> = context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|member| { + member.language == Language::Dart + && owners.iter().any(|owner| { + owner.file_path == member.file_path + && member.qualified_name == format!("{}::{name}", owner.qualified_name) + }) + && if written.call { + crate::dart_members::is_dart_constructor(member, context) + } else { + matches!(member.kind, NodeKind::Constant | NodeKind::EnumMember) + } + }) + .collect(); + (pool, ResolvedBy::QualifiedName) + } else { + return None; + }; + if pool.len() <= 1 { + return pool + .first() + .map(|node| resolved(reference, node, 0.9, resolved_by)); + } + // Libraries this cannot establish see every file's declarations. + let best = crate::name_matcher::find_best_match(reference, &pool)?; + let near = + crate::name_matcher::compute_path_proximity(&reference.file_path, &best.file_path) >= 30.0; + Some(resolved( + reference, + best, + if near { 0.7 } else { 0.4 }, + resolved_by, + )) +} + +fn resolved( + reference: &RefView, + target: &Node, + confidence: f64, + resolved_by: ResolvedBy, +) -> ResolvedRef { + ResolvedRef { + original: reference.clone(), + target_node_id: target.id.clone(), + confidence, + resolved_by, + } +} + +/// A class a Dart annotation can call a constructor of: not an extension, +/// which has none (upstream `isDartAnnotationClass`). +fn is_annotation_class(node: &Node, context: &dyn ResolutionContext) -> bool { + node.kind == NodeKind::Class && dart_extension_named(node, context).is_none() +} + +/// The static constant or enum value `name` of the type a Dart annotation is +/// written in, which a member's annotation names bare. A class's own +/// annotations stand outside its body (upstream `dartEnclosingStatic`). +fn dart_enclosing_static( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + let mut own: Option> = None; + for node in in_order(context.get_nodes_in_file_shared(&reference.file_path)) { + if is_dart_type_kind(node.kind) + && node.id != reference.from_node_id + && node.start_line <= reference.line + && node.end_line >= reference.line + && own + .as_ref() + .is_none_or(|own| node.start_line >= own.start_line) + { + own = Some(node); + } + } + let qualified_name = format!("{}::{name}", own?.qualified_name); + context + .get_nodes_by_name_shared(name) + .into_iter() + .find(|member| { + member.language == Language::Dart + && member.file_path == reference.file_path + && member.qualified_name == qualified_name + && matches!(member.kind, NodeKind::Constant | NodeKind::EnumMember) + }) +} + +/// How a Dart annotation is written: the names of `@p.Foo.named(…)` up to +/// the one its reference records, and whether it is called. +#[derive(Debug, PartialEq, Eq)] +struct Written { + names: Vec, + call: bool, +} + +/// How a Dart annotation is written, read from its `@` (upstream +/// `dartAnnotationAt`). The extractor records the last name, or the one a +/// type argument list follows (`@Foo.named(…)` is `Foo`'s). On a line +/// with non-ASCII text before it, the `@` whose names fit stands in. +fn dart_annotation_at(reference: &RefView, context: &dyn ResolutionContext) -> Option { + let facts = context.source_facts(&reference.file_path)?; + let code = facts.dart_code(); + let from = code.line_start(reference.line)?; + let to = code + .line_start(reference.line + 1) + .unwrap_or(code.text.len()); + let column = from + usize::try_from(reference.column).ok()?; + let line = code.text.get(from..to)?; + let ats = std::iter::once(column).chain( + line.match_indices('@') + .map(|(at, _)| from + at) + .filter(|at| *at != column), + ); + for at in ats { + if code.text.as_bytes().get(at) != Some(&b'@') { + continue; + } + let Some(read) = read_dart_annotation(&code.text, at + 1) else { + continue; + }; + let end = read.type_args_at.unwrap_or(read.names.len() - 1); + if read.names[end] == reference.reference_name { + return Some(Written { + names: read.names[..=end].to_vec(), + call: read.call, + }); + } + } + None +} + +/// A Dart annotation's dotted names, which of them a type argument list +/// follows, and whether `(` comes next (upstream `readDartAnnotation`). +#[derive(Debug, PartialEq, Eq)] +struct Read { + names: Vec, + type_args_at: Option, + call: bool, +} + +fn read_dart_annotation(text: &str, mut at: usize) -> Option { + let bytes = text.as_bytes(); + let blank = |at: usize| matches!(bytes.get(at), Some(b' ' | b'\t')); + let mut names = Vec::new(); + let mut type_args_at = None; + while names.len() < 8 { + while blank(at) { + at += 1; + } + let start = at; + if !bytes + .get(at) + .is_some_and(|b| b.is_ascii_alphabetic() || matches!(b, b'_' | b'$')) + { + return None; + } + while bytes + .get(at) + .is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'$')) + { + at += 1; + } + names.push(text[start..at].to_string()); + while blank(at) { + at += 1; + } + if bytes.get(at) == Some(&b'<') && type_args_at.is_none() { + let close = crate::dart_chains::type_arguments_end(bytes, at)?; + type_args_at = Some(names.len() - 1); + at = close + 1; + while blank(at) { + at += 1; + } + } + if bytes.get(at) != Some(&b'.') { + break; + } + at += 1; + } + Some(Read { + names, + type_args_at, + call: bytes.get(at) == Some(&b'('), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(text: &str) -> Option<(Vec, Option, bool)> { + read_dart_annotation(text, 1).map(|read| (read.names, read.type_args_at, read.call)) + } + + fn names(list: &[&str]) -> Vec { + list.iter().map(|name| (*name).to_string()).collect() + } + + #[test] + fn an_annotation_is_read_as_written_from_its_at() { + assert_eq!( + read("@riverpod\n"), + Some((names(&["riverpod"]), None, false)) + ); + assert_eq!( + read("@Riverpod(keepAlive: true)"), + Some((names(&["Riverpod"]), None, true)) + ); + assert_eq!( + read("@meta.immutable\n"), + Some((names(&["meta", "immutable"]), None, false)) + ); + assert_eq!( + read("@Riverpod.forever()"), + Some((names(&["Riverpod", "forever"]), None, true)) + ); + assert_eq!( + read("@Foo.named(1)"), + Some((names(&["Foo", "named"]), Some(0), true)) + ); + assert_eq!( + read("@ p . Foo . named (1)"), + Some((names(&["p", "Foo", "named"]), None, true)) + ); + assert_eq!(read("@1x"), None); + } +} diff --git a/crates/codegraph-resolve/src/dart_chains.rs b/crates/codegraph-resolve/src/dart_chains.rs new file mode 100644 index 00000000..b2c1d5ac --- /dev/null +++ b/crates/codegraph-resolve/src/dart_chains.rs @@ -0,0 +1,3029 @@ +//! A later link of a Dart call chain (upstream #2376, #2383). +//! +//! The extractor keeps one receiver level, so a later link of a chain arrives +//! by its bare name (`events.map(mapper).transform(…)` → `transform`) or, past +//! a member chain, by its last segment (`FutureProvider.autoDispose.family(…)` +//! → `autoDispose.family`, #1496). Such a link calls a member of what the +//! chain before it evaluates to, typed from evidence alone: the chain's head (a +//! type, a constructor call, a string, `this` / `super`, an import prefix, a +//! local, parameter, field, getter or constant with a declared type) and each +//! link's declared type (a method's or getter's return type with the method's +//! own type parameters substituted, a field's or abstract getter's type, what +//! a static constant is initialized with). A lookup nothing declared answers +//! for hands back its one type argument (`context.read()`), which +//! is also how a chain on a generic lookup (`BlocProvider.of(…)`, #2383) +//! is typed. Without evidence it links nothing — never a member found by its +//! name. +//! +//! The chain is read from the file's code with comments and string contents +//! blanked, backwards from the link. KEEP-RUST: the extractor places a chain's +//! references where the chain starts, so the link is looked for after that +//! position, written `.m(` (or `.s.m(`) as the extractor recorded the call, +//! with the chain read back from it starting exactly there. Several such +//! links in one chain (`x.m().m()`) carry the same reference, which links only +//! what all of them reach. + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; + +use crate::dart_members::{dart_lineage, dart_member_of, is_dart_getter}; +use crate::dart_scope::{CHAIN_LINES, in_order, is_dart_identifier, is_dart_type_kind}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// Lookups that hand back an instance of the one type they are given: +/// provider's and flutter_bloc's `context.read()` / `watch()` / +/// `BlocProvider.of(context)`, get_it's `get()`, GetX's `find()`, +/// and Flutter's `findAncestorStateOfType()` family. +const TYPE_ARGUMENT_LOOKUPS: &[&str] = &[ + "read", + "watch", + "get", + "find", + "of", + "maybeOf", + "call", + "dependOnInheritedWidgetOfExactType", + "getInheritedWidgetOfExactType", + "findAncestorStateOfType", + "findAncestorWidgetOfExactType", + "findRootAncestorStateOfType", +]; + +/// Words that can stand before a Dart name without being its declared type. +const NON_TYPE_WORDS: &[&str] = &[ + "final", + "var", + "late", + "const", + "required", + "covariant", + "static", + "external", + "return", + "await", + "yield", + "throw", + "new", + "in", + "is", + "as", + "case", + "else", + "if", + "for", + "while", + "do", + "switch", + "assert", + "this", + "super", + "get", + "set", + "operator", + "async", + "sync", + "show", + "hide", + "typedef", + "extends", + "with", + "implements", + "on", +]; + +/// How deep a chain's typing goes through the chains it reads its parts from. +const MAX_DEPTH: u32 = 3; + +/// A Dart file's code with comments and string contents blanked, byte +/// offsets kept, and where each line starts (upstream `DartCode`). +#[derive(Debug)] +pub struct DartCode { + pub text: String, + line_starts: Vec, + /// The calls written after a `.` that the extractor records by their + /// member alone or past a segment, by where the chain each is read back + /// from starts ([`link_index`]). + links: OnceLock>>, + /// Where each name asked for is written other than as a member + /// ([`name_uses`]), read once per name. + uses: Mutex>>>, +} + +impl DartCode { + pub fn of(source: &str) -> Self { + let text = blank_dart_code(source); + let mut line_starts = vec![0]; + line_starts.extend( + text.bytes() + .enumerate() + .filter(|(_, byte)| *byte == b'\n') + .map(|(at, _)| at + 1), + ); + Self { + text, + line_starts, + links: OnceLock::new(), + uses: Mutex::new(HashMap::new()), + } + } + + /// Where `name` is written in the code other than as a member, in order + /// (upstream `dartNameUses`). + pub(crate) fn name_uses(&self, name: &str) -> Arc> { + if let Some(hit) = self + .uses + .lock() + .ok() + .and_then(|memo| memo.get(name).cloned()) + { + return hit; + } + let uses = Arc::new(name_uses(&self.text, name)); + if let Ok(mut memo) = self.uses.lock() { + memo.insert(name.to_string(), Arc::clone(&uses)); + } + uses + } + + /// The calls a chain starting at `position` links ([`link_index`]), in + /// the order they are written. + fn links_at(&self, position: usize) -> &[LinkAt] { + self.links + .get_or_init(|| link_index(&self.text)) + .get(&position) + .map_or(&[], Vec::as_slice) + } + + /// The byte offset 1-based `line` starts at. + pub fn line_start(&self, line: i64) -> Option { + let index = usize::try_from(line).ok()?.checked_sub(1)?; + self.line_starts.get(index).copied() + } + + /// The text of 1-based `line`, without its newline. + fn line_text(&self, line: i64) -> Option<&str> { + let start = self.line_start(line)?; + let end = self + .line_start(line + 1) + .map_or(self.text.len(), |next| next - 1); + self.text.get(start..end) + } + + /// The byte offset of `column` on 1-based `line`. + fn offset(&self, line: i64, column: i64) -> Option { + let start = self.line_start(line)?; + let at = start + usize::try_from(column).ok()?; + (at <= self.text.len()).then_some(at) + } +} + +/// The byte length of the UTF-8 sequence at `at` (1 past the end). +fn char_len(src: &[u8], at: usize) -> usize { + match src.get(at) { + Some(byte) if *byte >= 0xF0 => 4, + Some(byte) if *byte >= 0xE0 => 3, + Some(byte) if *byte >= 0xC0 => 2, + _ => 1, + } +} + +/// A byte of `[\w$]`. +fn is_word_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$' +} + +/// A byte JavaScript's `\s` matches (ASCII). +fn is_space(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c) +} + +/// The largest char boundary of `text` at or below `at`. +fn floor_boundary(text: &str, at: usize) -> usize { + let mut at = at.min(text.len()); + while !text.is_char_boundary(at) { + at -= 1; + } + at +} + +/// Dart source with comments and string contents blanked, offsets kept +/// (upstream `blankDartCode`): a literal keeps its quotes, and an +/// interpolation's code (`'${a.b()}'`) stays, so a chain inside one reads +/// like any other. Whole characters are blanked, so the result is as long, in +/// bytes, as the source. +pub fn blank_dart_code(source: &str) -> String { + #[derive(Clone, Copy)] + struct Literal { + quote: u8, + triple: bool, + raw: bool, + } + fn blank(out: &mut [u8], from: usize, to: usize) { + let to = to.min(out.len()); + for byte in &mut out[from.min(to)..to] { + if *byte != b'\n' && *byte != b'\r' { + *byte = b' '; + } + } + } + let src = source.as_bytes(); + let n = src.len(); + let mut out = src.to_vec(); + // The literals an open `${…}` returns to, with the brace depth around each. + let mut outer: Vec<(Literal, usize)> = Vec::new(); + let mut literal: Option = None; + let mut depth = 0usize; + let mut i = 0usize; + while i < n { + let c = src[i]; + if let Some(lit) = literal { + let closing = if lit.triple { + src.get(i..i + 3) == Some(&[lit.quote; 3][..]) + } else { + c == lit.quote + }; + if !lit.raw && c == b'\\' { + let to = (i + 1 + char_len(src, i + 1)).min(n); + blank(&mut out, i, to); + i = to; + } else if !lit.raw && c == b'$' && src.get(i + 1) == Some(&b'{') { + blank(&mut out, i, i + 2); + outer.push((lit, depth)); + literal = None; + depth = 0; + i += 2; + } else if closing { + i += if lit.triple { 3 } else { 1 }; + literal = None; + } else if !lit.triple && c == b'\n' { + literal = None; + i += 1; + } else { + let to = (i + char_len(src, i)).min(n); + blank(&mut out, i, to); + i = to; + } + continue; + } + if c == b'/' && src.get(i + 1) == Some(&b'/') { + let stop = src[i..] + .iter() + .position(|byte| *byte == b'\n') + .map_or(n, |at| i + at); + blank(&mut out, i, stop); + i = stop; + } else if c == b'/' && src.get(i + 1) == Some(&b'*') { + let mut nest = 1usize; + let mut j = i + 2; + while j < n && nest > 0 { + if src[j] == b'/' && src.get(j + 1) == Some(&b'*') { + nest += 1; + j += 2; + } else if src[j] == b'*' && src.get(j + 1) == Some(&b'/') { + nest -= 1; + j += 2; + } else { + j += 1; + } + } + let j = j.min(n); + blank(&mut out, i, j); + i = j; + } else if c == b'\'' || c == b'"' { + let triple = src.get(i..i + 3) == Some(&[c; 3][..]); + let raw = i >= 1 && src[i - 1] == b'r' && !(i >= 2 && is_word_byte(src[i - 2])); + literal = Some(Literal { + quote: c, + triple, + raw, + }); + i += if triple { 3 } else { 1 }; + } else { + if !outer.is_empty() && c == b'{' { + depth += 1; + } else if !outer.is_empty() && c == b'}' { + if depth == 0 { + blank(&mut out, i, i + 1); + if let Some((lit, around)) = outer.pop() { + literal = Some(lit); + depth = around; + } + i += 1; + continue; + } + depth -= 1; + } + i += 1; + } + } + // Only whole characters were replaced, by spaces. + String::from_utf8(out).unwrap_or_else(|_| source.to_string()) +} + +/// Where `name` is written in `text` other than as a member (`.name`): a +/// whole word (upstream `dartNameUses`, `(? Vec { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + if name.is_empty() { + return out; + } + let mut from = 0; + while let Some(found) = text.get(from..).and_then(|rest| rest.find(name)) { + let at = from + found; + let end = at + name.len(); + let before = at + .checked_sub(1) + .is_some_and(|prev| is_word_byte(bytes[prev]) || bytes[prev] == b'.'); + let after = bytes.get(end).is_some_and(|next| is_word_byte(*next)); + if !before && !after { + out.push(at); + } + from = end; + } + out +} + +/// The offset of the `open` bracket matching the `close` one at `at`, read +/// backwards; `None` past `limit` bytes (upstream `dartOpening`). +fn opening(text: &[u8], at: usize, open: u8, close: u8, limit: usize) -> Option { + let mut depth = 0i64; + let mut k = at; + loop { + if at - k >= limit { + return None; + } + let ch = *text.get(k)?; + if ch == close { + depth += 1; + } else if ch == open { + depth -= 1; + if depth == 0 { + return Some(k); + } + } else if open == b'<' && matches!(ch, b';' | b'{' | b'}' | b'=') { + return None; + } + k = k.checked_sub(1)?; + } +} + +/// The simple name a Dart type is written as: `Foo` for `p.Foo?` +/// (upstream `dartSimpleTypeName`). +pub(crate) fn simple_type_name(text: &str) -> Option { + let cut = text.find('<').map_or(text, |at| &text[..at]); + let unwrapped: String = cut.chars().filter(|c| *c != '?').collect(); + let last = unwrapped.trim().rsplit('.').next()?.trim(); + is_dart_identifier(last).then(|| last.to_string()) +} + +/// What the nearest declaration of a name before a place says of its type +/// ([`local_declaration`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum LocalDeclaration { + /// The type written before the name (`final Ticker _ticker`, `Foo? x`, + /// `(BuildContext context)`). + Typed(String), + /// What the initializer starting at this offset evaluates to (`final + /// todo = Todo(…)`). + Initializer(usize), + /// None said: `final x;`, a `for (final x in …)` variable, an untyped + /// closure parameter, a `catch` variable. + Untyped, +} + +/// The nearest declaration of `name` in `code` before offset `at`, within the +/// scope starting at offset `start`, read from `uses` (where `name` is +/// written, [`name_uses`]); `None` for none (upstream `dartLocalType`). +pub(crate) fn local_declaration( + code: &DartCode, + uses: &[usize], + start: usize, + at: usize, + name: &str, +) -> Option { + static DECLARED: OnceLock = OnceLock::new(); + static TYPED_AFTER: OnceLock = OnceLock::new(); + static KEYWORD: OnceLock = OnceLock::new(); + static CALLEE: OnceLock = OnceLock::new(); + static CALLEE_TAIL: OnceLock = OnceLock::new(); + static BODY: OnceLock = OnceLock::new(); + if start > at { + return None; + } + let text = code.text.as_str(); + let scope = &text[start..at]; + let declared = DECLARED.get_or_init(|| { + Regex::new(r"([A-Za-z_$][A-Za-z0-9_$]*)\s*(<[^;{}()=]*>)?\s*\??\s+$") + .expect("dart declared") + }); + let typed_after = TYPED_AFTER + .get_or_init(|| Regex::new(r"^\s*(?:[=;,)}]|in(?-u:\b))").expect("dart typed after")); + let keyword = KEYWORD.get_or_init(|| { + Regex::new(r"(?:^|[^A-Za-z0-9_$])(?:final|var|late|const)\s+$").expect("dart keyword") + }); + let callee = CALLEE.get_or_init(|| { + Regex::new(r"([A-Za-z0-9_$]*)\s*(?:<[^;{}()]*>)?\s*$").expect("dart callee") + }); + let callee_tail = + CALLEE_TAIL.get_or_init(|| Regex::new(r"[A-Za-z0-9_$>)\]]\s*$").expect("dart tail")); + let body = BODY.get_or_init(|| { + Regex::new(r"^\)\s*(?:async\s*\*?|sync\s*\*)?\s*(?:=>|\{)").expect("dart body") + }); + // The last use before the call: uses are in file order. + let lo = uses.partition_point(|used| *used < at); + for &used in uses[..lo].iter().rev() { + if used < start { + break; + } + let pos = used - start; + let before = &scope[floor_boundary(scope, pos.saturating_sub(120))..pos]; + let after = scope.get(pos + name.len()..).unwrap_or(""); + let typed = declared.captures(before).and_then(|caps| { + let word = caps.get(1)?.as_str(); + (!NON_TYPE_WORDS.contains(&word) + && typed_after.is_match(after) + && !after.trim_start().starts_with("==")) + .then(|| word.to_string()) + }); + if let Some(typed) = typed { + return Some(LocalDeclaration::Typed(typed)); + } + // `final x = …`, `var x = …`: the initializer says the type. + if keyword.is_match(before) { + let rest = after.trim_start(); + let initialized = + rest.starts_with('=') && !rest[1..].starts_with('=') && !rest[1..].starts_with('>'); + if !initialized { + return Some(LocalDeclaration::Untyped); + } + return Some(LocalDeclaration::Initializer( + start + pos + name.len() + (after.len() - rest.len()) + 1, + )); + } + // A closure's untyped parameter, `(context, state) =>`, or a + // `catch (e)`; not an argument of a call, `foo(x, (a) {…})`, nor + // an `if (x) {`. + let bytes = text.as_bytes(); + if after.trim_start().starts_with([',', ')']) && before.trim_end().ends_with(['(', ',']) { + let open = group_open(bytes, start + pos); + let close = open.and_then(|open| group_close(bytes, open)); + let lead = open.map_or("", |open| { + &text[floor_boundary(text, open.saturating_sub(60))..open] + }); + let called = callee + .captures(lead) + .and_then(|caps| caps.get(1)) + .map_or("", |m| m.as_str()); + if called == "catch" { + return Some(LocalDeclaration::Untyped); + } + if let Some(close) = close + && !callee_tail.is_match(lead) + && body.is_match(&text[close..floor_boundary(text, close + 30)]) + { + return Some(LocalDeclaration::Untyped); + } + } + } + None +} + +/// The last character of the Dart expression starting at `from`, where its +/// statement, argument or element ends (upstream `dartInitializerType`). +fn initializer_end(text: &str, from: usize) -> Option { + let bytes = text.as_bytes(); + let mut nest = 0i64; + let mut k = from; + while k < bytes.len() && k - from < 4000 { + let ch = bytes[k]; + // `AsyncNotifierProvider.family(…)`: a type argument + // list's commas end nothing. + if ch == b'<' + && k > 0 + && is_word_byte(bytes[k - 1]) + && let Some(close) = type_arguments_end(bytes, k) + && close > 0 + { + k = close + 1; + continue; + } + if matches!(ch, b'(' | b'[' | b'{') { + nest += 1; + } else if matches!(ch, b')' | b']' | b'}') { + if nest == 0 { + break; + } + nest -= 1; + } else if (ch == b';' || ch == b',') && nest == 0 { + break; + } + k += 1; + } + let mut end = k.checked_sub(1)?; + while end >= from && is_space(bytes[end]) { + end = end.checked_sub(1)?; + } + (end >= from).then_some(end) +} + +/// The type a parenthesized Dart expression casts to (`(x as Foo)`), as +/// written. +fn dart_cast(inner: &str) -> Option<&str> { + static CAST: OnceLock = OnceLock::new(); + let cast = CAST.get_or_init(|| { + Regex::new( + r"(?-u:\b)as\s+((?:[A-Za-z_$][A-Za-z0-9_$]*\.)?[A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^()]*>)?\s*\??\s*$", + ) + .expect("dart cast") + }); + cast.captures(inner) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str()) +} + +/// A link of a chain: `.name`, `.name<…>(…)` (`call`), or a subscript `[…]` +/// (`index`). +#[derive(Debug, Clone, PartialEq, Eq)] +struct Link { + name: String, + type_args: Vec, + call: bool, + index: bool, +} + +/// What a chain starts from: a name (`x`, `Foo`, `foo(…)`, `Foo(…)`, with +/// the keyword written before it), a string literal, or a parenthesized +/// expression. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Head { + Name { + name: String, + type_args: Vec, + call: bool, + keyword: String, + /// Where the name starts. + start: usize, + /// Where the keyword starts. + keyword_start: usize, + }, + String, + Paren { + open: usize, + close: usize, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Chain { + head: Head, + links: Vec, +} + +impl Chain { + /// Where the expression the chain reads starts, as the extractor places + /// its references: the name's start, a `const` or `new` before it, or the + /// parenthesis. + fn start(&self) -> Option { + match &self.head { + Head::Name { + keyword, + start, + keyword_start, + .. + } => Some(if keyword == "const" || keyword == "new" { + *keyword_start + } else { + *start + }), + Head::Paren { open, .. } => Some(*open), + Head::String => None, + } + } +} + +/// The last word of `text[..=j]` within its last eleven bytes, a letter or +/// `_` first (upstream's `/([A-Za-z_]\w*)$/` over that slice), with where it +/// starts. +fn trailing_keyword(text: &[u8], j: isize) -> (String, usize) { + let Ok(j) = usize::try_from(j) else { + return (String::new(), 0); + }; + let from = j.saturating_sub(10); + let slice = &text[from..=j.min(text.len() - 1)]; + let mut start = slice.len(); + while start > 0 && (slice[start - 1].is_ascii_alphanumeric() || slice[start - 1] == b'_') { + start -= 1; + } + let run = &slice[start..]; + match run + .iter() + .position(|byte| byte.is_ascii_alphabetic() || *byte == b'_') + { + Some(first) => ( + String::from_utf8_lossy(&run[first..]).into_owned(), + from + start + first, + ), + None => (String::new(), 0), + } +} + +/// The links and head of the Dart expression whose last character is at +/// `end`, read backwards (upstream `readDartChain`): `FutureProvider. +/// autoDispose` is `FutureProvider` then `.autoDispose`; `events.map(mapper)` +/// is `events` then `.map(…)`. `None` for a shape the walk does not read: a +/// cascade section, a number, a call of an expression. +fn read_chain(text: &str, end: usize) -> Option { + let bytes = text.as_bytes(); + let at = |i: isize| -> u8 { + usize::try_from(i) + .ok() + .and_then(|i| bytes.get(i).copied()) + .unwrap_or(0) + }; + let skip = |i: &mut isize| { + while *i >= 0 && is_space(at(*i)) { + *i -= 1; + } + }; + let mut links: Vec = Vec::new(); + let mut i = isize::try_from(end).ok()?; + for _ in 0..32 { + skip(&mut i); + while at(i) == b'!' { + i -= 1; + skip(&mut i); + } + if at(i) == b']' { + let open = opening(bytes, i as usize, b'[', b']', 20_000)?; + links.push(Link { + name: String::new(), + type_args: Vec::new(), + call: false, + index: true, + }); + i = open as isize - 1; + continue; + } + if at(i) == b'\'' || at(i) == b'"' { + links.reverse(); + return Some(Chain { + head: Head::String, + links, + }); + } + let mut call = false; + let mut type_args: Vec = Vec::new(); + if at(i) == b')' { + let close = i as usize; + let open = opening(bytes, close, b'(', b')', 20_000)?; + i = open as isize - 1; + skip(&mut i); + if at(i) == b'>' { + let lt = opening(bytes, i as usize, b'<', b'>', 400)?; + type_args = crate::cpp_namespaces::split_cpp_top_level(&text[lt + 1..i as usize]); + i = lt as isize - 1; + skip(&mut i); + } + if !is_word_byte(at(i)) { + // `(…)` with no name before it is the head, unless an + // expression is called: `f()(…)`. + if !type_args.is_empty() || at(i) == b')' || at(i) == b']' { + return None; + } + links.reverse(); + return Some(Chain { + head: Head::Paren { open, close }, + links, + }); + } + call = true; + } else if at(i) == b'>' { + // `BlocProvider.value(…)`: a type written with its + // arguments. + let lt = opening(bytes, i as usize, b'<', b'>', 400)?; + type_args = crate::cpp_namespaces::split_cpp_top_level(&text[lt + 1..i as usize]); + i = lt as isize - 1; + skip(&mut i); + } + let name_end = usize::try_from(i + 1).ok()?; + while i >= 0 && is_word_byte(at(i)) { + i -= 1; + } + let name_start = usize::try_from(i + 1).ok()?; + let name = text.get(name_start..name_end)?; + if !is_dart_identifier(name) { + return None; + } + let mut j = i; + while j >= 0 && is_space(at(j)) { + j -= 1; + } + if at(j) == b'.' { + // `obj..a().b()`: a cascade section's receiver is the cascade's + // target. + if at(j - 1) == b'.' { + return None; + } + links.push(Link { + name: name.to_string(), + type_args, + call, + index: false, + }); + i = j - 1; + if at(i) == b'?' { + i -= 1; + } + continue; + } + let (keyword, keyword_start) = trailing_keyword(bytes, j); + links.reverse(); + return Some(Chain { + head: Head::Name { + name: name.to_string(), + type_args, + call, + keyword, + start: name_start, + keyword_start, + }, + links, + }); + } + None +} + +/// The `>` closing a Dart type argument list opened at `open`, one followed by +/// a call, a member or another `>` (which a comparison's `<` never is) +/// (upstream `dartTypeArgumentsEnd`). +pub(crate) fn type_arguments_end(text: &[u8], open: usize) -> Option { + let mut depth = 0i64; + let mut k = open; + while k < text.len() && k - open < 400 { + let ch = text[k]; + if ch == b'<' { + depth += 1; + } else if ch == b'>' { + depth -= 1; + if depth == 0 { + let next = &text[k + 1..(k + 4).min(text.len())]; + let first = next.iter().copied().find(|byte| !is_space(*byte)); + return matches!(first, Some(b'(' | b'.' | b'>' | b'?')).then_some(k); + } + } else if matches!(ch, b';' | b'{' | b'}' | b'=') { + return None; + } + k += 1; + } + None +} + +/// The `(` of the parenthesized group around offset `at`, read backwards +/// (upstream `dartGroupOpen`). +fn group_open(text: &[u8], at: usize) -> Option { + let mut parens = 0i64; + let mut braces = 0i64; + let mut k = at; + while k > 0 && at - (k - 1) < 4000 { + k -= 1; + let ch = text[k]; + if ch == b'}' { + braces += 1; + } else if ch == b'{' { + if braces == 0 { + return None; + } + braces -= 1; + } else if braces > 0 { + continue; + } else if ch == b')' { + parens += 1; + } else if ch == b'(' { + if parens == 0 { + return Some(k); + } + parens -= 1; + } else if ch == b';' { + return None; + } + } + None +} + +/// The `)` closing the `(` at `open` (upstream `dartGroupClose`). +fn group_close(text: &[u8], open: usize) -> Option { + let mut depth = 0i64; + let mut k = open; + while k < text.len() && k - open < 8000 { + if text[k] == b'(' { + depth += 1; + } else if text[k] == b')' { + depth -= 1; + if depth == 0 { + return Some(k); + } + } + k += 1; + } + None +} + +/// What a chain evaluates to: an instance of `type_name`, or (`is_static`) +/// the type itself, before a static member — of the type the file `owner` +/// declares, when the chain says which (`p.Box` names the one `p` brings in, +/// upstream #2389) (upstream `DartValue`). +#[derive(Debug, Clone, PartialEq, Eq)] +struct Value { + type_name: String, + is_static: bool, + via_super: bool, + owner: Option, +} + +fn instance(type_name: String) -> Value { + Value { + type_name, + is_static: false, + via_super: false, + owner: None, + } +} + +/// A Dart function's declared return type as a chain reads it, with the +/// function's own type parameters (upstream `DART_RETURN_TYPES`). +#[derive(Debug)] +pub struct ReturnInfo { + type_name: String, + own: Vec, +} + +/// Where an expression is typed: a reference of the file, and where its call +/// is written (the name a declaration of the code before it is looked for +/// up to). +#[derive(Debug, Clone)] +struct Site { + view: RefView, + at: Option, +} + +/// One chain link's resolution. +struct Typing<'c> { + context: &'c dyn ResolutionContext, + /// The link's member name, which a site in another file looks for on its + /// line, as upstream's do. + name: &'c str, +} + +impl Typing<'_> { + fn code(&self, file: &str) -> Option> { + Some(self.context.source_facts(file)?.dart_code()) + } + + /// A site at the start of 1-based `line` of `file`, as upstream's + /// `{ ...ref, filePath, line, column: 0 }`: its call is where the link's + /// name first shows on that line. + fn site_at(&self, base: &Site, file: &str, line: i64) -> Site { + let mut view = base.view.clone(); + view.file_path = file.to_string(); + view.line = line; + view.column = 0; + let at = self.code(file).and_then(|code| { + let start = code.line_start(line)?; + let text = code.line_text(line)?; + text.find(self.name).map(|found| start + found) + }); + Site { view, at } + } + + fn is_type_name(&self, name: &str) -> bool { + self.context + .get_nodes_by_name_shared(name) + .iter() + .any(|node| node.language == Language::Dart && is_dart_type_kind(node.kind)) + } + + /// What a chain evaluates to, link by link, or `None` where the evidence + /// runs out (upstream `typeDartChain`). + fn type_chain(&self, chain: &Chain, site: &Site, depth: u32) -> Option { + if depth > MAX_DEPTH { + return None; + } + let mut links: &[Link] = &chain.links; + let mut value = match &chain.head { + Head::String => Some(instance("String".to_string())), + Head::Paren { open, close } => self.type_paren(*open, *close, site, depth), + Head::Name { + name, + type_args, + call, + keyword, + start, + .. + } => { + // `p.Provider.autoDispose`: an import prefix names a library, + // so the chain starts at the link after it, which names what + // that library exports (upstream #2389). A parameter, local + // or member named like the prefix hides it. + if let Some(first) = links.first() + && !*call + && type_args.is_empty() + && !first.index + && crate::dart_libraries::dart_import_prefixes( + &site.view.file_path, + self.context, + ) + .contains(name.as_str()) + && !crate::dart_prefixes::is_dart_name_hidden( + name, + &site.view.file_path, + site.view.line, + *start, + self.context, + ) + { + links = &links[1..]; + self.prefixed_head(name, first, site, depth) + } else { + // `const Foo.named(…)`: the named constructor is the + // head's own link. + if (keyword == "const" || keyword == "new") + && !*call + && links.first().is_some_and(|link| link.call) + { + links = &links[1..]; + } + self.type_head(name, type_args, *call, keyword, site, depth) + } + } + }; + for link in links { + // Every Dart object's `toString()` returns a String. + if link.name == "toString" && link.call && link.type_args.is_empty() { + value = Some(instance("String".to_string())); + continue; + } + let next = value + .as_ref() + .and_then(|value| self.type_link(value, link, site, depth)); + // A lookup nothing declared answers for hands back the type it is + // given, whatever it is called on: `context.read()`. + let given = if next.is_none() + && link.call + && link.type_args.len() == 1 + && TYPE_ARGUMENT_LOOKUPS.contains(&link.name.as_str()) + && !value + .as_ref() + .is_some_and(|value| self.link_declared(value, link, site)) + { + simple_type_name(&link.type_args[0]) + } else { + None + }; + // An untyped value stays untyped until a link says what it makes. + value = next.or_else(|| given.map(instance)); + } + value + } + + /// Whether the type a chain has reached declares the member a link names. + fn link_declared(&self, value: &Value, link: &Link, site: &Site) -> bool { + if value.is_static { + self.static_member(&value.type_name, &link.name, site, value.owner.as_deref()) + .is_some() + } else { + dart_member_of( + &value.type_name, + &link.name, + &site.view, + self.context, + &|node| !is_setter(node, self.context), + u64::from(value.via_super), + ) + .is_some() + } + } + + /// `(x as Foo)` is a Foo; any other parenthesized expression is what its + /// last operand is: `(a ?? Todo(…))` (upstream `typeDartParen`). + fn type_paren(&self, open: usize, close: usize, site: &Site, depth: u32) -> Option { + let code = self.code(&site.view.file_path)?; + let inner = code.text.get(open + 1..close)?; + if let Some(cast) = dart_cast(inner) { + return simple_type_name(cast).map(instance); + } + let bytes = code.text.as_bytes(); + let mut end = close.checked_sub(1)?; + while end > open && is_space(bytes[end]) { + end -= 1; + } + self.expression_value(&code.text, end, site, depth) + } + + /// The value of the expression ending at `end` of the site's code: an + /// instance, never a type, and never an awaited one. + fn expression_value(&self, text: &str, end: usize, site: &Site, depth: u32) -> Option { + let chain = read_chain(text, end)?; + if matches!(&chain.head, Head::Name { keyword, .. } if keyword == "await") { + return None; + } + let value = self.type_chain(&chain, site, depth + 1)?; + (!value.is_static).then_some(value) + } + + /// What a chain's head evaluates to (upstream `typeDartHead`). + fn type_head( + &self, + name: &str, + type_args: &[String], + call: bool, + keyword: &str, + site: &Site, + depth: u32, + ) -> Option { + if name == "this" || name == "super" { + let own = self.enclosing_type(site)?; + return Some(Value { + type_name: own.name.clone(), + is_static: false, + via_super: name == "super", + owner: None, + }); + } + if matches!(keyword, "const" | "new" | "as") { + return Some(instance(name.to_string())); + } + if keyword == "is" { + return None; + } + let is_type = self.is_type_name(name); + if call { + // `Foo(…)` constructs a Foo; `foo(…)` is what the function says it + // returns, or, a variable holding a callable, what its `call` + // does. + if is_type { + return Some(instance(name.to_string())); + } + if let Some(function) = self.function_named(name, site) { + return self.return_type(&function, type_args).map(instance); + } + let held = self.variable_type(name, site, depth)?; + return self.call_result(&held, type_args, site); + } + // `Foo.` before a static member, a type outside the project included, + // whose members nothing reaches. + let uppercase = name.starts_with(|c: char| c.is_ascii_uppercase()); + if is_type + || (uppercase + && !self + .context + .get_nodes_by_name_shared(name) + .iter() + .any(|node| { + node.language == Language::Dart + && matches!(node.kind, NodeKind::Constant | NodeKind::Variable) + })) + { + return Some(Value { + type_name: name.to_string(), + is_static: true, + via_super: false, + owner: None, + }); + } + self.variable_type(name, site, depth).map(instance) + } + + /// What one link of a chain makes of the value before it (upstream + /// `typeDartLink`). + fn type_link(&self, value: &Value, link: &Link, site: &Site, depth: u32) -> Option { + if link.index { + return None; + } + if value.is_static { + let Some(member) = + self.static_member(&value.type_name, &link.name, site, value.owner.as_deref()) + else { + // `BlocProvider.value(…)`: a constructor the index holds no + // node for. + return (link.call && self.is_type_name(&value.type_name)) + .then(|| instance(value.type_name.clone())); + }; + if member.kind == NodeKind::EnumMember { + return (!link.call).then(|| instance(value.type_name.clone())); + } + if member.kind == NodeKind::Constant { + // `FutureProvider.autoDispose`: the value the static constant + // holds. + let held = self.constant_type(&member, site, depth)?; + return if link.call { + self.call_result(&held, &link.type_args, site) + } else { + Some(instance(held)) + }; + } + return self.member_use(&member, link, site); + } + if let Some((member, _)) = dart_member_of( + &value.type_name, + &link.name, + &site.view, + self.context, + &|node| !is_setter(node, self.context), + u64::from(value.via_super), + ) { + return self.member_use(&member, link, site); + } + // A field mints no node: its declared type is read from its class. + if link.call { + return None; + } + self.field_type(&value.type_name, &link.name, site, depth) + .map(instance) + } + + /// What using a member makes: a method's call or a getter's read is its + /// declared return type; a getter called is its value called (upstream + /// `typeDartMemberUse`). + fn member_use(&self, member: &Node, link: &Link, site: &Site) -> Option { + let getter = is_dart_getter(member, self.context); + let type_args: &[String] = if getter { &[] } else { &link.type_args }; + let type_name = self.return_type(member, type_args)?; + if getter { + return if link.call { + self.call_result(&type_name, &link.type_args, site) + } else { + Some(instance(type_name)) + }; + } + // A method named without a call is a tear-off: a function, not its + // result. + link.call.then(|| instance(type_name)) + } + + /// What calling a value of `type_name` returns: its `call` method's + /// declared return type (upstream `dartCallResult`). + fn call_result(&self, type_name: &str, type_args: &[String], site: &Site) -> Option { + let (call, _) = dart_member_of( + type_name, + "call", + &site.view, + self.context, + &|node| !is_dart_getter(node, self.context), + 0, + )?; + self.return_type(&call, type_args).map(instance) + } + + /// A Dart function's declared return type as a simple name, a type + /// parameter of its own replaced by the type argument the call gives + /// (`T read()` called as `read()` is a Foo); a type parameter of + /// its class (`State get state` in `BlocBase`) says nothing + /// without the receiver's own arguments (upstream `dartReturnType`). + fn return_type(&self, function: &Node, type_args: &[String]) -> Option { + let tables = self.context.dart_tables(); + let memo = tables + .return_types + .lock() + .ok() + .and_then(|memo| memo.get(&function.id).cloned()); + let info = match memo { + Some(info) => info, + None => { + let info = self.read_return_type(function).map(Arc::new); + if let Ok(mut memo) = tables.return_types.lock() { + memo.insert(function.id.clone(), info.clone()); + } + info + } + }?; + match info.own.iter().position(|own| *own == info.type_name) { + None => Some(info.type_name.clone()), + Some(at) => type_args.get(at).and_then(|arg| simple_type_name(arg)), + } + } + + fn read_return_type(&self, function: &Node) -> Option { + let type_name = function.return_type.as_deref().and_then(simple_type_name)?; + // `static T of(…)` in `BlocProvider`: the function's own + // parameter is the one meant. + let own = crate::member_types::class_type_parameters(function, self.context); + if !own.contains(&type_name) + && let Some((owner_qn, _)) = function.qualified_name.rsplit_once("::") + && !owner_qn.is_empty() + { + let owner = in_order(self.context.get_nodes_in_file_shared(&function.file_path)) + .into_iter() + .find(|node| { + is_dart_type_kind(node.kind) + && node.qualified_name == owner_qn + && node.start_line <= function.start_line + && node.end_line >= function.end_line + }); + if let Some(owner) = owner + && crate::member_types::class_type_parameters(&owner, self.context) + .contains(&type_name) + { + return None; + } + } + Some(ReturnInfo { type_name, own }) + } + + /// The innermost Dart class, mixin, enum or extension the site is in + /// (upstream `dartEnclosingType`). + fn enclosing_type(&self, site: &Site) -> Option> { + let mut own: Option> = None; + for node in in_order(self.context.get_nodes_in_file_shared(&site.view.file_path)) { + if is_dart_type_kind(node.kind) + && node.start_line <= site.view.line + && node.end_line >= site.view.line + && own + .as_ref() + .is_none_or(|own| node.start_line >= own.start_line) + { + own = Some(node); + } + } + own + } + + /// The static member `name` the Dart type `type_name` itself declares + /// ([`crate::dart_members::dart_static_member`]), preferring the site's + /// file; of the type the file `owner` declares, when given. + fn static_member( + &self, + type_name: &str, + name: &str, + site: &Site, + owner: Option<&str>, + ) -> Option> { + crate::dart_members::dart_static_member( + type_name, + name, + &site.view.file_path, + self.context, + &|type_node| owner.is_none_or(|owner| type_node.file_path == owner), + ) + } + + /// What the first link after an import prefix evaluates to — + /// `p.Provider` before a static member, `p.Report(…)`, `p.make(…)`, + /// `p.config` — read from the top-level declarations the prefixed import + /// brings in, and nothing when it brings in none of that name: a package + /// outside the repository, whatever the project declares under the name + /// elsewhere (upstream #2389 `typeDartPrefixedHead`). + fn prefixed_head(&self, prefix: &str, link: &Link, site: &Site, depth: u32) -> Option { + let found = crate::dart_prefixes::dart_prefixed_decls( + prefix, + &link.name, + &site.view.file_path, + self.context, + ); + let types: Vec<&Arc> = found + .iter() + .filter(|node| is_dart_type_kind(node.kind)) + .collect(); + if !types.is_empty() { + return Some(Value { + type_name: link.name.clone(), + is_static: !link.call, + via_super: false, + owner: (types.len() == 1).then(|| types[0].file_path.clone()), + }); + } + let [decl] = found.as_slice() else { + return None; + }; + if decl.kind == NodeKind::Function { + return if link.call { + self.return_type(decl, &link.type_args).map(instance) + } else { + None + }; + } + if !matches!(decl.kind, NodeKind::Constant | NodeKind::Variable) { + return None; + } + let held = self.constant_type(decl, site, depth)?; + if link.call { + self.call_result(&held, &link.type_args, site) + } else { + Some(instance(held)) + } + } + + /// The function a bare Dart call `name(…)` means: a member of the type + /// around it (an implicit `this.`), else a function of the file, else + /// the one function of that name the library holds or the project + /// declares (upstream `dartFunctionNamed`). + fn function_named(&self, name: &str, site: &Site) -> Option> { + if let Some(own) = self.enclosing_type(site) + && let Some((member, _)) = dart_member_of( + &own.name, + name, + &site.view, + self.context, + &|node| !is_dart_getter(node, self.context) && !is_setter(node, self.context), + 0, + ) + { + return Some(member); + } + let functions: Vec> = self + .context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| { + node.language == Language::Dart + && node.kind == NodeKind::Function + && !node.qualified_name.contains("::") + }) + .collect(); + self.library_pick(&functions, site) + } + + /// Of same-named top-level Dart declarations, the one the site's library + /// holds: its own file, then another file of the library, else the only + /// one (upstream `dartLibraryPick`; KEEP-RUST: the library is + /// W4-DART-02's, upstream reads the file's `part` directives itself). + fn library_pick(&self, nodes: &[Arc], site: &Site) -> Option> { + if let Some(own) = nodes + .iter() + .find(|node| node.file_path == site.view.file_path) + { + return Some(Arc::clone(own)); + } + let shared: Vec<&Arc> = nodes + .iter() + .filter(|node| { + crate::dart_libraries::in_same_dart_library( + &site.view.file_path, + &node.file_path, + self.context, + ) + }) + .collect(); + if shared.len() == 1 { + return Some(Arc::clone(shared[0])); + } + (nodes.len() == 1).then(|| Arc::clone(&nodes[0])) + } + + /// The declared type of a Dart variable a chain starts from: a local or + /// parameter the code around the call declares (the nearest declaration + /// wins, and an untyped one ends the search), a field or getter of the + /// class around it, or a top-level constant (upstream `dartVariableType`). + fn variable_type(&self, name: &str, site: &Site, depth: u32) -> Option { + if let Some(local) = self.local_type(name, site, depth) { + return local; + } + if let Some(field) = + crate::member_types::infer_member_receiver_type(name, &site.view, self.context) + { + return simple_type_name(&field); + } + if let Some(own) = self.enclosing_type(site) + && let Some((getter, _)) = dart_member_of( + &own.name, + name, + &site.view, + self.context, + &|node| is_dart_getter(node, self.context), + 0, + ) + { + return self.return_type(&getter, &[]); + } + let constants: Vec> = self + .context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| { + node.language == Language::Dart + && node.kind == NodeKind::Constant + && !node.qualified_name.contains("::") + }) + .collect(); + let top = self.library_pick(&constants, site)?; + self.constant_type(&top, site, depth) + } + + /// The type of the nearest declaration of `name` before the call, within + /// the function around it ([`local_declaration`]): its declared type, + /// else what its initializer evaluates to. `Some(None)` for a declaration + /// that says no type; `None` for none (upstream `dartLocalType`). + fn local_type(&self, name: &str, site: &Site, depth: u32) -> Option> { + let facts = self.context.source_facts(&site.view.file_path)?; + let code = facts.dart_code(); + let at = site.at?; + let scope_line = crate::name_matcher::enclosing_scope_start_line(&site.view, self.context); + let start = code.line_start(scope_line).unwrap_or(0); + Some( + match local_declaration(&code, &code.name_uses(name), start, at, name)? { + LocalDeclaration::Typed(typed) => Some(typed), + LocalDeclaration::Initializer(from) => { + self.initializer_type(&code.text, from, site, depth) + } + LocalDeclaration::Untyped => None, + }, + ) + } + + /// What the Dart expression starting at `from` evaluates to, read back + /// from where its statement ends (upstream `dartInitializerType`). + fn initializer_type(&self, text: &str, from: usize, site: &Site, depth: u32) -> Option { + let end = initializer_end(text, from)?; + self.expression_value(text, end, site, depth) + .map(|value| value.type_name) + } + + /// The type a Dart constant is declared with (`static const Foo x = …`, + /// `final Foo? x`) or, with none written, what its initializer evaluates + /// to: riverpod's `static const autoDispose = + /// AutoDisposeFutureProviderBuilder();` (upstream `dartConstantType`). + fn constant_type(&self, constant: &Node, site: &Site, depth: u32) -> Option { + let code = self.code(&constant.file_path)?; + let from = code.line_start(constant.start_line)?; + let window = &code.text[from..floor_boundary(&code.text, from + 600)]; + let head = Regex::new(&format!( + r"(?-u:\b)(?:const|final|var)\s+(?:([A-Za-z_$][A-Za-z0-9_$.]*)\s*(?:<[^;=]*>)?\s*\??\s+)?{}\s*(=)(?:[^=>]|$)", + regex::escape(&constant.name) + )) + .ok()?; + let caps = head.captures(window)?; + if let Some(written) = caps.get(1) + && !NON_TYPE_WORDS.contains(&written.as_str()) + { + return simple_type_name(written.as_str()); + } + let init = from + caps.get(2)?.end(); + let at = self.site_at(site, &constant.file_path, constant.start_line); + self.initializer_type(&code.text, init, &at, depth + 1) + } + + /// The type a Dart class, or a type it extends, mixes in or implements, + /// declares a field `name` with (`final ProviderPointerManager + /// _pointerManager`), or what the field is initialized with (`final + /// _cache = Cache()`) (upstream `dartFieldType`). KEEP-RUST: the + /// initializer is read only where a field is declared, at the class's + /// member level; upstream's search runs over the class's text, method + /// bodies included. + fn field_type(&self, type_name: &str, name: &str, site: &Site, depth: u32) -> Option { + let lineage = dart_lineage(type_name, self.context); + let mut order: Vec<&(String, u64)> = lineage.order.iter().collect(); + order.sort_by_key(|(_, depth)| *depth); + let getter = Regex::new(&format!( + r"(?:^|[\s;{{}}])([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^;{{}}=]*>)?\s*\??\s+get\s+{}\s*;", + regex::escape(name) + )) + .ok()?; + let initializer = Regex::new(&format!( + r"(?-u:\b)(?:final|var|late)\s+{}\s*(=)(?:[^=>]|$)", + regex::escape(name) + )) + .ok()?; + for (owner, _) in order { + for decl in self.context.get_nodes_by_name_shared(owner) { + if decl.language != Language::Dart || !is_dart_type_kind(decl.kind) { + continue; + } + if let Some(declared) = + crate::member_types::class_member_type(&decl, name, self.context) + { + return simple_type_name(&declared); + } + // An abstract getter mints no node: `ProviderElement? get + // _listenedElement;`. + for (text, _) in crate::member_types::class_member_lines(&decl, self.context).iter() + { + if !text.contains(name) { + continue; + } + if let Some(caps) = getter.captures(text) + && !NON_TYPE_WORDS.contains(&&caps[1]) + { + return simple_type_name(&caps[1]); + } + } + let Some(code) = self.code(&decl.file_path) else { + continue; + }; + let Some(from) = code.line_start(decl.start_line) else { + continue; + }; + let to = code + .line_start(decl.end_line + 1) + .unwrap_or(code.text.len()); + let body = &code.text[from..to.max(from)]; + let bytes = body.as_bytes(); + let mut braces = 0i64; + let mut scanned = 0usize; + for caps in initializer.captures_iter(body) { + let Some(whole) = caps.get(0) else { + continue; + }; + for byte in &bytes[scanned..whole.start()] { + match byte { + b'{' => braces += 1, + b'}' => braces -= 1, + _ => {} + } + } + scanned = whole.start(); + if braces != 1 { + continue; + } + let init = from + caps.get(1).map_or(whole.end(), |eq| eq.end()); + let at = self.site_at(site, &decl.file_path, decl.start_line); + return self.initializer_type(&code.text, init, &at, depth + 1); + } + } + } + None + } +} + +/// Whether a Dart member is a setter, declared `set (…)`, which no call +/// or read runs (upstream `isDartSetter`). +pub(crate) fn is_setter(node: &Node, context: &dyn ResolutionContext) -> bool { + if node.language != Language::Dart || node.kind != NodeKind::Method { + return false; + } + let tables = context.dart_tables(); + if let Some(hit) = tables + .setters + .lock() + .ok() + .and_then(|memo| memo.get(&node.id).copied()) + { + return hit; + } + let (head, _) = crate::dart_scope::dart_member_head(node, context); + // The pattern writes `set` and the name; a line writing neither compiles + // nothing. + let head = head.as_str(); + let setter = head.contains("set") + && head.contains(node.name.as_str()) + && Regex::new(&format!( + r"^[^={{;]*?(?-u:\b)set\s+{}\s*\(", + regex::escape(&node.name) + )) + .is_ok_and(|setter| setter.is_match(head)); + if let Ok(mut memo) = tables.setters.lock() { + memo.insert(node.id.clone(), setter); + } + setter +} + +/// How the extractor records a call written `.(…)`, read +/// from the text before the dot: through a plain name (`x.name`), through a +/// member chain's last segment (`s.name`, #1496), or by the name alone (a +/// call, a parenthesis, an index, `!`, `this` or `super`). +#[derive(Debug, PartialEq, Eq)] +enum Encoding<'t> { + Ident(&'t str), + Segment(&'t str), + Bare, +} + +fn encoding_before<'t>(text: &'t str, end: isize) -> Encoding<'t> { + let bytes = text.as_bytes(); + let at = |i: isize| -> u8 { + usize::try_from(i) + .ok() + .and_then(|i| bytes.get(i).copied()) + .unwrap_or(0) + }; + if !is_word_byte(at(end)) { + return Encoding::Bare; + } + let mut start = end; + while start > 0 && is_word_byte(at(start - 1)) { + start -= 1; + } + let word = &text[start as usize..=end as usize]; + if matches!(word, "this" | "super") { + return Encoding::Bare; + } + let mut before = start - 1; + while before >= 0 && is_space(at(before)) { + before -= 1; + } + if at(before) == b'.' && at(before - 1) != b'.' { + return Encoding::Segment(word); + } + Encoding::Ident(word) +} + +/// Whether a call follows offset `from`: `(`, or type arguments and `(`. +fn call_follows(text: &[u8], from: usize) -> bool { + let mut k = from; + while k < text.len() && is_space(text[k]) { + k += 1; + } + if text.get(k) == Some(&b'<') { + let Some(close) = type_arguments_end(text, k) else { + return false; + }; + k = close + 1; + while k < text.len() && is_space(text[k]) { + k += 1; + } + } + text.get(k) == Some(&b'(') +} + +/// The parts of a chain link's reference: the segment the extractor kept +/// before the member, if any, and the member. +fn link_parts(name: &str) -> Option<(Option<&str>, &str)> { + if is_dart_identifier(name) { + return Some((None, name)); + } + let (segment, member) = name.split_once('.')?; + (is_dart_identifier(segment) && is_dart_identifier(member)).then_some((Some(segment), member)) +} + +/// A call written `.(…)` (or `?.`) that the extractor +/// records by its member alone (after a call, a parenthesis, an index, `!`, +/// `this` or `super`) or past the receiver's last segment (`s.m`, #1496), +/// with the chain read back from its receiver. +#[derive(Debug)] +struct LinkAt { + /// Where the member starts and ends. + at: usize, + end: usize, + /// The segment the extractor records before the member, if any. + segment: Option, + chain: Chain, +} + +/// Every call of `text` [`LinkAt`] reads, by where its chain starts as the +/// extractor places its references ([`Chain::start`]); a cascade's `..m` and +/// a spread's `...m` are none, and neither is a call through a plain name +/// (`x.m`). +fn link_index(text: &str) -> HashMap> { + let bytes = text.as_bytes(); + let at = |i: isize| -> u8 { + usize::try_from(i) + .ok() + .and_then(|i| bytes.get(i).copied()) + .unwrap_or(0) + }; + let mut out: HashMap> = HashMap::new(); + let mut k = 0usize; + while k < bytes.len() { + if !is_word_byte(bytes[k]) { + k += 1; + continue; + } + let mut end = k; + while end < bytes.len() && is_word_byte(bytes[end]) { + end += 1; + } + let (start, member_end) = (k, end); + k = end; + if !is_dart_identifier(&text[start..member_end]) { + continue; + } + let mut dot = start as isize - 1; + while dot >= 0 && is_space(at(dot)) { + dot -= 1; + } + if at(dot) != b'.' || at(dot - 1) == b'.' { + continue; + } + let mut receiver_end = dot - 1; + if at(receiver_end) == b'?' { + receiver_end -= 1; + } + while receiver_end >= 0 && is_space(at(receiver_end)) { + receiver_end -= 1; + } + let segment = match encoding_before(text, receiver_end) { + Encoding::Bare => None, + Encoding::Segment(word) => Some(word.to_string()), + Encoding::Ident(_) => continue, + }; + if receiver_end < 0 || !call_follows(bytes, member_end) { + continue; + } + let Some(chain) = read_chain(text, receiver_end as usize) else { + continue; + }; + if let Some(chain_start) = chain.start() { + out.entry(chain_start).or_default().push(LinkAt { + at: start, + end: member_end, + segment, + chain, + }); + } + } + out +} + +/// The links a chain link's reference stands for (KEEP-RUST): the calls of +/// its member written after the reference's position, on its line or the +/// lines below, that the extractor records as this reference, each with the +/// chain read back from it, which starts at the reference's position. A +/// bare member is one written after a call, a parenthesis, an index, `!`, +/// `this` or `super`; `s.m` one written after a member `s` of something. +fn link_sites(code: &DartCode, line: i64, column: i64, name: &str) -> Vec<(usize, Chain)> { + let Some((segment, member)) = link_parts(name) else { + return Vec::new(); + }; + let Some(position) = code.offset(line, column) else { + return Vec::new(); + }; + let limit = code + .line_start(line + CHAIN_LINES + 1) + .unwrap_or(code.text.len()); + code.links_at(position) + .iter() + .filter(|link| { + link.end <= limit + && &code.text[link.at..link.end] == member + && link.segment.as_deref() == segment + }) + .map(|link| (link.at, link.chain.clone())) + .collect() +} + +/// A later link of a Dart call chain (upstream #2376 `isDartChainLink`, and +/// `matchDartChainLink`): `Some` with what it links, or nothing, when the +/// reference is one; `None` when it is not. A bare `calls` reference is one +/// unless it is written without a receiver; KEEP-RUST: `s.m` is one when `s` +/// is itself written after a `.`. +pub fn dart_chain_link( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + if reference.language != Language::Dart + || reference.reference_kind != EdgeKind::Calls + || reference.is_function_ref + { + return None; + } + let (_, member) = link_parts(&reference.reference_name)?; + let code = context.source_facts(&reference.file_path)?.dart_code(); + let sites = chain_link_sites( + &code, + &reference.reference_name, + reference.line, + reference.column, + )?; + Some(match_link(reference, context, member, &sites)) +} + +/// What a chain link links: the member of what each of its sites' chains +/// evaluates to, when they all agree. +fn match_link( + reference: &RefView, + context: &dyn ResolutionContext, + member: &str, + sites: &[(usize, Chain)], +) -> Option { + if !context + .get_nodes_by_name_shared(member) + .iter() + .any(|node| node.language == Language::Dart) + { + return None; + } + let typing = Typing { + context, + name: member, + }; + let mut chosen: Option> = None; + for (at, chain) in sites { + let site = Site { + view: reference.clone(), + at: Some(*at), + }; + let target = typing.type_chain(chain, &site, 0).and_then(|receiver| { + if receiver.is_static { + // Static members are the type's own: `p.Provider.autoDispose(…)` + // calls the constant, as `Provider.autoDispose(…)` does. + let found = typing.static_member( + &receiver.type_name, + member, + &site, + receiver.owner.as_deref(), + )?; + (found.kind != NodeKind::EnumMember) + .then(|| (found.id.clone(), ResolvedBy::QualifiedName)) + } else { + let (found, _) = dart_member_of( + &receiver.type_name, + member, + reference, + context, + &|node| !is_setter(node, context), + u64::from(receiver.via_super), + )?; + Some((found.id.clone(), ResolvedBy::InstanceMethod)) + } + }); + match &chosen { + None => chosen = Some(target), + Some(earlier) if *earlier == target => {} + Some(_) => return None, + } + } + let (target_node_id, resolved_by) = chosen.flatten()?; + Some(ResolvedRef { + original: reference.clone(), + target_node_id, + confidence: 0.85, + resolved_by, + }) +} + +/// Whether a constructor call written at `column` of 1-based `line` names +/// `name` through an import prefix: `new p.Foo()`, `const p.Foo()`, which +/// the extractor records as `Foo` where its `new` or `const` is. +fn constructed_through_prefix(code: &DartCode, name: &str, line: i64, column: usize) -> bool { + let Some(text) = code.line_text(line) else { + return false; + }; + let rest = text.get(column..).unwrap_or(""); + let Some(rest) = rest + .strip_prefix("const") + .or_else(|| rest.strip_prefix("new")) + .filter(|after| after.starts_with(|c: char| c.is_ascii_whitespace())) + else { + return false; + }; + let rest = rest.trim_start(); + let prefix_len = rest.bytes().take_while(|byte| is_word_byte(*byte)).count(); + if prefix_len == 0 { + return false; + } + let after = rest[prefix_len..].trim_start(); + let Some(after) = after.strip_prefix('.') else { + return false; + }; + let after = after.trim_start(); + after.starts_with(name) + && !after + .as_bytes() + .get(name.len()) + .is_some_and(|byte| is_word_byte(*byte)) +} + +/// How a bare Dart call reference reads where the extractor placed it. +enum BareCall { + /// Written without a receiver. + ReceiverLess, + /// A constructor called through an import prefix (`const p.Foo()`, + /// placed at its `const`): it has a receiver, and is no chain link. + ThroughPrefix, + /// A later link of a chain, at the sites the extractor recorded it at + /// (none when the chain cannot be read). + Link(Vec<(usize, Chain)>), +} + +/// How a bare Dart call reference reads (upstream `isReceiverLessDartCall`): +/// written without a receiver when its name is where it is placed and no `.` +/// is written before it there (a spread's `...items()` is none). KEEP-RUST: +/// the extractor places a chain link's reference where the chain starts, so +/// one placed elsewhere has a receiver when it is a link the extractor +/// recorded there ([`link_sites`]), or a constructor called through an +/// import prefix; else when the first whole word of its name after that +/// place, on its line or the first line below holding one, is written after +/// a `.`. +fn bare_call(code: &DartCode, name: &str, line: i64, column: i64) -> BareCall { + let Some(text) = code.line_text(line) else { + return BareCall::ReceiverLess; + }; + let position = usize::try_from(column.max(0)).unwrap_or(0); + let bytes = text.as_bytes(); + let word_at = |at: usize| { + text.get(at..).is_some_and(|rest| rest.starts_with(name)) + && !(at > 0 && is_word_byte(bytes[at - 1])) + && !bytes.get(at + name.len()).is_some_and(|b| is_word_byte(*b)) + }; + let without_receiver = |text: &str, start: usize| { + let before = text[..start].trim_end_matches(crate::dart_scope::is_js_space); + if !before.ends_with('.') { + return true; + } + before + .strip_suffix("...") + .is_some_and(|rest| !rest.ends_with('.')) + }; + // Upstream places a reference on its name or just past it. + let at_name = if word_at(position) { + Some(position) + } else if position >= name.len() && word_at(position - name.len()) { + Some(position - name.len()) + } else { + None + }; + if let Some(at) = at_name { + return if without_receiver(text, at) { + BareCall::ReceiverLess + } else { + BareCall::Link(link_sites(code, line, column, name)) + }; + } + if constructed_through_prefix(code, name, line, position) { + return BareCall::ThroughPrefix; + } + let sites = link_sites(code, line, column, name); + if !sites.is_empty() { + return BareCall::Link(sites); + } + for offset in 0..=CHAIN_LINES { + let Some(current) = code.line_text(line + offset) else { + break; + }; + let bytes = current.as_bytes(); + let from = if offset == 0 { position } else { 0 }; + let first = current.match_indices(name).map(|(at, _)| at).find(|at| { + *at >= from + && !(*at > 0 && is_word_byte(bytes[*at - 1])) + && !bytes + .get(*at + name.len()) + .is_some_and(|b| is_word_byte(*b)) + }); + if let Some(at) = first { + return if without_receiver(current, at) { + BareCall::ReceiverLess + } else { + BareCall::Link(Vec::new()) + }; + } + } + BareCall::ReceiverLess +} + +/// Whether a bare Dart call reference is written without a receiver +/// ([`bare_call`]). +pub(crate) fn receiver_less_at(code: &DartCode, name: &str, line: i64, column: i64) -> bool { + matches!(bare_call(code, name, line, column), BareCall::ReceiverLess) +} + +/// Whether `segment.member` is written where the reference is placed: a call +/// through a plain name, which the extractor records as `segment.member` +/// where the name is. +fn written_through_name( + code: &DartCode, + line: i64, + column: i64, + segment: &str, + member: &str, +) -> bool { + let Some(text) = code.line_text(line) else { + return false; + }; + let at = usize::try_from(column.max(0)).unwrap_or(0); + let Some(rest) = text.get(at..).and_then(|rest| rest.strip_prefix(segment)) else { + return false; + }; + if at > 0 && is_word_byte(text.as_bytes()[at - 1]) { + return false; + } + let rest = rest.trim_start(); + let rest = rest.strip_prefix('?').unwrap_or(rest); + let Some(rest) = rest.strip_prefix('.') else { + return false; + }; + let rest = rest.trim_start(); + rest.starts_with(member) + && !rest + .as_bytes() + .get(member.len()) + .is_some_and(|byte| is_word_byte(*byte)) +} + +/// The sites of a Dart call reference that is a later link of a chain, or +/// `None` when it is none (KEEP-RUST: read where the extractor placed it). +fn chain_link_sites( + code: &DartCode, + name: &str, + line: i64, + column: i64, +) -> Option> { + match link_parts(name)? { + (None, _) => match bare_call(code, name, line, column) { + BareCall::Link(sites) => Some(sites), + BareCall::ReceiverLess | BareCall::ThroughPrefix => None, + }, + (Some(segment), member) => { + if written_through_name(code, line, column, segment, member) { + return None; + } + let sites = link_sites(code, line, column, name); + (!sites.is_empty()).then_some(sites) + } + } +} + +/// Whether a Dart call reference of the file whose code is `code` is a later +/// link of a chain, read from the text alone (a sync selecting the references +/// a changed declaration may move), as [`dart_chain_link`] tells one. +pub fn is_chain_link_in(code: &DartCode, name: &str, line: i64, column: i64) -> bool { + chain_link_sites(code, name, line, column).is_some() +} + +/// The offset of the `}` closing the `{` at `open`, or `to` when none does. +fn matching_brace(bytes: &[u8], open: usize, to: usize) -> usize { + let mut depth = 0usize; + let mut k = open; + while k < to { + match bytes[k] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return k; + } + } + _ => {} + } + k += 1; + } + to +} + +/// The offset of the `)` closing the `(` at `open`, or `to` when none does. +fn matching_paren(bytes: &[u8], open: usize, to: usize) -> usize { + let mut depth = 0usize; + let mut k = open; + while k < to { + match bytes[k] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + return k; + } + } + _ => {} + } + k += 1; + } + to +} + +/// The declarations written between `from` and `to` of a Dart file's code +/// (W4-DART-03b): a class body's members, or the file's top level, each with +/// its whitespace collapsed and what no chain's typing reads of it dropped: a +/// block (`{…}` of a method, a closure, a nested class, a map) shows as `{}`, +/// an argument or parameter list as `()`, a function's arrow body as `=> ;`. +/// What a typing reads is left: a field's or constant's type and the chain +/// its initializer ends with, a method's head, a getter's or setter's +/// keyword, a type's head. +pub fn dart_statements(text: &str, from: usize, to: usize) -> Vec { + let bytes = text.as_bytes(); + let to = to.min(bytes.len()); + let mut out = Vec::new(); + let mut current = String::new(); + let flush = |current: &mut String, out: &mut Vec| { + let statement = current.split_whitespace().collect::>().join(" "); + if !statement.is_empty() { + out.push(statement); + } + current.clear(); + }; + let mut parens = 0usize; + // Whether the declaration assigns, so an `=>` after it is a closure's. + let mut assigned = false; + let mut k = from; + while k < to { + let c = bytes[k]; + let prev = current.trim_end().bytes().next_back(); + match c { + b'{' => { + let close = matching_brace(bytes, k, to); + current.push_str("{}"); + k = close + 1; + if parens == 0 { + // A body ends the declaration; what follows a literal or a + // closure (`;`, `,`, `.`, `)`) goes on with it. + let next = bytes[k.min(to)..to] + .iter() + .copied() + .find(|byte| !is_space(*byte)); + if !matches!( + next, + Some(b';' | b',' | b'.' | b'?' | b')' | b']' | b'[' | b'(') + ) { + flush(&mut current, &mut out); + assigned = false; + } + } + continue; + } + // An argument or parameter list: `name(…)`, `name(…)`. + b'(' if prev.is_some_and(|prev| is_word_byte(prev) || prev == b'>') => { + current.push_str("()"); + k = matching_paren(bytes, k, to) + 1; + continue; + } + b'=' if parens == 0 && bytes.get(k + 1) == Some(&b'>') && !assigned => { + // A function's body: up to the `;` that ends it. + let mut depth = 0i64; + let mut j = k + 2; + while j < to { + match bytes[j] { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b';' if depth <= 0 => break, + _ => {} + } + j += 1; + } + current.push_str("=> ;"); + flush(&mut current, &mut out); + assigned = false; + k = j + 1; + continue; + } + b'=' if parens == 0 => { + let before = k.checked_sub(1).map(|p| bytes[p]); + let next = bytes.get(k + 1).copied(); + if !matches!(before, Some(b'=' | b'!' | b'<' | b'>')) + && !matches!(next, Some(b'=' | b'>')) + { + assigned = true; + } + } + b'(' | b'[' => parens += 1, + b')' | b']' => parens = parens.saturating_sub(1), + b';' if parens == 0 => { + current.push(';'); + flush(&mut current, &mut out); + assigned = false; + k += 1; + continue; + } + _ => {} + } + let len = char_len(bytes, k).min(to - k); + current.push_str(text.get(k..k + len).unwrap_or(" ")); + k += len; + } + flush(&mut current, &mut out); + out +} + +/// Dart's reserved words: no declaration is named by one, so no typing looks +/// one up. +const RESERVED_WORDS: &[&str] = &[ + "assert", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", + "enum", "extends", "false", "final", "finally", "for", "if", "in", "is", "new", "null", + "rethrow", "return", "super", "switch", "this", "throw", "true", "try", "var", "void", "while", + "with", +]; + +/// Whether `word` (a [`dart_words`] word) can name a declaration: no reserved +/// word or number does. +pub fn can_name_a_declaration(word: &str) -> bool { + !word.starts_with(|c: char| c.is_ascii_digit()) && !RESERVED_WORDS.contains(&word) +} + +/// Words that open a Dart declaration without naming it. +const MODIFIERS: &[&str] = &[ + "abstract", + "base", + "final", + "sealed", + "interface", + "external", + "static", + "const", + "late", + "var", + "covariant", + "factory", + "augment", +]; + +/// Where a Dart declaration statement's annotations end: `@override`, +/// `@Riverpod(keepAlive: true)`. +fn annotations_end(statement: &str) -> usize { + let bytes = statement.as_bytes(); + let mut k = 0usize; + loop { + while k < bytes.len() && is_space(bytes[k]) { + k += 1; + } + if bytes.get(k) != Some(&b'@') { + break; + } + k += 1; + while k < bytes.len() && (is_word_byte(bytes[k]) || bytes[k] == b'.') { + k += 1; + } + while k < bytes.len() && is_space(bytes[k]) { + k += 1; + } + if bytes.get(k) == Some(&b'(') { + let mut depth = 0usize; + while k < bytes.len() { + match bytes[k] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + k += 1; + break; + } + } + _ => {} + } + k += 1; + } + } + } + k +} + +/// The words of a Dart declaration statement ([`dart_statements`]) that say +/// the type of `name`, which it declares, as a chain's typing reads them, and +/// whether no type is written for it (W4-DART-03b): a type's whole head; +/// nothing of a constructor of `class` (it makes a `class`); else the words +/// written before the name (a field's or variable's type, a method's or +/// getter's return type) or, with no type among them, the initializer's. +/// The whole statement when the name is not found before its parameters, +/// its initializer or its body. +pub fn dart_type_words<'s>( + statement: &'s str, + name: &str, + class: Option<&str>, +) -> (Vec<&'s str>, bool) { + let rest = &statement[annotations_end(statement).min(statement.len())..]; + let bytes = rest.as_bytes(); + let mut spans: Vec<(usize, &str)> = Vec::new(); + let mut k = 0usize; + while k < bytes.len() { + if !is_word_byte(bytes[k]) { + k += 1; + continue; + } + let start = k; + while k < bytes.len() && is_word_byte(bytes[k]) { + k += 1; + } + spans.push((start, &rest[start..k])); + } + let all = || spans.iter().map(|(_, word)| *word).collect::>(); + if let Some((at, lead)) = spans.iter().find(|(_, word)| !MODIFIERS.contains(word)) { + if matches!(*lead, "class" | "mixin" | "enum" | "extension" | "typedef") { + return (all(), false); + } + if class == Some(*lead) && rest[at + lead.len()..].trim_start().starts_with(['(', '.']) { + return (Vec::new(), false); + } + } + // Where the parameters, the initializer or the body start. + let end = bytes + .iter() + .position(|byte| matches!(byte, b'(' | b'=' | b';' | b'{')) + .unwrap_or(bytes.len()); + let Some(&(name_at, _)) = spans.iter().rfind(|(at, word)| *at < end && *word == name) else { + return (all(), true); + }; + let written: Vec<&str> = spans + .iter() + .filter(|(at, word)| { + *at < name_at && !MODIFIERS.contains(word) && !NON_TYPE_WORDS.contains(word) + }) + .map(|(_, word)| *word) + .collect(); + if !written.is_empty() { + return (written, false); + } + // No type written: the initializer's, past its `=`. + let mut depth = 0i64; + for (at, byte) in bytes.iter().enumerate().skip(name_at) { + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b'=' if depth == 0 => { + let prev = at.checked_sub(1).map(|p| bytes[p]); + let next = bytes.get(at + 1).copied(); + if !matches!(prev, Some(b'=' | b'!' | b'<' | b'>')) + && !matches!(next, Some(b'=' | b'>')) + { + return (dart_words(&rest[at + 1..]).collect(), true); + } + } + _ => {} + } + } + (Vec::new(), true) +} + +/// The names a Dart declaration statement ([`dart_statements`]) declares: a +/// type's, a function's, method's, getter's, setter's or named +/// constructor's, or each variable's of a field or constant declaration. +pub fn dart_declared_names(statement: &str) -> Vec { + let k = annotations_end(statement); + let rest = &statement[k.min(statement.len())..]; + let words: Vec<&str> = rest + .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')) + .filter(|word| !word.is_empty()) + .collect(); + let mut lead = words.iter().skip_while(|word| MODIFIERS.contains(word)); + if let Some(first) = lead.next() { + match *first { + "class" | "mixin" | "enum" | "typedef" => { + return lead + .find(|word| !matches!(**word, "class")) + .map(|word| vec![(*word).to_string()]) + .unwrap_or_default(); + } + "extension" => { + return lead + .find(|word| !matches!(**word, "type")) + .filter(|word| **word != "on") + .map(|word| vec![(*word).to_string()]) + .unwrap_or_default(); + } + "import" | "export" | "part" | "library" => return Vec::new(), + _ => {} + } + } + let rest_bytes = rest.as_bytes(); + // Where the first `(` and the first assignment `=` sit outside any + // bracket; a function type's (`void Function() onDone;`) opens no + // parameters of the declaration. + let mut depth = 0i64; + let mut paren: Option = None; + let mut assign: Option = None; + for (at, byte) in rest_bytes.iter().enumerate() { + match byte { + b'(' | b'[' | b'{' => { + if depth == 0 + && *byte == b'(' + && paren.is_none() + && !rest[..at].trim_end().ends_with("Function") + { + paren = Some(at); + } + depth += 1; + } + b')' | b']' | b'}' => depth -= 1, + b'=' if depth == 0 && assign.is_none() => { + let prev = at.checked_sub(1).map(|p| rest_bytes[p]); + let next = rest_bytes.get(at + 1).copied(); + if !matches!(prev, Some(b'=' | b'!' | b'<' | b'>')) + && !matches!(next, Some(b'=' | b'>')) + { + assign = Some(at); + } + } + _ => {} + } + } + let word_after = |keyword: &str| -> Option { + let at = words.iter().position(|word| *word == keyword)?; + words.get(at + 1).map(|word| (*word).to_string()) + }; + if assign.is_none_or(|assign| paren.is_some_and(|paren| paren < assign)) { + if let Some(name) = word_after("get").or_else(|| word_after("set")) { + return vec![name]; + } + if let Some(paren) = paren { + if words.contains(&"operator") { + return Vec::new(); + } + // The name before the parameters, past its type parameters. + let mut end = paren; + while end > 0 && is_space(rest_bytes[end - 1]) { + end -= 1; + } + if end > 0 + && rest_bytes[end - 1] == b'>' + && let Some(open) = opening(rest_bytes, end - 1, b'<', b'>', 400) + { + end = open; + while end > 0 && is_space(rest_bytes[end - 1]) { + end -= 1; + } + } + let mut start = end; + while start > 0 && is_word_byte(rest_bytes[start - 1]) { + start -= 1; + } + let name = &rest[start..end]; + return if is_dart_identifier(name) { + vec![name.to_string()] + } else { + Vec::new() + }; + } + } + // Variables: each declarator's name, before its `=` or its end. + let body = rest.trim_end().trim_end_matches(';'); + let body_bytes = body.as_bytes(); + let mut names = Vec::new(); + let mut depth = 0i64; + let mut angle = 0i64; + let mut initializing = false; + let mut last_word: Option<(usize, usize)> = None; + let push = |last_word: Option<(usize, usize)>, names: &mut Vec| { + if let Some((start, end)) = last_word + && let Some(name) = body.get(start..end) + && is_dart_identifier(name) + && !MODIFIERS.contains(&name) + { + names.push(name.to_string()); + } + }; + let mut k = 0usize; + while k < body_bytes.len() { + let byte = body_bytes[k]; + // An initializer's type arguments (`Provider()`, `{}`) + // hold commas no declarator ends at. + if initializing + && byte == b'<' + && let Some(close) = literal_type_arguments_end(body_bytes, k) + { + k = close + 1; + continue; + } + if is_word_byte(byte) && !initializing && depth == 0 && angle == 0 { + let start = k; + while k < body_bytes.len() && is_word_byte(body_bytes[k]) { + k += 1; + } + last_word = Some((start, k)); + continue; + } + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b'<' if !initializing && depth == 0 => angle += 1, + b'>' if !initializing && depth == 0 && angle > 0 => angle -= 1, + b'=' if depth == 0 && angle == 0 && !initializing => { + push(last_word.take(), &mut names); + initializing = true; + } + b',' if depth == 0 && angle == 0 => { + if !initializing { + push(last_word.take(), &mut names); + } + initializing = false; + last_word = None; + } + _ => {} + } + k += 1; + } + if !initializing { + push(last_word, &mut names); + } + names +} + +/// The `>` closing type arguments opened at `open` in an expression, one a +/// call, a member, a literal (`{}`, `[]`) or another `>` follows. +fn literal_type_arguments_end(text: &[u8], open: usize) -> Option { + let mut depth = 0i64; + let mut k = open; + while k < text.len() && k - open < 400 { + match text[k] { + b'<' => depth += 1, + b'>' => { + depth -= 1; + if depth == 0 { + let first = text[k + 1..].iter().copied().find(|byte| !is_space(*byte)); + return matches!(first, Some(b'(' | b'.' | b'>' | b'?' | b'{' | b'[')) + .then_some(k); + } + } + b';' | b'{' | b'}' | b'=' => return None, + _ => {} + } + k += 1; + } + None +} + +/// The words of a Dart text (`[A-Za-z0-9_$]` runs), in order. +pub fn dart_words(text: &str) -> impl Iterator { + text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')) + .filter(|word| !word.is_empty()) +} + +/// The statements of the body of the Dart type declared by `node` in the file +/// whose code is `code` ([`dart_statements`]), none for one without a body. +pub fn dart_class_statements(code: &DartCode, node: &Node) -> Vec { + let Some(from) = code.line_start(node.start_line) else { + return Vec::new(); + }; + let to = code + .line_start(node.end_line + 1) + .unwrap_or(code.text.len()); + let bytes = code.text.as_bytes(); + let mut parens = 0usize; + let mut k = from + usize::try_from(node.start_column.max(0)).unwrap_or(0); + while k < to { + match bytes[k] { + b'(' | b'[' => parens += 1, + b')' | b']' => parens = parens.saturating_sub(1), + b'{' if parens == 0 => { + let close = matching_brace(bytes, k, to); + return dart_statements(&code.text, k + 1, close); + } + b';' if parens == 0 => return Vec::new(), + _ => {} + } + k += 1; + } + Vec::new() +} + +/// The names a chain link calling `member` at `line` / `column` is recorded +/// as where a chain starting there links one of its calls ([`link_sites`]): +/// bare, or past the segment written before it. +pub fn chain_link_names(code: &DartCode, member: &str, line: i64, column: i64) -> Vec { + let mut names: Vec = Vec::new(); + let Some(position) = code.offset(line, column) else { + return names; + }; + let limit = code + .line_start(line + CHAIN_LINES + 1) + .unwrap_or(code.text.len()); + for link in code.links_at(position) { + if link.end > limit || code.text[link.at..link.end] != *member { + continue; + } + let name = match &link.segment { + Some(segment) => format!("{segment}.{member}"), + None => member.to_string(), + }; + if !names.contains(&name) { + names.push(name); + } + } + names +} + +/// What the typing of a later link of a Dart call chain reads by name +/// (W4-DART-03b): what it looks a declaration up by, and what it asks only +/// whether a type is named ([`chain_link_reads`]). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ChainReads { + /// The names a typing looks a declaration up by: the member each link + /// calls or reads, a type argument, a function or constructor the head + /// calls, a name it reads that no local or parameter declares (a field, + /// a getter, a constant, a type), a cast's type, a local's declared type, + /// the type the chain is written in, and so on through what a local's + /// initializer reads. + pub names: BTreeSet, + /// The locals and parameters a chain's head names: the typing asks only + /// whether a type of the name exists, which would take their place. + pub types: BTreeSet, +} + +/// What the typing of a Dart call recorded as `name` at `line` / `column` of +/// the file whose code is `code` and whose nodes are `nodes` reads by name, +/// when it is a later link of a chain (`None` otherwise, or when no chain is +/// read where the extractor recorded it, which links nothing whatever +/// changes). Read as [`Typing`] reads it: the chain from its head to each +/// site of the link, the type it is written in (what `this`, `super` and a +/// receiver-less head are typed by), and for a name the code before the +/// chain declares as a local or parameter ([`local_declaration`], in the +/// function around it, else the file) that declaration's type or its +/// initializer's reading in turn, as deep as the typing reads; another +/// file's declarations only by the names read, the `call` a called value +/// runs included. +pub fn chain_link_reads( + code: &DartCode, + nodes: &[Node], + name: &str, + line: i64, + column: i64, +) -> Option { + let (_, member) = link_parts(name)?; + let sites = chain_link_sites(code, name, line, column)?; + if sites.is_empty() { + return None; + } + let scope_line = crate::name_matcher::scope_start_line_in(nodes.iter(), Language::Dart, line); + let start = code.line_start(scope_line).unwrap_or(0); + let mut reads = ChainReads::default(); + reads.names.insert(member.to_string()); + reads.names.extend( + nodes + .iter() + .filter(|node| { + node.language == Language::Dart + && is_dart_type_kind(node.kind) + && node.start_line <= line + && node.end_line.max(node.start_line) >= line + }) + .map(|node| node.name.clone()), + ); + for (at, chain) in &sites { + let mut reader = ChainReader { + code, + start, + at: *at, + reads: &mut reads, + visited: HashSet::new(), + }; + reader.chain(chain, 0); + } + Some(reads) +} + +/// [`chain_link_reads`] at one site of a link: its scope starts at `start`, +/// and a local is declared before `at`. +struct ChainReader<'r> { + code: &'r DartCode, + start: usize, + at: usize, + reads: &'r mut ChainReads, + visited: HashSet<(String, u32)>, +} + +impl ChainReader<'_> { + /// [`Typing::type_chain`]'s reading, one level deeper than it reads. + fn chain(&mut self, chain: &Chain, depth: u32) { + if depth > MAX_DEPTH + 1 { + return; + } + for link in &chain.links { + if !link.name.is_empty() { + self.reads.names.insert(link.name.clone()); + } + self.type_arguments(&link.type_args); + } + match &chain.head { + Head::String => {} + Head::Paren { open, close } => self.paren(*open, *close, depth), + Head::Name { + name, + type_args, + call, + keyword, + .. + } => { + self.type_arguments(type_args); + if name == "this" || name == "super" || keyword == "is" { + return; + } + if matches!(keyword.as_str(), "const" | "new" | "as") { + self.reads.names.insert(name.clone()); + return; + } + // An import prefix names a library and the head is the link + // after it, read as a head too. + if !*call + && type_args.is_empty() + && let Some(first) = chain.links.first() + && !first.index + { + self.variable(&first.name, depth); + } + if *call { + // A function or type by name, else a variable's `call`. + self.reads.names.insert(name.clone()); + self.variable(name, depth); + } else if name.starts_with(|c: char| c.is_ascii_uppercase()) { + // A type, or a constant written in capitals. + self.reads.names.insert(name.clone()); + self.variable(name, depth); + } else { + self.reads.types.insert(name.clone()); + self.variable(name, depth); + } + } + } + } + + fn type_arguments(&mut self, type_args: &[String]) { + for arg in type_args { + self.reads.names.extend(dart_words(arg).map(str::to_string)); + } + } + + /// [`Typing::variable_type`]'s reading of `name`: a local or parameter + /// the scope declares, else a field, getter or constant by name. + fn variable(&mut self, name: &str, depth: u32) { + if !self.visited.insert((name.to_string(), depth)) { + return; + } + let uses = self.code.name_uses(name); + match local_declaration(self.code, &uses, self.start, self.at, name) { + None => { + self.reads.names.insert(name.to_string()); + } + Some(LocalDeclaration::Typed(typed)) => { + self.reads.names.insert(typed); + } + Some(LocalDeclaration::Untyped) => {} + Some(LocalDeclaration::Initializer(from)) => { + if let Some(end) = initializer_end(&self.code.text, from) { + self.expression(end, depth); + } + } + } + } + + /// [`Typing::expression_value`]'s reading of the expression ending at + /// `end`. + fn expression(&mut self, end: usize, depth: u32) { + let Some(chain) = read_chain(&self.code.text, end) else { + return; + }; + if matches!(&chain.head, Head::Name { keyword, .. } if keyword == "await") { + return; + } + self.chain(&chain, depth + 1); + } + + /// [`Typing::type_paren`]'s reading: a cast's type, else the last + /// operand. + fn paren(&mut self, open: usize, close: usize, depth: u32) { + let Some(inner) = self.code.text.get(open + 1..close) else { + return; + }; + if let Some(cast) = dart_cast(inner) { + self.reads + .names + .extend(dart_words(cast).map(str::to_string)); + return; + } + let bytes = self.code.text.as_bytes(); + let Some(mut end) = close.checked_sub(1) else { + return; + }; + while end > open && is_space(bytes[end]) { + end -= 1; + } + self.expression(end, depth); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blanking_keeps_offsets_quotes_and_interpolated_code() { + let source = + "var a = 'x.y()'; // c.d()\nvar b = \"${c.e()}\"; /* f.g() */ r'\\n';\n'''é\n''';"; + let blanked = blank_dart_code(source); + assert_eq!(blanked.len(), source.len()); + assert_eq!( + blanked, + "var a = ' '; \nvar b = \" c.e() \"; r' ';\n''' \n''';" + ); + } + + #[test] + fn a_name_use_is_a_whole_word_not_a_member() { + let text = "x = foo; a.foo; foo2; _foo; foo("; + assert_eq!(name_uses(text, "foo"), vec![4, 28]); + } + + fn chain_of(text: &str) -> Chain { + read_chain(text, text.len() - 1).expect("a chain") + } + + #[test] + fn a_chain_reads_back_its_links_and_head() { + let chain = chain_of("return events\n .map(mapper)\n .transform(x)"); + assert_eq!( + chain + .links + .iter() + .map(|l| l.name.as_str()) + .collect::>(), + ["map", "transform"] + ); + assert!(chain.links[1].call); + assert_eq!(chain.links[1].type_args, ["dynamic"]); + assert!( + matches!(&chain.head, Head::Name { name, keyword, .. } if name == "events" && keyword == "return") + ); + let chain = chain_of(" final c = const SignUpState().withEmail('a')"); + assert!( + matches!(&chain.head, Head::Name { name, keyword, call: true, keyword_start, .. } if name == "SignUpState" && keyword == "const" && *keyword_start == 12) + ); + assert_eq!(chain.start(), Some(12)); + let chain = chain_of("(state.initialTodo ?? const Todo()).copyWith(title: 'x')"); + assert!(matches!(chain.head, Head::Paren { open: 0, .. })); + assert_eq!(chain.links.len(), 1); + let chain = chain_of("x!.a?.b[0].c"); + assert_eq!(chain.links.len(), 4); + assert!(chain.links[2].index); + assert!(read_chain("obj..a().b", 9).is_none()); + } + + #[test] + fn a_simple_type_name_drops_prefix_arguments_and_nullability() { + assert_eq!(simple_type_name("p.Foo?").as_deref(), Some("Foo")); + assert_eq!(simple_type_name(" Todo? ").as_deref(), Some("Todo")); + assert_eq!(simple_type_name("Function(int)"), None); + assert_eq!(simple_type_name("Map").as_deref(), Some("Map")); + } + + #[test] + fn a_body_is_cut_into_its_declarations() { + let text = " final Todo? initialTodo;\n @override\n void add(E e) {\n go();\n }\n static const auto = Builder();\n final p = Provider((ref) {\n return 1;\n }).select(1);\n int get x => 1;\n final m = {1: 2};\n Repo make() => Repo(a, (b) => c);\n final f = (x) => x.y;\n final c = (a ?? b).copyWith(t: 1);\n"; + assert_eq!( + dart_statements(text, 0, text.len()), + [ + "final Todo? initialTodo;", + "@override void add() {}", + "static const auto = Builder();", + "final p = Provider().select();", + "int get x => ;", + "final m = {};", + "Repo make() => ;", + "final f = (x) => x.y;", + "final c = (a ?? b).copyWith();", + ] + ); + } + + #[test] + fn a_declaration_names_what_it_declares() { + let names = |statement: &str| dart_declared_names(statement); + assert_eq!(names("final Todo? initialTodo;"), ["initialTodo"]); + assert_eq!(names("@override void add(E e) {}"), ["add"]); + assert_eq!(names("static const auto = Builder();"), ["auto"]); + assert_eq!(names("final Map a = {}, b;"), ["a", "b"]); + assert_eq!(names("int get x => 1;"), ["x"]); + assert_eq!(names("set value(int v) {}"), ["value"]); + assert_eq!(names("T read(Object p) => throw 0;"), ["read"]); + assert_eq!(names("const Foo.named();"), ["named"]); + assert_eq!(names("factory Foo.from(Map m) = _Impl;"), ["from"]); + assert_eq!( + names("@Riverpod(keepAlive: true) Repo repo(Ref ref) {}"), + ["repo"] + ); + assert_eq!( + names("bool operator ==(Object o) => true;"), + Vec::::new() + ); + assert_eq!(names("abstract class Foo extends Bar {}"), ["Foo"]); + assert_eq!(names("extension on Shape {}"), Vec::::new()); + assert_eq!(names("import ' ';"), Vec::::new()); + assert_eq!(names("late final x;"), ["x"]); + assert_eq!( + names("final todos = StateNotifierProvider>();"), + ["todos"] + ); + assert_eq!( + names("final calls = {};"), + ["calls"] + ); + assert_eq!(names("final a = b < c, d = 2;"), ["a", "d"]); + // A function type's parameters are none of the declaration's. + assert_eq!(names("final void Function() onDispose;"), ["onDispose"]); + assert_eq!(names("final void Function()? onError;"), ["onError"]); + assert_eq!(names("void Function() callback() => ;"), ["callback"]); + assert_eq!(names("typedef Create = T Function();"), ["Create"]); + } + + #[test] + fn a_declaration_says_its_type_where_the_typing_reads_it() { + let words = |statement: &str, name: &str, class: Option<&str>| { + let (words, untyped) = dart_type_words(statement, name, class); + (words.join(" "), untyped) + }; + // A type's whole head. + assert_eq!( + words("abstract class Foo extends Bar {}", "Foo", None), + ("abstract class Foo T extends Bar T".to_string(), false) + ); + // A constructor makes its class. + assert_eq!( + words("Foo.from() : parent = node.parent;", "from", Some("Foo")), + (String::new(), false) + ); + assert_eq!( + words("const Foo._() : super._();", "_", Some("Foo")), + (String::new(), false) + ); + // A method returning its class is no constructor. + assert_eq!( + words("Foo copy() => ;", "copy", Some("Foo")), + ("Foo".to_string(), false) + ); + // What is written before the name, else the initializer. + assert_eq!( + words( + "@override ProviderContainer get container => ;", + "container", + Some("Ref") + ), + ("ProviderContainer".to_string(), false) + ); + assert_eq!( + words( + "late final List parentIds = container.parents .map();", + "parentIds", + None + ), + ("List ContainerId".to_string(), false) + ); + assert_eq!( + words("static const auto = Builder();", "auto", None), + ("Builder".to_string(), true) + ); + assert_eq!( + words( + "@ProviderFor() final fooProvider = FooProvider._();", + "fooProvider", + None + ), + ("FooProvider _".to_string(), true) + ); + assert_eq!(words("get bar => ;", "bar", None), (String::new(), true)); + assert_eq!( + words("final void Function() onDispose;", "onDispose", None), + ("final void Function onDispose".to_string(), true) + ); + } + + fn dart_node(kind: NodeKind, name: &str, start_line: i64, end_line: i64) -> Node { + Node { + id: format!("{}:{name}", kind.as_str()), + kind, + name: name.to_string(), + qualified_name: name.to_string(), + file_path: "lib/a.dart".to_string(), + language: Language::Dart, + start_line, + end_line, + start_column: 0, + end_column: 0, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + } + } + + #[test] + fn a_chain_link_reads_its_type_and_the_declarations_its_names_name() { + let code = DartCode::of( + "class Child extends Base {\n void f(Repo r) {\n final a = Probe();\n final b = Other();\n this.go().stop();\n b.make().stop();\n b.make(Arg()).stop();\n b.make().stop();\n r.load().stop();\n field.make().stop();\n final c = b.make();\n c.go().stop();\n }\n}\n", + ); + let nodes = [ + dart_node(NodeKind::Class, "Child", 1, 14), + dart_node(NodeKind::Method, "f", 2, 13), + ]; + let reads = |line: i64| chain_link_reads(&code, &nodes, "stop", line, 4).expect("a link"); + let names = |line: i64| reads(line).names.into_iter().collect::>(); + // `this` is typed by the type the chain is written in. + assert_eq!(names(5), ["Child", "go", "stop"]); + // A local is read through its declaration, not by its name; a call's + // arguments are no part of what the chain is typed by, its type + // arguments are. + assert_eq!(names(6), ["Child", "Other", "make", "stop"]); + assert_eq!(reads(6).types, BTreeSet::from(["b".to_string()])); + assert_eq!(names(7), ["Child", "Other", "make", "stop"]); + assert_eq!(names(8), ["Child", "Other", "Typed", "make", "stop"]); + // A parameter by its declared type. + assert_eq!(names(9), ["Child", "Repo", "load", "stop"]); + // A name no local declares is looked up by it (a field, a getter). + assert_eq!(names(10), ["Child", "field", "make", "stop"]); + // A local's initializer is read on in turn. + assert_eq!(names(12), ["Child", "Other", "go", "make", "stop"]); + assert_eq!( + reads(12).types, + BTreeSet::from(["b".to_string(), "c".to_string()]) + ); + } + + #[test] + fn a_link_is_found_where_the_extractor_recorded_it() { + let code = DartCode::of( + "void f() {\n x.m12().m12().m12();\n make().make();\n this.a.m6();\n y.go(z.go()).go();\n}\n", + ); + let found = |line, column, name| { + link_sites(&code, line, column, name) + .into_iter() + .map(|(at, _)| at) + .collect::>() + }; + // `x.m12` is the first call's, recorded through `x`. + assert_eq!(found(2, 2, "m12"), vec![21, 27]); + assert_eq!(found(3, 2, "make"), vec![43]); + assert_eq!(found(4, 2, "a.m6"), vec![60]); + // The inner `z.go` is another chain's; only the outer link counts. + assert_eq!(found(5, 2, "go"), vec![81]); + } +} diff --git a/crates/codegraph-resolve/src/dart_libraries.rs b/crates/codegraph-resolve/src/dart_libraries.rs new file mode 100644 index 00000000..bb8ad3ee --- /dev/null +++ b/crates/codegraph-resolve/src/dart_libraries.rs @@ -0,0 +1,2089 @@ +//! Which files' top-level declarations a Dart file can name without a prefix +//! (upstream `dart-libraries.ts`: #2386, with #2388's URIs and #2391's parts). +//! +//! A Dart library is a file and its `part`s. Its top-level scope holds its own +//! declarations, private `_` names included, and every name exported by a +//! library it imports without a prefix: that library's own public declarations +//! and, through its `export`s, other libraries'. Every import and export is +//! narrowed by its `show` / `hide` combinators. A conditional import (`import +//! 'stub.dart' if (dart.library.io) 'io.dart'`) brings in every alternative. +//! `dart:` libraries and packages outside the repository (flutter, test, +//! mockito, …) export nothing of the project. +//! +//! `package:/` is `/lib/` of the package whose +//! `pubspec.yaml` says `name: `: the nearest `pubspec.yaml` above an +//! indexed `.dart` file, never above the project root. When packages share a +//! name (example apps), the importing file's own package wins, then the one +//! enclosing it, then the one its package depends on by `path:`, else all of +//! them count. An `import`, `export` or `part` edge links the one file its URI +//! names ([`dart_directive_file`]). +//! +//! Directives are read from the head of each file at resolution time. Where +//! the library cannot be established — a `part of` naming no library this can +//! find, a file in no package — every file counts as visible, so the rule only +//! narrows where the library is known. +//! +//! KEEP-RUST: only an indexed file's directives are read, and a URI whose path +//! leaves the project (`package:app/../../x.dart` included) names nothing, so a +//! file the index does not hold never moves resolution and no path outside the +//! project is probed. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; + +use crate::pathutil; +use crate::types::{RefView, ResolutionContext}; + +/// The names an import's or export's `show` / `hide` combinators let through +/// (upstream `NameFilter`); no `show` lets every name through. Equal filters +/// compare equal, so a namespace keeps each once. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct NameFilter { + pub show: Option>, + pub hide: Option>, +} + +impl NameFilter { + /// The filter that lets every name through. + pub const EVERY: NameFilter = NameFilter { + show: None, + hide: None, + }; + + /// Upstream `makeFilter`: an empty `hide` hides nothing. + fn new(show: Option>, hide: Option>) -> Self { + Self { + show, + hide: hide.filter(|hide| !hide.is_empty()), + } + } + + fn is_every(&self) -> bool { + self.show.is_none() && self.hide.is_none() + } + + /// `self`, then `next` (upstream `composeFilters`): a name gets through + /// when both let it. + fn then(&self, next: &NameFilter) -> NameFilter { + if self.is_every() { + return next.clone(); + } + if next.is_every() { + return self.clone(); + } + let show = match (&self.show, &next.show) { + (Some(a), Some(b)) => Some(a.intersection(b).cloned().collect()), + (a, b) => a.clone().or_else(|| b.clone()), + }; + let hide = match (&self.hide, &next.hide) { + (Some(a), Some(b)) => Some(a.union(b).cloned().collect()), + (a, b) => a.clone().or_else(|| b.clone()), + }; + NameFilter::new(show, hide) + } + + /// Whether `name` gets through (upstream `lets`). + pub fn lets(&self, name: &str) -> bool { + self.show.as_ref().is_none_or(|show| show.contains(name)) + && self.hide.as_ref().is_none_or(|hide| !hide.contains(name)) + } +} + +/// An `import` or `export` directive (upstream `DartNamespaceDirective`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamespaceDirective { + /// The URI, then each conditional alternative (`if (dart.library.io) + /// 'io.dart'`). + pub uris: Vec, + /// The prefix of `import '…' as p`. + pub prefix: Option, + pub filter: NameFilter, +} + +/// What a `part of` directive names: a file by URI, or a library by name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PartOf { + Uri(String), + Name(String), +} + +/// The directives at the head of a Dart file (upstream `DartDirectives`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DartDirectives { + /// `library a.b;` gives `a.b`, `library;` the empty name, no directive + /// `None`. + pub library_name: Option, + pub part_of: Option, + pub parts: Vec, + pub imports: Vec, + pub exports: Vec, +} + +/// The length of the identifier (`[A-Za-z_$][\w$]*`, ASCII as in +/// JavaScript) at byte `i`, if one starts there. +fn identifier_at(src: &str, i: usize) -> Option { + let bytes = src.as_bytes(); + let first = *bytes.get(i)?; + if !(first.is_ascii_alphabetic() || first == b'_' || first == b'$') { + return None; + } + let len = bytes[i..] + .iter() + .take_while(|b| b.is_ascii_alphanumeric() || **b == b'_' || **b == b'$') + .count(); + Some(len) +} + +/// The byte length of the character at `i` (1 past the end). +fn char_len_at(src: &str, i: usize) -> usize { + src.get(i..) + .and_then(|rest| rest.chars().next()) + .map_or(1, char::len_utf8) +} + +/// The index past the whitespace and comments at `i` (upstream +/// `skipTrivia`; Dart block comments nest). +fn skip_trivia(src: &str, mut i: usize) -> usize { + let bytes = src.as_bytes(); + let n = bytes.len(); + while i < n { + let c = bytes[i]; + if c == b' ' || (9..=13).contains(&c) { + i += 1; + } else if src[i..].starts_with('\u{feff}') { + i += '\u{feff}'.len_utf8(); + } else if c == b'/' && bytes.get(i + 1) == Some(&b'/') { + i = src[i + 2..].find('\n').map_or(n, |at| i + 2 + at + 1); + } else if c == b'/' && bytes.get(i + 1) == Some(&b'*') { + let mut depth = 1; + i += 2; + while i < n && depth > 0 { + if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') { + depth += 1; + i += 2; + } else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') { + depth -= 1; + i += 2; + } else { + i += char_len_at(src, i); + } + } + } else { + break; + } + } + i.min(n) +} + +/// The string literal at `i` — `'…'`, `"…"`, triple-quoted or raw — as its +/// value and the index past it (upstream `readString`). +fn read_string(src: &str, mut i: usize) -> Option<(String, usize)> { + let bytes = src.as_bytes(); + let mut raw = false; + if bytes.get(i) == Some(&b'r') && matches!(bytes.get(i + 1), Some(b'\'' | b'"')) { + raw = true; + i += 1; + } + let quote = *bytes.get(i)?; + if quote != b'\'' && quote != b'"' { + return None; + } + let triple = bytes.get(i..i + 3) == Some(&[quote; 3][..]); + let close_len = if triple { 3 } else { 1 }; + let closes_at = |j: usize| { + bytes + .get(j..j + close_len) + .is_some_and(|w| w.iter().all(|b| *b == quote)) + }; + let mut j = i + close_len; + let mut value = String::new(); + while j < bytes.len() { + if closes_at(j) { + return Some((value, j + close_len)); + } + let c = src[j..].chars().next()?; + if c == '\n' && !triple { + return None; + } + if c == '\\' && !raw { + match src[j + 1..].chars().next() { + Some(next) => { + value.push(next); + j += 1 + next.len_utf8(); + } + None => j += 1, + } + } else if c == '$' && !raw && bytes.get(j + 1) == Some(&b'{') { + let mut depth = 1; + j += 2; + while j < bytes.len() && depth > 0 { + if let Some((_, end)) = read_string(src, j) { + j = end; + continue; + } + if bytes[j] == b'{' { + depth += 1; + } else if bytes[j] == b'}' { + depth -= 1; + } + j += char_len_at(src, j); + } + } else { + value.push(c); + j += c.len_utf8(); + } + } + None +} + +/// The index past the balanced `open` … `close` group at `i`, strings and +/// comments skipped (upstream `skipGroup`); `None` when it never closes. +fn skip_group(src: &str, mut i: usize, open: u8, close: u8) -> Option { + let bytes = src.as_bytes(); + let mut depth = 0i64; + while i < bytes.len() { + i = skip_trivia(src, i); + if let Some((_, end)) = read_string(src, i) { + i = end; + continue; + } + let Some(&c) = bytes.get(i) else { + break; + }; + if c == open { + depth += 1; + } else if c == close { + depth -= 1; + if depth == 0 { + return Some(i + 1); + } + } + i += char_len_at(src, i); + } + None +} + +/// The index past the metadata annotation at `i` (`@TestOn('vm')`, +/// `@Tags(['slow'])`, `@JS()`; upstream `skipAnnotation`). +fn skip_annotation(src: &str, mut i: usize) -> usize { + let bytes = src.as_bytes(); + i += 1; + loop { + i = skip_trivia(src, i); + let Some(len) = identifier_at(src, i) else { + return i; + }; + i += len; + let next = skip_trivia(src, i); + if bytes.get(next) != Some(&b'.') { + break; + } + i = next + 1; + } + i = skip_trivia(src, i); + if bytes.get(i) == Some(&b'<') { + let Some(end) = skip_group(src, i, b'<', b'>') else { + return src.len(); + }; + i = skip_trivia(src, end); + } + if bytes.get(i) == Some(&b'(') { + return skip_group(src, i, b'(', b')').unwrap_or(src.len()); + } + i +} + +/// A token of a directive (upstream `DirectiveToken`). +#[derive(Debug, Clone, PartialEq, Eq)] +enum Token { + Str(String), + Word(String), + Condition, + Punct(char), +} + +/// The tokens of a directive, from just past its keyword to its `;`, and the +/// index past that `;` (upstream `readDirectiveTokens`) — or `None` when a +/// body (`{`, `=>`) comes first, as in a function named `part`, or the file +/// ends. +fn read_directive_tokens(src: &str, mut i: usize) -> Option<(Vec, usize)> { + let bytes = src.as_bytes(); + let mut tokens = Vec::new(); + loop { + i = skip_trivia(src, i); + if i >= bytes.len() || tokens.len() > 2000 { + return None; + } + let c = bytes[i]; + if c == b';' { + return Some((tokens, i + 1)); + } + if c == b'{' || c == b'}' || (c == b'=' && bytes.get(i + 1) == Some(&b'>')) { + return None; + } + if let Some((value, end)) = read_string(src, i) { + tokens.push(Token::Str(value)); + i = end; + continue; + } + if c == b'(' { + // A configuration condition: `(dart.library.io)`. + i = skip_group(src, i, b'(', b')')?; + tokens.push(Token::Condition); + continue; + } + if let Some(len) = identifier_at(src, i) { + tokens.push(Token::Word(src[i..i + len].to_string())); + i += len; + continue; + } + let ch = src[i..].chars().next()?; + tokens.push(Token::Punct(ch)); + i += ch.len_utf8(); + } +} + +/// A dotted library name (`app.named`), as the words and dots it is written +/// with (upstream `dottedName`). +fn dotted_name(tokens: &[Token]) -> Option { + let mut name = String::new(); + for token in tokens { + match token { + Token::Word(word) => name.push_str(word), + Token::Punct('.') => name.push('.'), + _ => return None, + } + } + Some(name) +} + +/// Record the directive `keyword` with `tokens` in `out`; false when the +/// tokens are no such directive (upstream `addDirective`). +fn add_directive(out: &mut DartDirectives, keyword: &str, tokens: &[Token]) -> bool { + let first = tokens.first(); + if keyword == "library" { + let Some(name) = dotted_name(tokens) else { + return false; + }; + out.library_name = Some(name); + return true; + } + if keyword == "part" { + if first == Some(&Token::Word("of".to_string())) { + match tokens.get(1) { + Some(Token::Str(uri)) => out.part_of = Some(PartOf::Uri(uri.clone())), + _ => match dotted_name(&tokens[1..]).filter(|name| !name.is_empty()) { + Some(name) => out.part_of = Some(PartOf::Name(name)), + None => return false, + }, + } + return true; + } + let Some(Token::Str(uri)) = first else { + return false; + }; + out.parts.push(uri.clone()); + return true; + } + let Some(Token::Str(uri)) = first else { + return false; + }; + let mut uris = vec![uri.clone()]; + let mut prefix = None; + let mut filter = NameFilter::EVERY; + let mut k = 1; + // Adjacent literals are one string: `'package:a/' 'b.dart'`. + while let Some(Token::Str(more)) = tokens.get(k) { + uris[0].push_str(more); + k += 1; + } + while k < tokens.len() { + match (&tokens[k], tokens.get(k + 1), tokens.get(k + 2)) { + (Token::Word(word), Some(Token::Condition), Some(Token::Str(alternative))) + if word == "if" => + { + uris.push(alternative.clone()); + k += 3; + } + (Token::Word(word), Some(Token::Word(name)), _) if word == "as" => { + prefix = Some(name.clone()); + k += 2; + } + (Token::Word(word), _, _) if word == "show" || word == "hide" => { + let shows = word == "show"; + let mut names = BTreeSet::new(); + k += 1; + while let Some(Token::Word(name)) = tokens.get(k) { + names.insert(name.clone()); + if tokens.get(k + 1) != Some(&Token::Punct(',')) { + k += 1; + break; + } + k += 2; + } + let combinator = if shows { + NameFilter::new(Some(names), None) + } else { + NameFilter::new(None, Some(names)) + }; + filter = filter.then(&combinator); + } + // `deferred`, and anything this does not model. + _ => k += 1, + } + } + let directive = NamespaceDirective { + uris, + prefix, + filter, + }; + if keyword == "import" { + out.imports.push(directive); + } else { + out.exports.push(directive); + } + true +} + +/// The `library`, `part`, `part of`, `import` and `export` directives at the +/// head of a Dart source file — past its script tag, comments and metadata, +/// up to its first declaration (upstream `readDartDirectives`). +pub fn read_dart_directives(source: &str) -> DartDirectives { + let mut out = DartDirectives::default(); + let mut i = if source.starts_with('\u{feff}') { + '\u{feff}'.len_utf8() + } else { + 0 + }; + if source[i..].starts_with("#!") { + i = source[i..].find('\n').map_or(source.len(), |at| i + at + 1); + } + loop { + i = skip_trivia(source, i); + if i >= source.len() { + break; + } + if source.as_bytes()[i] == b'@' { + i = skip_annotation(source, i); + continue; + } + let Some(len) = identifier_at(source, i) else { + break; + }; + let keyword = &source[i..i + len]; + if !matches!(keyword, "library" | "import" | "export" | "part") { + break; + } + let Some((tokens, end)) = read_directive_tokens(source, i + len) else { + break; + }; + if !add_directive(&mut out, keyword, &tokens) { + break; + } + i = end; + } + out +} + +/// A pub package of the project: its pubspec.yaml's `name`, and the +/// directory holding it (`""` for the project root). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct PubPackage { + pub name: String, + pub root: String, +} + +/// An import prefix (`""` for none) and a declaring file. +type PrefixedFile = (String, String); + +/// A package's root and the name of one of its dependencies. +type PackageDependency = (String, String); + +/// What a file's imports and its library's own files make visible (upstream +/// `FileScope`). +#[derive(Debug)] +struct FileScope { + /// The library's files: the library, its parts, and the file itself. + own: BTreeSet, + /// The files whose imports apply here: the file, and for a part each file + /// up to its library. + importers: Vec, + /// By import prefix (`""` for none), the libraries those imports name, + /// each with the import's filter (read on first use). + imported: OnceLock>>, + /// By prefix and declaring file, the filters through which the imports + /// bring in its names (read on first use). + visible: Mutex>>>, +} + +/// The files whose public names a library exports, each with the filters on +/// the way (upstream `exportsOf`'s namespace). +pub type Namespace = BTreeMap>; + +/// How many libraries deep an export chain is followed; a deeper chain +/// exports nothing more (KEEP-RUST: upstream recurses without a bound). +const EXPORT_DEPTH_LIMIT: usize = 64; + +/// One step of [`DartLibraries::exports_of`]: the namespace, the shallowest +/// library on the walk's stack it leans on, and whether the depth limit cut +/// it short. +struct ExportWalk { + namespace: Arc, + low: usize, + cut: bool, +} + +/// One fixed state of the project's Dart files, read in place of a +/// context's: a sync's before or after (W4-DART-02). +#[derive(Debug, Default)] +struct FixedState { + files: Vec, + directives: HashMap>, + /// The pubspec manifests of the state, by directory ([`pubspec_snapshot`]). + manifests: BTreeMap, +} + +/// What a Dart file's resolution reads of the library model, apart from its +/// library's own files and what the libraries it imports export: its scope +/// (`None` when unknown, every file then visible), by import prefix (`""` for +/// none) the libraries its scope's imports bring in with their filters (an +/// unprefixed import naming no library dropped), and the files each of its +/// own directives names (W4-DART-02). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileStructure { + pub scope: Option<(String, Vec)>, + pub imported: BTreeMap>, + pub targets: Vec>, +} + +/// The Dart library memos of one resolution pass (upstream `Memo`). +#[derive(Debug, Default)] +pub struct DartLibraries { + state: Option, + directives: Mutex>>>, + package_of_dir: Mutex>>>, + packages: OnceLock>>>, + path_dependencies: Mutex>>>, + library_names: OnceLock>>, + library_of: Mutex>>, + parent_of: Mutex>, + members: Mutex>>>, + exports: Mutex>>, + scopes: Mutex>>>, + prefixes: Mutex>>>, +} + +fn memo_get(memo: &Mutex>, key: &K) -> Option +where + K: std::hash::Hash + Eq, + V: Clone, +{ + memo.lock().ok().and_then(|memo| memo.get(key).cloned()) +} + +fn memo_set(memo: &Mutex>, key: K, value: V) +where + K: std::hash::Hash + Eq, +{ + if let Ok(mut memo) = memo.lock() { + memo.insert(key, value); + } +} + +/// `name: ` at the start of a pubspec line (upstream +/// `PUBSPEC_NAME`). +fn pubspec_name() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"(?m)^name[ \t]*:[ \t]*['"]?([A-Za-z_][0-9A-Za-z_]*)"#).expect("pubspec name") + }) +} + +/// The `name:` a pubspec text gives its package. +pub fn pubspec_package_name(text: &str) -> Option { + pubspec_name() + .captures(text) + .and_then(|captures| captures.get(1)) + .map(|name| name.as_str().to_string()) +} + +/// The text of the project file `relative`, read as a source file is: a +/// regular file within the size limit, stat first. +fn read_project_file(context: &dyn ResolutionContext, relative: &str) -> Option { + read_file_under(context.get_project_root(), relative) +} + +/// [`read_project_file`] under the project root `project_root`. +fn read_file_under(project_root: &str, relative: &str) -> Option { + crate::context::read_source_file( + &std::path::Path::new(project_root).join(relative), + relative, + codegraph_core::config::DEFAULT_MAX_FILE_SIZE, + ) +} + +/// Metadata key prefix of a directory's pubspec row (W5-06). +pub const PUBSPEC_ROW_KEY_PREFIX: &str = "dart_pubspec:"; + +/// The `project_metadata` key of the pubspec row of project directory `dir` +/// (`""` for the root). +pub fn pubspec_row_key(dir: &str) -> String { + format!("{PUBSPEC_ROW_KEY_PREFIX}{dir}") +} + +/// What the library model reads in one directory: its `pubspec.yaml` and +/// `pubspec_overrides.yaml`, as [`read_project_file`] reads them (`None` for +/// a missing or unreadable one). +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PubspecRow { + pub pubspec: Option, + pub overrides: Option, +} + +impl PubspecRow { + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("dart pubspec row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } + + /// The text of `file`, one of the two manifests. + fn text_of(&self, file: &str) -> Option<&str> { + match file { + "pubspec.yaml" => self.pubspec.as_deref(), + "pubspec_overrides.yaml" => self.overrides.as_deref(), + _ => None, + } + } +} + +/// The pubspec manifests the library model reads for the Dart files `files` +/// of the project at `project_root`: in each directory from a file's up to +/// the root, `pubspec.yaml` and `pubspec_overrides.yaml`, read as the model +/// reads them; a directory holding neither is left out (W5-06). A package's +/// name and its `path:` dependencies are read only in those directories. +pub fn pubspec_snapshot(project_root: &str, files: &[String]) -> BTreeMap { + let mut dirs: BTreeSet = BTreeSet::new(); + for file in files.iter().filter(|file| file.ends_with(".dart")) { + let mut dir = pathutil::dirname(file); + loop { + if !dirs.insert(dir.clone()) || dir.is_empty() { + break; + } + dir = pathutil::dirname(&dir); + } + } + dirs.into_iter() + .filter_map(|dir| { + let row = PubspecRow { + pubspec: read_file_under(project_root, &join(&dir, "pubspec.yaml")), + overrides: read_file_under(project_root, &join(&dir, "pubspec_overrides.yaml")), + }; + (row != PubspecRow::default()).then_some((dir, row)) + }) + .collect() +} + +/// `dir/file`, or `file` at the project root. +fn join(dir: &str, file: &str) -> String { + if dir.is_empty() { + file.to_string() + } else { + format!("{dir}/{file}") + } +} + +/// A project-relative path, normalized, unless it leaves the project. +fn contained(path: &str) -> Option { + let normalized = pathutil::normalize(path); + (normalized != ".." + && !normalized.starts_with("../") + && !normalized.starts_with('/') + && normalized != ".") + .then_some(normalized) +} + +/// A URI with a scheme (`dart:`, `http:`), which names no project file. +fn has_scheme(uri: &str) -> bool { + let bytes = uri.as_bytes(); + bytes.first().is_some_and(u8::is_ascii_alphabetic) + && bytes[1..] + .iter() + .take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'+' | b'.' | b'-')) + .count() + .checked_add(1) + .is_some_and(|len| bytes.get(len) == Some(&b':')) +} + +/// The `path:` a pubspec gives its dependency `name`, written as a block +/// (`name:`, then `path: x` below it) or a flow mapping (`name: {path: x}`) +/// (upstream `yamlPathDependencies`). A git dependency's `path:` (inside its +/// `git:` mapping) is a path in that repository, so only `path:` directly +/// under the name counts. +pub fn yaml_path_dependencies(text: &str, name: &str) -> Vec { + static FLOW_PATH: OnceLock = OnceLock::new(); + static BLOCK_PATH: OnceLock = OnceLock::new(); + let flow_path = FLOW_PATH.get_or_init(|| { + Regex::new(r#"[{,][ \t]*path[ \t]*:[ \t]*(?:"([^"]*)"|'([^']*)'|([^,}\s]+))"#) + .expect("flow path") + }); + let block_path = BLOCK_PATH.get_or_init(|| { + Regex::new(r#"^[ \t]*path[ \t]*:[ \t]*(?:"([^"]*)"|'([^']*)'|([^\s#]+))"#) + .expect("block path") + }); + let first_group = |captures: regex::Captures<'_>| { + (1..=3) + .find_map(|group| captures.get(group)) + .map(|found| found.as_str().to_string()) + }; + let lines: Vec<&str> = text + .split('\n') + .map(|line| line.trim_end_matches('\r')) + .collect(); + let mut out = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let Some((indent, value)) = yaml_key_of(line, name) else { + continue; + }; + let value = strip_yaml_comment(value).trim(); + if !value.is_empty() { + if value.starts_with('{') + && value.ends_with('}') + && !value[1..value.len() - 1].contains(['{', '}']) + && let Some(path) = flow_path.captures(value).and_then(first_group) + { + out.push(path); + } + continue; + } + let mut child_indent = None; + for next in &lines[i + 1..] { + let trimmed = next.trim_start_matches([' ', '\t']); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let depth = next.len() - trimmed.len(); + if depth <= indent { + break; + } + let child = *child_indent.get_or_insert(depth); + if depth == child + && let Some(path) = block_path.captures(next).and_then(first_group) + { + out.push(path); + } + } + } + out +} + +/// An indented YAML key line for `name` (` name:` or ` "name": value`): +/// its indent and the text after the colon (upstream's +/// `/^([ \t]+)(['"]?)([A-Za-z_]\w*)\2[ \t]*:(?:[ \t]+(.*))?$/`). +fn yaml_key_of<'a>(line: &'a str, name: &str) -> Option<(usize, &'a str)> { + let rest = line.trim_start_matches([' ', '\t']); + let indent = line.len() - rest.len(); + if indent == 0 { + return None; + } + let (quote, rest) = match rest.as_bytes().first() { + Some(b'\'') => (Some('\''), &rest[1..]), + Some(b'"') => (Some('"'), &rest[1..]), + _ => (None, rest), + }; + let bytes = rest.as_bytes(); + if !bytes + .first() + .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_') + { + return None; + } + let len = bytes + .iter() + .take_while(|b| b.is_ascii_alphanumeric() || **b == b'_') + .count(); + if &rest[..len] != name { + return None; + } + let mut rest = &rest[len..]; + if let Some(quote) = quote { + rest = rest.strip_prefix(quote)?; + } + let rest = rest.trim_start_matches([' ', '\t']).strip_prefix(':')?; + if rest.is_empty() { + return Some((indent, "")); + } + let value = rest.trim_start_matches([' ', '\t']); + (value.len() < rest.len()).then_some((indent, value)) +} + +/// A YAML value with its trailing comment (` # …`, or `#…` at its start) +/// removed. +fn strip_yaml_comment(value: &str) -> &str { + let bytes = value.as_bytes(); + for (at, byte) in bytes.iter().enumerate() { + if *byte == b'#' && (at == 0 || matches!(bytes[at - 1], b' ' | b'\t')) { + return &value[..at.saturating_sub(usize::from(at > 0))]; + } + } + value +} + +impl DartLibraries { + pub fn new() -> Self { + Self::default() + } + + /// The model of one fixed state of the project's Dart files (a sync's + /// before or after): `files` are its indexed Dart files, `directives` + /// what each of them holds (a file missing from it holds none), and + /// `manifests` its pubspec manifests by directory ([`pubspec_snapshot`]; + /// a directory missing from it holds none). Nothing is read from disk. + pub fn of_state( + mut files: Vec, + directives: HashMap, + manifests: BTreeMap, + ) -> Self { + files.sort(); + files.dedup(); + Self { + state: Some(FixedState { + files, + directives: directives + .into_iter() + .map(|(file, d)| (file, Arc::new(d))) + .collect(), + manifests, + }), + ..Self::default() + } + } + + /// The text of manifest `file` in project directory `dir`: the fixed + /// state's snapshot, or the file on disk. + fn manifest_text( + &self, + dir: &str, + file: &str, + context: &dyn ResolutionContext, + ) -> Option { + match &self.state { + Some(state) => state + .manifests + .get(dir) + .and_then(|row| row.text_of(file)) + .map(str::to_string), + None => read_project_file(context, &join(dir, file)), + } + } + + /// The project's indexed Dart files. + fn dart_files(&self, context: &dyn ResolutionContext) -> Vec { + match &self.state { + Some(state) => state.files.clone(), + None => context + .get_all_files_shared() + .iter() + .filter(|file| file.ends_with(".dart")) + .cloned() + .collect(), + } + } + + /// The directives of the indexed Dart file `file`, read once per pass. + fn directives_of( + &self, + file: &str, + context: &dyn ResolutionContext, + ) -> Option> { + if let Some(state) = &self.state { + return state + .files + .binary_search_by(|f| f.as_str().cmp(file)) + .is_ok() + .then(|| { + state + .directives + .get(file) + .cloned() + .unwrap_or_else(|| Arc::new(DartDirectives::default())) + }); + } + if let Some(hit) = memo_get(&self.directives, &file.to_string()) { + return hit; + } + let directives = if context.is_indexed_file(file) { + context + .read_file_shared(file) + .map(|source| Arc::new(read_dart_directives(&source))) + } else { + None + }; + memo_set(&self.directives, file.to_string(), directives.clone()); + directives + } + + /// The package of the nearest pubspec.yaml at or above project-relative + /// `dir`, never above the project root (upstream `packageOfDir`). + fn package_of_dir( + &self, + dir: &str, + context: &dyn ResolutionContext, + ) -> Option> { + let mut walked = Vec::new(); + let mut d = dir.to_string(); + let found = loop { + if let Some(hit) = memo_get(&self.package_of_dir, &d) { + break hit; + } + walked.push(d.clone()); + if let Some(name) = self + .manifest_text(&d, "pubspec.yaml", context) + .as_deref() + .and_then(pubspec_package_name) + { + break Some(Arc::new(PubPackage { + name, + root: d.clone(), + })); + } + if d.is_empty() { + break None; + } + d = pathutil::dirname(&d); + }; + for walked in walked { + memo_set(&self.package_of_dir, walked, found.clone()); + } + found + } + + /// The project's packages named `name`: the packages that hold an indexed + /// `.dart` file (upstream `packagesNamed`). + fn packages_named(&self, name: &str, context: &dyn ResolutionContext) -> Vec> { + let packages = self.packages.get_or_init(|| { + let mut by_name: BTreeMap>> = BTreeMap::new(); + let mut seen: HashSet = HashSet::new(); + for file in &self.dart_files(context) { + let Some(package) = self.package_of_dir(&pathutil::dirname(file), context) else { + continue; + }; + if seen.insert(package.root.clone()) { + by_name + .entry(package.name.clone()) + .or_default() + .push(package); + } + } + for list in by_name.values_mut() { + list.sort(); + } + by_name + }); + packages.get(name).cloned().unwrap_or_default() + } + + /// The project directories `package`'s pubspec_overrides.yaml and + /// pubspec.yaml give as the `path:` of its dependency `name` (upstream + /// `pathDependencies`). + fn path_dependencies( + &self, + package: &PubPackage, + name: &str, + context: &dyn ResolutionContext, + ) -> Arc> { + let key = (package.root.clone(), name.to_string()); + if let Some(hit) = memo_get(&self.path_dependencies, &key) { + return hit; + } + let mut dirs: Vec = Vec::new(); + for file in ["pubspec_overrides.yaml", "pubspec.yaml"] { + let Some(text) = self.manifest_text(&package.root, file, context) else { + continue; + }; + for written in yaml_path_dependencies(&text, name) { + let dep = written.replace('\\', "/"); + let bytes = dep.as_bytes(); + if dep.starts_with('/') + || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') + { + continue; + } + let base = if package.root.is_empty() { + "." + } else { + package.root.as_str() + }; + let dir = pathutil::normalize(&format!("{base}/{dep}")); + if dir == ".." || dir.starts_with("../") { + continue; + } + let root = if dir == "." { String::new() } else { dir }; + if !dirs.contains(&root) { + dirs.push(root); + } + } + } + let dirs = Arc::new(dirs); + memo_set(&self.path_dependencies, key, Arc::clone(&dirs)); + dirs + } + + /// The project files a URI written in `from` names (upstream + /// `resolveDartUri`): none for `dart:` and other schemes, a package + /// outside the project, or a path out of it. + fn resolve_dart_uri( + &self, + from: &str, + uri: &str, + context: &dyn ResolutionContext, + ) -> Vec { + if let Some(rest) = uri.strip_prefix("package:") { + let Some((name, path)) = rest.split_once('/') else { + return Vec::new(); + }; + let mut chosen = self.packages_named(name, context); + if chosen.len() > 1 { + let own = self.package_of_dir(&pathutil::dirname(from), context); + let enclosing: Vec> = chosen + .iter() + .filter(|p| p.root.is_empty() || from.starts_with(&format!("{}/", p.root))) + .cloned() + .collect(); + if let Some(own) = own.as_ref().filter(|own| own.name == name) { + chosen = vec![Arc::clone(own)]; + } else if !enclosing.is_empty() { + let nearest = enclosing + .iter() + .fold(None::<&Arc>, |best, p| match best { + Some(best) if best.root.len() >= p.root.len() => Some(best), + _ => Some(p), + }) + .cloned(); + chosen = nearest.into_iter().collect(); + } else if let Some(own) = own { + // Example apps that each keep a same-named package (bloc's + // two authentication_repository) name theirs as a path + // dependency. + let dirs = self.path_dependencies(&own, name, context); + let declared: Vec> = chosen + .iter() + .filter(|p| dirs.contains(&p.root)) + .cloned() + .collect(); + if declared.len() == 1 { + chosen = declared; + } + } + } + return chosen + .iter() + .filter_map(|p| contained(&join(&p.root, &format!("lib/{path}")))) + .collect(); + } + if has_scheme(uri) { + return Vec::new(); + } + let dir = pathutil::dirname(from); + let base = if dir.is_empty() { "." } else { dir.as_str() }; + contained(&format!("{base}/{uri}")).into_iter().collect() + } + + /// The files declaring `library ;` (upstream `libraryFilesNamed`). + fn library_files_named(&self, name: &str, context: &dyn ResolutionContext) -> Vec { + let names = self.library_names.get_or_init(|| { + let mut by_name: BTreeMap> = BTreeMap::new(); + for file in &self.dart_files(context) { + if let Some(declared) = self + .directives_of(file, context) + .and_then(|d| d.library_name.clone()) + .filter(|declared| !declared.is_empty()) + { + by_name.entry(declared).or_default().push(file.clone()); + } + } + by_name + }); + names.get(name).cloned().unwrap_or_default() + } + + /// The file a part's `part of` names, or `None` when it names none this + /// can find (upstream `partParent`). + fn part_parent( + &self, + file: &str, + part_of: &PartOf, + context: &dyn ResolutionContext, + ) -> Option { + let candidates: Vec = match part_of { + PartOf::Uri(uri) => self + .resolve_dart_uri(file, uri, context) + .into_iter() + .filter(|f| self.directives_of(f, context).is_some()) + .collect(), + PartOf::Name(name) => self.library_files_named(name, context), + }; + if candidates.len() <= 1 { + return candidates.into_iter().next(); + } + let listing: Vec = candidates + .into_iter() + .filter(|candidate| { + self.directives_of(candidate, context).is_some_and(|d| { + d.parts.iter().any(|uri| { + self.resolve_dart_uri(candidate, uri, context) + .iter() + .any(|part| part == file) + }) + }) + }) + .collect(); + if listing.len() == 1 { + listing.into_iter().next() + } else { + None + } + } + + /// The library file `file` belongs to: itself, or for a part the library + /// its `part of` chain ends at; `None` when unknown (upstream + /// `libraryOf`). A part cycle ends at `None` for every file on it. + fn library_of(&self, file: &str, context: &dyn ResolutionContext) -> Option { + let mut path: Vec = Vec::new(); + let mut on_path: HashSet = HashSet::new(); + let mut current = file.to_string(); + let library = loop { + if let Some(hit) = memo_get(&self.library_of, ¤t) { + break hit; + } + if !on_path.insert(current.clone()) { + break None; + } + path.push(current.clone()); + let Some(directives) = self.directives_of(¤t, context) else { + break None; + }; + let Some(part_of) = directives.part_of.as_ref() else { + break Some(current.clone()); + }; + let Some(parent) = self.part_parent(¤t, part_of, context) else { + break None; + }; + memo_set(&self.parent_of, current.clone(), parent.clone()); + current = parent; + }; + for walked in path { + memo_set(&self.library_of, walked, library.clone()); + } + library + } + + /// A library's files: the library and its parts, theirs included + /// (upstream `membersOf`). + fn members_of(&self, library: &str, context: &dyn ResolutionContext) -> Arc> { + if let Some(hit) = memo_get(&self.members, &library.to_string()) { + return hit; + } + let mut files = BTreeSet::from([library.to_string()]); + let mut queue = VecDeque::from([library.to_string()]); + while files.len() < 1000 { + let Some(file) = queue.pop_front() else { + break; + }; + let Some(directives) = self.directives_of(&file, context) else { + continue; + }; + for uri in &directives.parts { + for part in self.resolve_dart_uri(&file, uri, context) { + if files.insert(part.clone()) { + queue.push_back(part); + } + } + } + } + let files = Arc::new(files); + memo_set(&self.members, library.to_string(), Arc::clone(&files)); + files + } + + /// The files whose public names `library` exports, each with the filters + /// on the way: its own files, and what its `export`s reach (upstream + /// `exportsOf`). `low` is the shallowest library still on `stack` that the + /// walk came back to; a result that leans on one is incomplete until that + /// library finishes, so it is kept only when the walk came back to + /// nothing shallower than this library. A walk cut at the depth limit is + /// never kept, so a result never depends on where a walk started. + fn exports_of( + &self, + library: &str, + context: &dyn ResolutionContext, + stack: &mut Vec, + ) -> ExportWalk { + if let Some(hit) = memo_get(&self.exports, &library.to_string()) { + return ExportWalk { + namespace: hit, + low: usize::MAX, + cut: false, + }; + } + let depth = stack.len(); + if depth >= EXPORT_DEPTH_LIMIT { + return ExportWalk { + namespace: Arc::new(Namespace::new()), + low: usize::MAX, + cut: true, + }; + } + stack.push(library.to_string()); + let mut namespace = Namespace::new(); + let mut low = usize::MAX; + let mut cut = false; + let members = self.members_of(library, context); + for file in members.iter() { + add_filter(&mut namespace, file, NameFilter::EVERY); + } + for file in members.iter() { + let Some(directives) = self.directives_of(file, context) else { + continue; + }; + for directive in &directives.exports { + for uri in &directive.uris { + for target in self.resolve_dart_uri(file, uri, context) { + let Some(exported) = self.library_of(&target, context) else { + continue; + }; + if let Some(on_stack) = stack.iter().position(|l| *l == exported) { + low = low.min(on_stack); + continue; + } + let inner = self.exports_of(&exported, context, stack); + low = low.min(inner.low); + cut |= inner.cut; + for (exported_file, filters) in inner.namespace.iter() { + for filter in filters { + add_filter( + &mut namespace, + exported_file, + directive.filter.then(filter), + ); + } + } + } + } + } + } + stack.pop(); + let namespace = Arc::new(namespace); + if low >= depth && !cut { + memo_set(&self.exports, library.to_string(), Arc::clone(&namespace)); + } + ExportWalk { + namespace, + low, + cut, + } + } + + /// What `file` can see, or `None` when that is unknown: its library + /// cannot be found, or no pubspec.yaml above it says which package its + /// `package:` imports start from (upstream `scopeOf`). + fn scope_of(&self, file: &str, context: &dyn ResolutionContext) -> Option> { + if let Some(hit) = memo_get(&self.scopes, &file.to_string()) { + return hit; + } + let library = if self + .package_of_dir(&pathutil::dirname(file), context) + .is_none() + { + None + } else { + self.library_of(file, context) + }; + let scope = library.map(|library| { + let mut own = (*self.members_of(&library, context)).clone(); + own.insert(file.to_string()); + let mut importers = Vec::new(); + let mut current = Some(file.to_string()); + while let Some(f) = current { + if importers.len() >= 32 { + break; + } + importers.push(f.clone()); + if f == library { + break; + } + current = self.parent_of(&f); + } + Arc::new(FileScope { + own, + importers, + imported: OnceLock::new(), + visible: Mutex::new(HashMap::new()), + }) + }); + memo_set(&self.scopes, file.to_string(), scope.clone()); + scope + } + + /// The filters through which the imports written with `prefix` (`""` for + /// none) bring `decl_file`'s names in — empty when they bring in none of + /// them — or `None` when no import has that prefix (upstream + /// `importedFilters`). + fn imported_filters( + &self, + scope: &FileScope, + prefix: &str, + decl_file: &str, + context: &dyn ResolutionContext, + ) -> Option>> { + let imported = scope.imported.get_or_init(|| { + let mut imported: BTreeMap> = BTreeMap::new(); + for importer in &scope.importers { + let Some(directives) = self.directives_of(importer, context) else { + continue; + }; + for directive in &directives.imports { + let libraries = imported + .entry(directive.prefix.clone().unwrap_or_default()) + .or_default(); + for uri in &directive.uris { + for target in self.resolve_dart_uri(importer, uri, context) { + if let Some(library) = self.library_of(&target, context) { + libraries.push((directive.filter.clone(), library)); + } + } + } + } + } + imported + }); + let Some(libraries) = imported.get(prefix) else { + return prefix.is_empty().then(|| Arc::new(Vec::new())); + }; + let key = (prefix.to_string(), decl_file.to_string()); + if let Some(hit) = memo_get(&scope.visible, &key) { + return Some(hit); + } + let mut through: Vec = Vec::new(); + for (filter, library) in libraries { + let walk = self.exports_of(library, context, &mut Vec::new()); + for exported in walk.namespace.get(decl_file).into_iter().flatten() { + push_filter(&mut through, filter.then(exported)); + } + } + let through = Arc::new(through); + memo_set(&scope.visible, key, Arc::clone(&through)); + Some(through) + } + + /// Whether `name`, declared at the top level of `decl`, is what `from` + /// writes as `name` (no `prefix`) or `prefix.name`: upstream + /// `dartLibrarySees` and `dartPrefixSees`. + pub fn sees( + &self, + from: &str, + prefix: Option<&str>, + decl: &str, + name: &str, + context: &dyn ResolutionContext, + ) -> bool { + if prefix.is_none() && from == decl { + return true; + } + let Some(scope) = self.scope_of(from, context) else { + return true; + }; + let Some(prefix) = prefix else { + if scope.own.contains(decl) { + return true; + } + if name.starts_with('_') { + return false; + } + return self + .imported_filters(&scope, "", decl, context) + .is_some_and(|filters| filters.iter().any(|filter| filter.lets(name))); + }; + match self.imported_filters(&scope, prefix, decl, context) { + None => true, + Some(filters) => { + !name.starts_with('_') && filters.iter().any(|filter| filter.lets(name)) + } + } + } + + /// How `from` can name `name` declared at the top level of `decl`: + /// whether without a prefix, then through each import prefix of its + /// scope in order (W4-DART-02's sync compares it before and after). + pub fn reach_of( + &self, + from: &str, + decl: &str, + name: &str, + context: &dyn ResolutionContext, + ) -> Vec<(String, bool)> { + let mut out = vec![(String::new(), self.sees(from, None, decl, name, context))]; + if let Some(scope) = self.scope_of(from, context) { + // `imported_filters` reads the scope's imports on first use. + let _ = self.imported_filters(&scope, "", decl, context); + let prefixes: Vec = scope + .imported + .get() + .map(|imported| { + imported + .keys() + .filter(|prefix| !prefix.is_empty()) + .cloned() + .collect() + }) + .unwrap_or_default(); + for prefix in prefixes { + let seen = self.sees(from, Some(&prefix), decl, name, context); + out.push((prefix, seen)); + } + } + out + } + + /// The parent a part's `part of` named, once [`Self::library_of`] read it. + fn parent_of(&self, file: &str) -> Option { + memo_get(&self.parent_of, &file.to_string()) + } + + /// What `file`'s resolution reads of the model beyond its library's own + /// files and the namespaces of the libraries it imports. + pub fn structure_of(&self, file: &str, context: &dyn ResolutionContext) -> FileStructure { + let targets = self + .directives_of(file, context) + .map(|d| { + let part_of = match &d.part_of { + Some(PartOf::Uri(uri)) => vec![uri.clone()], + _ => Vec::new(), + }; + d.imports + .iter() + .chain(&d.exports) + .flat_map(|directive| directive.uris.clone()) + .chain(d.parts.iter().cloned()) + .chain(part_of) + .map(|uri| self.resolve_dart_uri(file, &uri, context)) + .collect() + }) + .unwrap_or_default(); + let Some(scope) = self.scope_of(file, context) else { + return FileStructure { + scope: None, + imported: BTreeMap::new(), + targets, + }; + }; + let library = self.library_of(file, context).unwrap_or_default(); + // The imports `imported_filters` reads, an unprefixed one naming no + // library dropped: it brings nothing in. + let mut imported: BTreeMap> = BTreeMap::new(); + for importer in &scope.importers { + let Some(directives) = self.directives_of(importer, context) else { + continue; + }; + for directive in &directives.imports { + let prefix = directive.prefix.clone().unwrap_or_default(); + let libraries: Vec = directive + .uris + .iter() + .flat_map(|uri| self.resolve_dart_uri(importer, uri, context)) + .filter_map(|target| self.library_of(&target, context)) + .collect(); + if prefix.is_empty() && libraries.is_empty() { + continue; + } + let entry = imported.entry(prefix).or_default(); + entry.extend(libraries.into_iter().map(|l| (directive.filter.clone(), l))); + } + } + for libraries in imported.values_mut() { + libraries.sort(); + libraries.dedup(); + } + FileStructure { + scope: Some((library, scope.importers.clone())), + imported, + targets, + } + } + + /// The files whose public names `library` exports, with the filters on + /// the way, each file's filters sorted. + pub fn namespace_of(&self, library: &str, context: &dyn ResolutionContext) -> Namespace { + let walk = self.exports_of(library, context, &mut Vec::new()); + walk.namespace + .iter() + .map(|(file, filters)| { + let mut filters = filters.clone(); + filters.sort(); + (file.clone(), filters) + }) + .collect() + } + + /// The import prefixes written in `file` can stand for — the `p` of + /// `import '…' as p` and `deferred as p` — read from the imports of its + /// library, which a part takes from the file it is part of (upstream + /// #2389 `dartImportPrefixes`). + pub fn import_prefixes_in( + &self, + file: &str, + context: &dyn ResolutionContext, + ) -> Arc> { + if let Some(hit) = memo_get(&self.prefixes, &file.to_string()) { + return hit; + } + // Records each part's parent, a library in no package included. + self.library_of(file, context); + let mut prefixes = BTreeSet::new(); + let mut current = Some(file.to_string()); + for _ in 0..32 { + let Some(at) = current else { + break; + }; + if let Some(directives) = self.directives_of(&at, context) { + prefixes.extend( + directives + .imports + .iter() + .filter_map(|import| import.prefix.clone()), + ); + } + current = self.parent_of(&at); + } + let prefixes = Arc::new(prefixes); + memo_set(&self.prefixes, file.to_string(), Arc::clone(&prefixes)); + prefixes + } + + /// The library `file` belongs to, if known. + pub fn library_of_file(&self, file: &str, context: &dyn ResolutionContext) -> Option { + self.library_of(file, context) + } +} + +/// Add `filter` to `file`'s filters (upstream `addFilter`): every name +/// absorbs the rest, and equal filters are kept once. +fn add_filter(namespace: &mut Namespace, file: &str, filter: NameFilter) { + push_filter(namespace.entry(file.to_string()).or_default(), filter); +} + +fn push_filter(filters: &mut Vec, filter: NameFilter) { + if filters.first().is_some_and(NameFilter::is_every) { + return; + } + if filter.is_every() { + filters.clear(); + filters.push(filter); + } else if !filters.contains(&filter) { + filters.push(filter); + } +} + +/// The project file a Dart `import`, `export` or `part` in `from` names by +/// `uri`, or `None`: a `dart:` library, a package from outside the project, +/// a path out of it, or a package name the project repeats with nothing to +/// say which (upstream `dartDirectiveFile`). +pub fn dart_directive_file( + from: &str, + uri: &str, + context: &dyn ResolutionContext, +) -> Option { + let mut files = context + .dart_libraries() + .resolve_dart_uri(from, uri, context); + (files.len() == 1).then(|| files.remove(0)) +} + +/// Whether the library of `from` can name `name`, declared at the top level +/// of `decl`, without a prefix (upstream `dartLibrarySees`): `decl` is one of +/// the library's own files, or a library it imports unprefixed exports the +/// name. True when the library of `from` cannot be established. +pub fn dart_library_sees( + from: &str, + decl: &str, + name: &str, + context: &dyn ResolutionContext, +) -> bool { + context + .dart_libraries() + .sees(from, None, decl, name, context) +} + +/// Whether `prefix.name` in `from` can mean `name` declared at the top level +/// of `decl` (upstream `dartPrefixSees`): a library imported `as prefix` +/// exports it. True when no import of the file's library has that prefix, +/// or the library is unknown. +pub fn dart_prefix_sees( + from: &str, + prefix: &str, + decl: &str, + name: &str, + context: &dyn ResolutionContext, +) -> bool { + context + .dart_libraries() + .sees(from, Some(prefix), decl, name, context) +} + +/// Whether `decl` is a file of the library `from` belongs to (upstream +/// `inSameDartLibrary`; false when that library is unknown). +pub fn in_same_dart_library(from: &str, decl: &str, context: &dyn ResolutionContext) -> bool { + if from == decl { + return true; + } + context + .dart_libraries() + .scope_of(from, context) + .is_some_and(|scope| scope.own.contains(decl)) +} + +/// The `project_metadata` key prefix of the Dart library rows (W4-DART-02). +pub const LIBRARY_ROW_KEY_PREFIX: &str = "dart_library:"; + +/// The `project_metadata` key of `file_path`'s Dart library row. +pub fn library_row_key(file_path: &str) -> String { + format!("{LIBRARY_ROW_KEY_PREFIX}{file_path}") +} + +/// An `import` or `export` as a library row records it. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DirectiveRow { + pub uris: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hide: Option>, +} + +impl DirectiveRow { + fn of(directive: &NamespaceDirective) -> Self { + let names = + |set: &Option>| set.as_ref().map(|set| set.iter().cloned().collect()); + Self { + uris: directive.uris.clone(), + prefix: directive.prefix.clone(), + show: names(&directive.filter.show), + hide: names(&directive.filter.hide), + } + } +} + +/// What a sync keeps of a Dart file's directives, read by the resolver's own +/// reader ([`read_dart_directives`]) from the file's text (W4-DART-02): the +/// `library` name, the `part of` target, the `part`s, and the imports and +/// exports with their prefixes and combinators, in source order. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LibraryRow { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub part_of_uri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub part_of_name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub imports: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exports: Vec, +} + +impl LibraryRow { + /// The row of a Dart file's text. + pub fn of(source: &str) -> Self { + let directives = read_dart_directives(source); + let (part_of_uri, part_of_name) = match directives.part_of { + Some(PartOf::Uri(uri)) => (Some(uri), None), + Some(PartOf::Name(name)) => (None, Some(name)), + None => (None, None), + }; + Self { + library: directives.library_name, + part_of_uri, + part_of_name, + parts: directives.parts, + imports: directives.imports.iter().map(DirectiveRow::of).collect(), + exports: directives.exports.iter().map(DirectiveRow::of).collect(), + } + } + + /// The directives this row records, as the reader returned them. + pub fn directives(&self) -> DartDirectives { + let directive = |row: &DirectiveRow| NamespaceDirective { + uris: row.uris.clone(), + prefix: row.prefix.clone(), + filter: NameFilter::new( + row.show + .as_ref() + .map(|names| names.iter().cloned().collect()), + row.hide + .as_ref() + .map(|names| names.iter().cloned().collect()), + ), + }; + DartDirectives { + library_name: self.library.clone(), + part_of: match (&self.part_of_uri, &self.part_of_name) { + (Some(uri), _) => Some(PartOf::Uri(uri.clone())), + (None, Some(name)) => Some(PartOf::Name(name.clone())), + (None, None) => None, + }, + parts: self.parts.clone(), + imports: self.imports.iter().map(directive).collect(), + exports: self.exports.iter().map(directive).collect(), + } + } + + /// Whether the file has no directive at all. + pub fn is_empty(&self) -> bool { + *self == Self::default() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("dart library row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +/// What a Dart library declares at its top level (upstream +/// `isDartLibraryDecl`): a function, a type, a typedef, a top-level `final` / +/// `const`. The extractor files mixins and extensions as classes. +pub fn is_dart_library_decl(node: &Node) -> bool { + node.language == Language::Dart + && matches!( + node.kind, + NodeKind::Function + | NodeKind::Class + | NodeKind::Enum + | NodeKind::TypeAlias + | NodeKind::Constant + | NodeKind::Variable + | NodeKind::Interface + | NodeKind::Struct + | NodeKind::Trait + ) + && !node.qualified_name.contains("::") +} + +/// A value a Dart type holds — an enum constant, a `static const` / `final` +/// (upstream `isDartTypeValue`) — which a bare name reaches only from inside +/// the type, as its methods: bloc's brick hook calls `info(…)` on mason's +/// `Logger`, and the call went to bloc_lint's enum constant +/// `LinterRuleState.info`. +pub(crate) fn is_dart_type_value(node: &Node) -> bool { + node.language == Language::Dart + && matches!( + node.kind, + NodeKind::EnumMember + | NodeKind::Variable + | NodeKind::Constant + | NodeKind::Field + | NodeKind::Property + ) + && node.qualified_name.contains("::") +} + +/// A Dart type name — a declared type, a supertype, the `Color` of +/// `Color.red` — which is in scope as a call is, or through its import +/// prefix (upstream #2386's `dartTypeRef`). +pub(crate) fn is_dart_type_ref(reference: &RefView) -> bool { + reference.language == Language::Dart + && (reference.reference_kind == EdgeKind::References + || crate::types::is_inheritance_ref(reference.reference_kind)) + && crate::dart_scope::is_dart_identifier(&reference.reference_name) +} + +/// The import prefix a Dart type name is written through — the `p` of +/// `p.Report`, `p.Color.red` — or `None` (upstream `dartImportPrefixOf`). A +/// type reference's column is the name's start; on a line where that misses, +/// the nearest whole-word occurrence stands in. +pub(crate) fn dart_import_prefix_of( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let line = crate::dart_scope::source_line(&reference.file_path, reference.line, context)?; + let name = reference.reference_name.as_str(); + let is_word = |at: usize| { + line.get(at..).is_some_and(|rest| rest.starts_with(name)) + && !line[..at] + .chars() + .next_back() + .is_some_and(crate::dart_scope::is_dart_name_char) + && !line[at + name.len()..] + .chars() + .next() + .is_some_and(crate::dart_scope::is_dart_name_char) + }; + let column = usize::try_from(reference.column).ok(); + let start = column.filter(|at| is_word(*at)).or_else(|| { + line.match_indices(name) + .map(|(at, _)| at) + .find(|at| is_word(*at)) + })?; + let before = line[..start].trim_end_matches(crate::dart_scope::is_js_space); + let before = before + .strip_suffix('.')? + .trim_end_matches(crate::dart_scope::is_js_space); + let run = before.len() + - before + .trim_end_matches(crate::dart_scope::is_dart_name_char) + .len(); + let word = &before[before.len() - run..]; + let word = word.trim_start_matches(|c: char| c.is_ascii_digit()); + (!word.is_empty()).then(|| word.to_string()) +} + +/// The import prefixes written in `file` can stand for, its library's +/// ([`DartLibraries::import_prefixes_in`]). +pub(crate) fn dart_import_prefixes( + file: &str, + context: &dyn ResolutionContext, +) -> Arc> { + context.dart_libraries().import_prefixes_in(file, context) +} + +/// Whether a Dart name written without a receiver — a call, a type — can mean +/// `candidate` when it is declared at the top level of a library (upstream +/// `isDartTopLevelVisible`): the caller's own library declares it, or a +/// library the caller imports without a prefix exports it, `show` / `hide` +/// and `export` chains followed. A name written through an import prefix +/// (`p.Report`) means only what the imports with that prefix export. +/// riverpod's generated `async.g.dart` (`part of 'async.dart'`) calls the +/// `family(…)` async.dart declares, and the call went to annotated.dart's +/// `family` beside it. +pub(crate) fn is_dart_top_level_visible( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, + prefix: Option<&str>, +) -> bool { + if !is_dart_library_decl(candidate) { + return true; + } + match prefix { + None => dart_library_sees( + &reference.file_path, + &candidate.file_path, + &candidate.name, + context, + ), + Some(prefix) => dart_prefix_sees( + &reference.file_path, + prefix, + &candidate.file_path, + &candidate.name, + context, + ), + } +} + +/// A library's own top-level declaration shadows every import of its name +/// (upstream `preferOwnDartLibrary`), so when the caller's library declares +/// one, the imported namesakes are no candidates. (A function nested in +/// another body is not the library's.) +pub(crate) fn prefer_own_dart_library( + candidates: Vec>, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Vec> { + if candidates + .iter() + .filter(|node| is_dart_library_decl(node)) + .count() + < 2 + { + return candidates; + } + let own: HashSet = candidates + .iter() + .filter(|node| { + is_dart_library_decl(node) + && in_same_dart_library(&reference.file_path, &node.file_path, context) + && crate::name_matcher::is_lexically_reachable(node, reference, context) + }) + .map(|node| node.id.clone()) + .collect(); + if own.is_empty() { + return candidates; + } + candidates + .into_iter() + .filter(|node| !is_dart_library_decl(node) || own.contains(&node.id)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uris(directives: &[NamespaceDirective]) -> Vec> { + directives.iter().map(|d| d.uris.clone()).collect() + } + + fn set(names: &[&str]) -> BTreeSet { + names.iter().map(|name| name.to_string()).collect() + } + + /// Upstream's `readDartDirectives` cases. + #[test] + fn reads_every_directive_up_to_the_first_declaration() { + let d = read_dart_directives( + "// Copyright\n@TestOn('vm')\nlibrary app.main;\n\nimport 'dart:async';\nimport 'package:kit/kit.dart' show A, B hide B;\nimport 'stub.dart' if (dart.library.io) 'io.dart' if (dart.library.js_interop == 'true') 'web.dart';\nimport 'lazy.dart' deferred as lazy;\nexport 'src/a.dart'\n show\n // one\n One,\n Two;\npart 'main.g.dart';\n\nvoid main() {}\nimport 'late.dart';\n", + ); + assert_eq!(d.library_name.as_deref(), Some("app.main")); + assert_eq!(d.part_of, None); + assert_eq!(d.parts, vec!["main.g.dart".to_string()]); + assert_eq!( + uris(&d.imports), + vec![ + vec!["dart:async".to_string()], + vec!["package:kit/kit.dart".to_string()], + vec![ + "stub.dart".to_string(), + "io.dart".to_string(), + "web.dart".to_string() + ], + vec!["lazy.dart".to_string()], + ] + ); + assert_eq!( + d.imports + .iter() + .map(|i| i.prefix.clone()) + .collect::>(), + vec![None, None, None, Some("lazy".to_string())] + ); + assert_eq!(d.imports[1].filter.show, Some(set(&["A", "B"]))); + assert_eq!(d.imports[1].filter.hide, Some(set(&["B"]))); + assert_eq!(uris(&d.exports), vec![vec!["src/a.dart".to_string()]]); + assert_eq!(d.exports[0].filter.show, Some(set(&["One", "Two"]))); + } + + #[test] + fn reads_part_of_by_uri_and_by_library_name_and_an_unnamed_library() { + assert_eq!( + read_dart_directives("part of 'async.dart';\n").part_of, + Some(PartOf::Uri("async.dart".to_string())) + ); + assert_eq!( + read_dart_directives("part of app.named;\n").part_of, + Some(PartOf::Name("app.named".to_string())) + ); + let unnamed = read_dart_directives("/// Docs\nlibrary;\n\nimport 'a.dart';\n"); + assert_eq!(unnamed.library_name.as_deref(), Some("")); + assert_eq!(uris(&unnamed.imports), vec![vec!["a.dart".to_string()]]); + } + + #[test] + fn stops_at_a_declaration_that_looks_like_a_directive() { + let d = read_dart_directives("import 'a.dart';\npart() => 1;\nimport 'b.dart';\n"); + assert_eq!(uris(&d.imports), vec![vec!["a.dart".to_string()]]); + } + + /// The readers past upstream's cases: a BOM and a script tag, nested + /// block comments, raw, triple-quoted and adjacent strings, and a + /// `${…}` in an annotation's argument. + #[test] + fn reads_past_the_head_shapes_dart_allows() { + let d = read_dart_directives( + "\u{feff}#!/usr/bin/env dart\n/* a /* nested */ comment */\n@Annotation('${\"x\"}')\nimport r'raw.dart';\nimport '''triple.dart''';\nimport 'package:a/' 'b.dart' as ab;\n", + ); + assert_eq!( + uris(&d.imports), + vec![ + vec!["raw.dart".to_string()], + vec!["triple.dart".to_string()], + vec!["package:a/b.dart".to_string()], + ] + ); + assert_eq!(d.imports[2].prefix.as_deref(), Some("ab")); + } + + #[test] + fn filters_compose_as_dart_combinators_do() { + let show_ab = NameFilter::new(Some(set(&["A", "B"])), None); + let hide_b = NameFilter::new(None, Some(set(&["B"]))); + let both = show_ab.then(&hide_b); + assert!(both.lets("A")); + assert!(!both.lets("B")); + assert!(!both.lets("C")); + assert_eq!(NameFilter::EVERY.then(&hide_b), hide_b); + assert_eq!( + NameFilter::new(None, Some(BTreeSet::new())), + NameFilter::EVERY + ); + let show_b = NameFilter::new(Some(set(&["B", "C"])), None); + assert_eq!(show_ab.then(&show_b).show, Some(set(&["B"]))); + } + + /// Upstream #2388's pubspec shapes: a block dependency with a comment, a + /// flow mapping, and a git dependency's own `path:` that is no project + /// directory. + #[test] + fn reads_a_dependencys_path_from_block_and_flow_yaml() { + let block = "name: login_a\ndependencies:\n auth_repo:\n # the app's own copy\n path: packages/auth_repo\n bloc: ^9.0.0\n"; + assert_eq!( + yaml_path_dependencies(block, "auth_repo"), + vec!["packages/auth_repo".to_string()] + ); + let flow = "name: login_b\ndependencies:\n auth_repo: { path: \"packages/auth_repo\" }\n"; + assert_eq!( + yaml_path_dependencies(flow, "auth_repo"), + vec!["packages/auth_repo".to_string()] + ); + let git = "dependencies:\n auth_repo:\n git:\n url: https://example.com/x.git\n path: auth_repo\n"; + assert_eq!( + yaml_path_dependencies(git, "auth_repo"), + Vec::::new() + ); + let quoted = "dependencies:\n 'auth_repo':\n path: '../shared/auth_repo' # local\n"; + assert_eq!( + yaml_path_dependencies(quoted, "auth_repo"), + vec!["../shared/auth_repo".to_string()] + ); + assert_eq!(yaml_path_dependencies(block, "bloc"), Vec::::new()); + } + + #[test] + fn a_scheme_names_no_project_file() { + assert!(has_scheme("dart:async")); + assert!(has_scheme("http://example.com/a.dart")); + assert!(has_scheme("x+y.z-w:path")); + assert!(!has_scheme("src/a.dart")); + assert!(!has_scheme("../a.dart")); + assert!(!has_scheme(":a")); + assert_eq!(contained("lib/../../x.dart"), None); + assert_eq!( + contained("lib/./a/../b.dart").as_deref(), + Some("lib/b.dart") + ); + } + + #[test] + fn a_pubspec_names_its_package() { + assert_eq!(pubspec_package_name("name: app\n").as_deref(), Some("app")); + assert_eq!( + pubspec_package_name("# x\nname: \"kit_2\"\nversion: 1.0.0\n").as_deref(), + Some("kit_2") + ); + assert_eq!(pubspec_package_name(" name: nested\n"), None); + } +} diff --git a/crates/codegraph-resolve/src/dart_local_scope.rs b/crates/codegraph-resolve/src/dart_local_scope.rs new file mode 100644 index 00000000..3ae998e5 --- /dev/null +++ b/crates/codegraph-resolve/src/dart_local_scope.rs @@ -0,0 +1,1188 @@ +//! A name a Dart function, closure or block around a call declares — a +//! parameter, a local variable, a loop or `catch` variable, a pattern +//! variable — is what a bare call of that name means there (upstream #2379 +//! `dart-local-scope.ts`). +//! +//! riverpod's `overrideWith(CreatedT Function(Ref ref, ArgT arg) create)` +//! calls its parameter, `create(ref, arg)`, and a bloc event handler's +//! `emit(…)` calls its `Emitter` parameter, never `Bloc.emit`. The index holds +//! no node for either, so such a call, or a function passed by name, links +//! nothing, whichever strategy chose a namesake. Read from the code of the +//! call's file: +//! +//! - a parameter list that a body follows (`{ … }` or `=> …`), the body being +//! its scope: a function's, a method's, a closure's, a `catch` clause's, a +//! constructor's (its initializer list too); +//! - the variables a `for` head declares, for the loop; +//! - a local a block declares (`final x = …`, `Foo? x;`, `var (a, b) = …`, a +//! pattern's `final x`), from there to the block's end. +//! +//! Fields and top-level variables are not locals. The nearest binding around +//! the call decides: a local function declared there is a node, and the call +//! keeps what it resolved to. +//! +//! KEEP-RUST: offsets are bytes of the file (upstream's are UTF-16 code +//! units), and a Dart reference's column is where its name starts. + +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use codegraph_core::types::{EdgeKind, Language}; +use regex::Regex; + +use crate::csharp_types::js_regex; +use crate::dart_scope::{is_dart_identifier, is_dart_name_char, is_js_space}; +use crate::types::{RefView, ResolutionContext, ResolvedRef}; + +/// Where a name is bound: byte offsets into the file's code, inclusive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Scope { + start: usize, + end: usize, + /// A local function's name, which a call can reach. + function: bool, +} + +/// Every parameter, loop variable and local a Dart file binds, with its +/// scope. +#[derive(Debug, Default)] +pub struct DartLocalScopes { + names: HashMap>, +} + +impl DartLocalScopes { + /// The scopes of the Dart file whose code, comments and string contents + /// blanked ([`crate::dart_chains::blank_dart_code`]), is `code`. + pub fn of(code: &str) -> Self { + let code = blank_type_arguments(code); + let brackets = match_brackets(code.as_bytes()); + let mut scopes = Self::default(); + let parameter_lists = bind_parameters(&code, &brackets, &mut scopes); + bind_locals(&code, &brackets, ¶meter_lists, &mut scopes); + scopes + } + + fn bind(&mut self, name: &str, start: usize, end: usize, function: bool) { + if name == "_" || is_reserved(name) { + return; + } + self.names.entry(name.to_string()).or_default().push(Scope { + start, + end, + function, + }); + } + + /// The nearest binding of `name` around byte offset `at`: scopes nest, so + /// the one that starts last. + fn nearest(&self, name: &str, at: usize) -> Option { + self.names + .get(name)? + .iter() + .filter(|scope| scope.start <= at && at <= scope.end) + .fold(None, |nearest: Option, scope| match nearest { + Some(nearest) if nearest.start >= scope.start => Some(nearest), + _ => Some(*scope), + }) + } +} + +/// Whether a parameter, local or local function `name` is bound at byte +/// offset `at` of the Dart file `file`: where it hides a library name, an +/// import prefix among them — `http.get(…)` with a parameter `http` calls the +/// parameter's (upstream #2389 `isDartLocallyBound`). +pub(crate) fn is_dart_locally_bound( + file: &str, + name: &str, + at: usize, + context: &dyn ResolutionContext, +) -> bool { + context + .source_facts(file) + .is_some_and(|facts| facts.dart_local_scopes().nearest(name, at).is_some()) +} + +/// The resolved reference, or `None` when it is a receiver-less Dart call +/// (or a function passed by name) whose name a parameter or local around it +/// binds (upstream `gateDartLocal`). +pub(crate) fn gate_dart_local( + resolved: Option, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let result = resolved?; + if reference.language != Language::Dart + || !(reference.reference_kind == EdgeKind::Calls + || (reference.reference_kind == EdgeKind::References && reference.is_function_ref)) + || !is_dart_identifier(&reference.reference_name) + { + return Some(result); + } + match dart_local_binding(reference, context) { + Some(scope) if !scope.function => None, + _ => Some(result), + } +} + +/// The nearest binding of a reference's name around where it is written, when +/// it is written without a receiver. +fn dart_local_binding(reference: &RefView, context: &dyn ResolutionContext) -> Option { + let name = reference.reference_name.as_str(); + let facts = context.source_facts(&reference.file_path)?; + let scopes = facts.dart_local_scopes(); + if !scopes.names.contains_key(name) { + return None; + } + // KEEP-RUST: the extractor records a chain's later links where the chain + // starts, so whether a call has a receiver (`this.create()`, `x.create()`) + // is read as the bare-call rules read it (W4-DART-01, W4-DART-03b), where + // upstream reads a `.` before the name, on its line or ending the line + // before. A spread's `...create()` is a call without one, as those rules + // and upstream's `isReceiverLessDartCall` read it. A function value is + // recorded at its name, and one written after a `.` with its receiver. + if reference.reference_kind == EdgeKind::Calls + && !crate::dart_scope::is_receiver_less_dart_call(reference, context) + { + return None; + } + let index = usize::try_from(reference.line - 1).ok()?; + let lines = facts.lines(); + let line = lines.raw_line(facts.source(), index)?; + let column = name_column(line, name, reference.column)?; + scopes.nearest(name, lines.raw_line_start(index)? + column) +} + +/// Where on its line a reference's name starts: at its column, or just +/// before it (upstream's column sits past the name), else the nearest +/// whole-word occurrence. +fn name_column(line: &str, name: &str, column: i64) -> Option { + let bytes = line.as_bytes(); + let is_word = |at: usize| { + line.get(at..).is_some_and(|rest| rest.starts_with(name)) + && !(at > 0 && is_name_byte(bytes[at - 1])) + && !bytes.get(at + name.len()).is_some_and(|b| is_name_byte(*b)) + }; + if let Ok(at) = usize::try_from(column) { + if is_word(at) { + return Some(at); + } + if at >= name.len() && is_word(at - name.len()) { + return Some(at - name.len()); + } + } + let distance = |at: usize| (i64::try_from(at).unwrap_or(i64::MAX) - column).abs(); + line.match_indices(name) + .map(|(at, _)| at) + .filter(|at| is_word(*at)) + .fold(None, |best: Option, at| match best { + Some(best) if distance(best) <= distance(at) => Some(best), + _ => Some(at), + }) +} + +/// `[\w$]` for a byte. +fn is_name_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$' +} + +/// `[A-Za-z_$]` for a byte: where an identifier can start. +fn is_name_start(byte: u8) -> bool { + byte.is_ascii_alphabetic() || byte == b'_' || byte == b'$' +} + +/// Keywords that head a parenthesised expression, not a parameter list. +const CONTROL_HEADS: &[&str] = &["if", "while", "switch"]; + +/// Keywords a declaration's name can follow. +const DECLARATION_KEYWORDS: &[&str] = &["final", "var", "const"]; + +/// Dart's reserved words: never a name a declaration binds. +const DART_RESERVED: &[&str] = &[ + "assert", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", + "enum", "extends", "false", "final", "finally", "for", "if", "in", "is", "new", "null", + "rethrow", "return", "super", "switch", "this", "throw", "true", "try", "var", "void", "while", + "with", +]; + +/// The built-in and contextual words that, with the reserved ones, never +/// name a declaration's type or a function there. Several are fine parameter +/// names (`R Function(DartType item) set`), so only [`DART_RESERVED`] keeps a +/// name from binding. +const DART_CONTEXTUAL: &[&str] = &[ + "abstract", + "as", + "async", + "await", + "base", + "covariant", + "deferred", + "export", + "extension", + "external", + "factory", + "get", + "hide", + "implements", + "import", + "interface", + "late", + "library", + "mixin", + "of", + "on", + "operator", + "part", + "required", + "sealed", + "set", + "show", + "static", + "sync", + "typedef", + "when", + "yield", +]; + +fn is_reserved(word: &str) -> bool { + DART_RESERVED.contains(&word) +} + +/// Upstream `DART_KEYWORDS`: the reserved words and the contextual ones. +fn is_keyword(word: &str) -> bool { + is_reserved(word) || DART_CONTEXTUAL.contains(&word) +} + +// ---------------------------------------------------------------- tokens + +/// The word right before an offset (blanks skipped) and where it starts, or +/// else no word and where the character there starts (`None` at the start). +#[derive(Debug, Clone, Copy)] +struct Token<'c> { + word: Option<&'c str>, + at: Option, +} + +/// The character ending right before byte `at`, with where it starts. +fn char_before(code: &str, at: usize) -> Option<(usize, char)> { + code.get(..at)?.char_indices().next_back() +} + +fn token_before(code: &str, at: usize) -> Token<'_> { + let mut end = at; + while let Some((start, c)) = char_before(code, end) { + if !is_js_space(c) { + break; + } + end = start; + } + let mut start = end; + while let Some((at, c)) = char_before(code, start) { + if !is_dart_name_char(c) { + break; + } + start = at; + } + if start == end { + return Token { + word: None, + at: char_before(code, end).map(|(at, _)| at), + }; + } + Token { + word: Some(&code[start..end]), + at: Some(start), + } +} + +/// The character starting at `at`, when there is one. +fn char_at(code: &str, at: Option) -> Option { + code.get(at?..)?.chars().next() +} + +fn skip_space(code: &str, at: usize) -> usize { + code.get(at..).map_or(at, |rest| { + at + rest.len() - rest.trim_start_matches(is_js_space).len() + }) +} + +/// The end of the identifier starting at byte `at` (`[A-Za-z_$][\w$]*`). +fn identifier_end(bytes: &[u8], at: usize) -> Option { + if !is_name_start(*bytes.get(at)?) { + return None; + } + Some(at + bytes[at..].iter().take_while(|b| is_name_byte(**b)).count()) +} + +// ---------------------------------------------------------------- brackets + +/// Every bracket of the code, in order, with its partner and the open +/// bracket around it (indexes into `at`). +struct Brackets { + at: Vec, + partner: Vec>, + parent: Vec>, +} + +/// Pairs `()`, `[]` and `{}`; a stray close is left unpaired, and so are the +/// opens a close skips past. +fn match_brackets(code: &[u8]) -> Brackets { + let (mut at, mut partner, mut parent) = (Vec::new(), Vec::new(), Vec::new()); + let mut stack: Vec = Vec::new(); + for (i, ch) in code.iter().enumerate() { + let want = match ch { + b'(' | b'[' | b'{' => { + at.push(i); + partner.push(None); + parent.push(stack.last().copied()); + stack.push(at.len() - 1); + continue; + } + b')' => b'(', + b']' => b'[', + b'}' => b'{', + _ => continue, + }; + let open = stack.iter().rposition(|k| code[at[*k]] == want); + at.push(i); + partner.push(None); + let index = at.len() - 1; + match open { + None => parent.push(stack.last().copied()), + Some(s) => { + let open = stack[s]; + stack.truncate(s); + partner[open] = Some(index); + partner[index] = Some(open); + parent.push(parent[open]); + } + } + } + Brackets { + at, + partner, + parent, + } +} + +impl Brackets { + /// The index of the first bracket at or after byte `from`. + fn first_from(&self, from: usize) -> usize { + self.at.partition_point(|at| *at < from) + } + + /// The index of the bracket at byte `at`. + fn index_at(&self, at: usize) -> Option { + let k = self.first_from(at); + (self.at.get(k) == Some(&at)).then_some(k) + } + + /// The index of the innermost open bracket around byte `at`. + fn innermost(&self, code: &[u8], at: usize) -> Option { + let k = self.first_from(at).checked_sub(1)?; + if matches!(code[self.at[k]], b'(' | b'[' | b'{') { + Some(k) + } else { + self.parent[k] + } + } + + /// Where the bracket at index `k` closes: the end of the code when it + /// never does. + fn end(&self, code_len: usize, k: usize) -> usize { + self.partner[k].map_or(code_len, |close| self.at[close]) + } +} + +// ---------------------------------------------------------------- parameters + +/// Each parameter list a body follows, the body being where its names are +/// bound, and a `for` head's variables, for the loop. A group a control-flow +/// keyword heads (`if (create) {`) holds an expression, and a call's +/// arguments (`: super(x) {`, `: _f = make(x) {` in an initializer list) bind +/// nothing. A function declared in a block binds its own name there, to the +/// block's end. Returns where the groups read as parameter lists open. +fn bind_parameters(code: &str, b: &Brackets, scopes: &mut DartLocalScopes) -> HashSet { + let bytes = code.as_bytes(); + let mut lists = HashSet::new(); + for k in 0..b.at.len() { + let Some(open) = b.partner[k] else { + continue; + }; + if bytes[b.at[k]] != b')' { + continue; + } + let from = b.at[open]; + let head = token_before(code, from); + let mut i = skip_space(code, b.at[k] + 1); + if let Some(modifier) = body_modifier(&code[i..]) { + i = skip_space(code, i + modifier); + } + let list = &code[from + 1..b.at[k]]; + let block = if bytes.get(i) == Some(&b'{') && b.at.get(k + 1) == Some(&i) { + b.partner[k + 1].map(|close| b.at[close]) + } else { + None + }; + if head.word == Some("for") { + let end = block.unwrap_or_else(|| end_of_statement(code, b, i, false)); + for name in for_head_names(list) { + scopes.bind(&name, from, end, false); + } + lists.insert(from); + continue; + } + if head.word.is_some_and(|word| CONTROL_HEADS.contains(&word)) { + continue; + } + let scope = if let Some(end) = block { + Some((i, end)) + } else if code[i..].starts_with("=>") { + Some((i, end_of_statement(code, b, i + 2, true))) + } else if bytes.get(i) == Some(&b':') && is_constructor_head(code, head) { + Some((i, end_of_constructor(code, b, i + 1))) + } else { + None + }; + let Some((start, end)) = scope else { + continue; + }; + if is_call_arguments(code, head) { + continue; + } + lists.insert(from); + for name in parameter_names(list) { + scopes.bind(&name, start, end, false); + } + // `int make() => 2;` in a block: a local function, a node of its own. + if let (Some(word), Some(word_at)) = (head.word, head.at) + && !is_keyword(word) + && let Some(around) = enclosing_block(code, b, word_at) + { + scopes.bind(word, word_at, b.end(code.len(), around), true); + } + } + lists +} + +/// The length of a body's `async`, `async*` or `sync*` modifier at the start +/// of `text` (`^(?:async\s*\*?|sync\s*\*)` over its next eight characters). +fn body_modifier(text: &str) -> Option { + let window = text + .char_indices() + .nth(8) + .map_or(text, |(at, _)| &text[..at]); + let spaces = |rest: &str| rest.len() - rest.trim_start_matches(is_js_space).len(); + if let Some(rest) = window.strip_prefix("async") { + let blank = spaces(rest); + let star = usize::from(rest[blank..].starts_with('*')); + return Some("async".len() + blank + star); + } + let rest = window.strip_prefix("sync")?; + let blank = spaces(rest); + rest[blank..] + .starts_with('*') + .then_some("sync".len() + blank + 1) +} + +/// Whether a group is a call's arguments rather than a declaration's +/// parameters: a call in a constructor's initializer list (`: _value = +/// compute(x) {`, `: super.named(x) {`, `: assert(x) {`). +fn is_call_arguments(code: &str, head: Token<'_>) -> bool { + let (Some(word), Some(word_at)) = (head.word, head.at) else { + return false; + }; + if matches!(word, "super" | "this" | "assert") { + return true; + } + let before = token_before(code, word_at); + if before.word.is_some() { + return false; + } + match char_at(code, before.at) { + Some('=' | ':' | ',') => true, + // `super.named(x)` / `this.named(x)`, never a named constructor + // `Foo.named(x)`. + Some('.') => before + .at + .is_some_and(|dot| matches!(token_before(code, dot).word, Some("super" | "this"))), + _ => false, + } +} + +/// Whether a group followed by `:` is a constructor's parameter list — +/// `Foo(this.x) :`, `const Foo.named(int x) :` at the start of a member — and +/// not a call in a conditional (`c ? f(x) : y`) or a `case Foo(x):`. +fn is_constructor_head(code: &str, head: Token<'_>) -> bool { + let (Some(word), Some(word_at)) = (head.word, head.at) else { + return false; + }; + if is_keyword(word) { + return false; + } + let mut type_name = word; + let mut before = token_before(code, word_at); + if before.word.is_none() + && let Some(dot) = before.at + && code.as_bytes()[dot] == b'.' + { + let owner = token_before(code, dot); + let (Some(owner_word), Some(owner_at)) = (owner.word, owner.at) else { + return false; + }; + type_name = owner_word; + before = token_before(code, owner_at); + } + if !type_name + .trim_start_matches('_') + .starts_with(|c: char| c.is_ascii_uppercase() || c == '$') + { + return false; + } + if let Some(word) = before.word { + return matches!(word, "const" | "external"); + } + before + .at + .is_none_or(|at| matches!(code.as_bytes()[at], b';' | b'{' | b'}')) +} + +/// The end of a constructor's initializer list and body, from just past its +/// `:` — the body being the first `{` that does not start a value (`: _m = +/// {}`). +fn end_of_constructor(code: &str, b: &Brackets, from: usize) -> usize { + let bytes = code.as_bytes(); + let mut k = b.first_from(from); + let mut pos = from; + loop { + let next = b.at.get(k).copied().unwrap_or(code.len()); + if let Some(semicolon) = bytes[pos.min(next)..next].iter().position(|c| *c == b';') { + return pos + semicolon; + } + if k >= b.at.len() { + return code.len(); + } + if matches!(bytes[next], b')' | b']' | b'}') { + return next; + } + let Some(close) = b.partner[k] else { + return code.len(); + }; + if bytes[next] == b'{' { + let prior = token_before(code, next); + if prior.word.is_some() + || matches!(char_at(code, prior.at), Some(')' | ']' | '\'' | '"')) + { + return b.at[close]; + } + } + pos = b.at[close] + 1; + k = close + 1; + } +} + +/// The names a parameter list declares: `(Ref ref, {required this.create, +/// int count = 0})`, `([String? label])`, old-style `void cb(int x)` (`cb`), +/// untyped closure parameters `(create, {name})`, and a switch arm's pattern +/// `Foo(:final value) =>`. +fn parameter_names(list: &str) -> Vec { + static ANNOTATION: OnceLock = OnceLock::new(); + let annotation = ANNOTATION.get_or_init(|| js_regex(r"@[A-Za-z0-9_$.]+(?:\s*\([^()]*\))?")); + let mut names = Vec::new(); + for raw in split_parameters(list) { + let mut item = annotation.replace_all(raw, " ").into_owned(); + if let Some(eq) = item.find('=') { + item.truncate(eq); + } + let mut item = item.trim_end_matches(is_js_space); + // `void cb(int x)`: the name is before its own parameter list. + if item.ends_with(')') { + let mut depth = 0i64; + let mut cut = 0; + for (at, byte) in item.bytes().enumerate().rev() { + if byte == b')' { + depth += 1; + } else if byte == b'(' { + depth -= 1; + if depth == 0 { + cut = at; + break; + } + } + } + item = item[..cut].trim_end_matches(is_js_space); + } + if let Some(name) = trailing_identifier(item) { + names.push(name.to_string()); + } + } + names +} + +/// The identifier `item` ends with (`([A-Za-z_$][\w$]*)$`). +fn trailing_identifier(item: &str) -> Option<&str> { + let bytes = item.as_bytes(); + let run = bytes.len() - bytes.iter().rev().take_while(|b| is_name_byte(**b)).count(); + let first = bytes[run..].iter().position(|b| is_name_start(*b))?; + Some(&item[run + first..]) +} + +/// A parameter list's items, the optional group's (`{…}` / `[…]`, always +/// last) included. +fn split_parameters(list: &str) -> Vec<&str> { + let mut items = Vec::new(); + let mut depth = 0i64; + let mut item_start = 0; + let blank = |text: &str| text.trim_matches(is_js_space).is_empty(); + for (i, ch) in list.char_indices() { + if depth == 0 && (ch == '{' || ch == '[') && blank(&list[item_start..i]) { + let close = list.rfind(if ch == '{' { '}' } else { ']' }); + let end = close.filter(|close| *close > i).unwrap_or(list.len()); + items.extend(split_parameters(&list[i + 1..end])); + return items; + } + match ch { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + ',' if depth == 0 => { + if !blank(&list[item_start..i]) { + items.push(&list[item_start..i]); + } + item_start = i + 1; + } + _ => {} + } + } + if !blank(&list[item_start..]) { + items.push(&list[item_start..]); + } + items +} + +/// The variables a `for` head declares: `final x in xs`, `var (a, b) in +/// pairs`, `var i = 0, j = n; …`. +fn for_head_names(head: &str) -> Vec { + static LOOP: OnceLock = OnceLock::new(); + static PATTERN: OnceLock = OnceLock::new(); + static DECLARED: OnceLock = OnceLock::new(); + static INIT: OnceLock = OnceLock::new(); + static ASSIGNED: OnceLock = OnceLock::new(); + let looped = LOOP.get_or_init(|| js_regex(r"^([^;]*?)(?-u:\b)in(?-u:\b)")); + if let Some(caps) = looped.captures(head) { + let decl = caps[1].trim_matches(is_js_space); + let pattern = PATTERN + .get_or_init(|| js_regex(r"^(?:final|var)\s*(?:[A-Za-z0-9_$.]+\s*)?([(\[{](?s:.)*)$")); + if let Some(caps) = pattern.captures(decl) { + return pattern_binders(&caps[1]); + } + let declared = DECLARED.get_or_init(|| { + js_regex( + r"^(?:(?:final|var|const)\s+(?:[A-Za-z0-9_$.]+\s*\??\s+)?|[A-Za-z0-9_$.]+\s*\??\s+)([A-Za-z_$][A-Za-z0-9_$]*)$", + ) + }); + return declared + .captures(decl) + .map(|caps| vec![caps[1].to_string()]) + .unwrap_or_default(); + } + let init = head.split(';').next().unwrap_or(""); + let declares = INIT.get_or_init(|| { + js_regex( + r"^\s*(?:(?:final|var|const|late)(?-u:\b)|[A-Za-z0-9_$.]+\s*\??\s+[A-Za-z_$][A-Za-z0-9_$]*\s*=)", + ) + }); + if !declares.is_match(init) { + return Vec::new(); + } + // `(?:^|,|\s)([A-Za-z_$][\w$]*)\s*=(?![=>])`, the lookahead read here. + let assigned = ASSIGNED.get_or_init(|| js_regex(r"(?:^|,|\s)([A-Za-z_$][A-Za-z0-9_$]*)\s*=")); + let mut names = Vec::new(); + let mut from = 0; + while let Some(caps) = assigned.captures_at(init, from) { + let whole = caps.get(0).expect("the whole match"); + if matches!(init.as_bytes().get(whole.end()), Some(b'=' | b'>')) { + // The search goes on from the next position. + from = whole.start() + + init[whole.start()..] + .chars() + .next() + .map_or(1, char::len_utf8); + } else { + names.push(caps[1].to_string()); + from = whole.end(); + } + } + names +} + +/// The names a destructuring pattern binds: `(a, b)`, `[x, ...rest]`, +/// `Point(:x, y: final py)`, `{'k': v}`. +fn pattern_binders(pattern: &str) -> Vec { + let mut names = Vec::new(); + for (start, end) in identifier_runs(pattern) { + let after = &pattern[end..]; + let rest = after.trim_start_matches(is_js_space); + // `(?!\s*[:(])(?!\s+[A-Za-z_$(])` + if rest.starts_with([':', '(']) + || (rest.len() < after.len() + && rest + .bytes() + .next() + .is_some_and(|b| is_name_start(b) || b == b'(')) + { + continue; + } + let name = &pattern[start..end]; + if name != "_" && !is_reserved(name) { + names.push(name.to_string()); + } + } + names +} + +/// Every identifier of `text` written whole and not after a `.` +/// (`(? Vec<(usize, usize)> { + let bytes = text.as_bytes(); + let mut runs = Vec::new(); + let mut k = 0; + while k < bytes.len() { + if !is_name_byte(bytes[k]) { + k += 1; + continue; + } + let start = k; + while k < bytes.len() && is_name_byte(bytes[k]) { + k += 1; + } + // A run that opens with a digit holds no match: each later start + // follows a word character. + if is_name_start(bytes[start]) && !(start > 0 && bytes[start - 1] == b'.') { + runs.push((start, k)); + } + } + runs +} + +/// The locals a block declares, each bound from its declaration to the +/// block's end: `final x = …`, `var x;`, `Foo? x = …`, `void Function() x +/// = …`, `final (a, b) = …`, and a pattern's `final x` / `var x` (`case +/// Foo(:final x)`, `if (v case final x?)`). A declaration directly in a class +/// body is a field, and one at the top of the file a library variable. +fn bind_locals( + code: &str, + b: &Brackets, + parameter_lists: &HashSet, + scopes: &mut DartLocalScopes, +) { + let bytes = code.as_bytes(); + let mut type_bodies: HashMap = HashMap::new(); + // `direct`: a statement's own declaration, whose block is the bracket + // right around it; a pattern's variable is in the block around its + // `case` or `if`. + let mut bind_in_block = |scopes: &mut DartLocalScopes, name: &str, at: usize, direct: bool| { + let mut k = b.innermost(bytes, at); + while !direct + && let Some(index) = k + && bytes[b.at[index]] != b'{' + { + // A parameter list's `final x` is the parameter, bound where the + // list is read. + if parameter_lists.contains(&b.at[index]) { + return; + } + k = b.parent[index]; + } + if let Some(index) = k + && bytes[b.at[index]] == b'{' + && !*type_bodies + .entry(b.at[index]) + .or_insert_with(|| is_type_body(code, b.at[index])) + { + scopes.bind(name, at, b.end(code.len(), index), false); + } + }; + // `(?])|;))` + for (start, end) in identifier_runs(code) { + let rest = code[end..].trim_start_matches(is_js_space); + let declares = + rest.starts_with(';') || (rest.starts_with('=') && !rest[1..].starts_with(['=', '>'])); + if declares && is_declaration_type(code, b, token_before(code, start)) { + bind_in_block(scopes, &code[start..end], start, true); + } + } + // A pattern variable: `case Foo(:final value)`, `if (x case final y?)` — + // not the type of `final Foo? x = …`. + for (keyword_at, name_start, name_end) in pattern_variables(code) { + bind_in_block(scopes, &code[name_start..name_end], keyword_at, false); + } + // A destructuring declaration: `final (a, b) = …`, `var Point(:x, :y) = + // …`. + for (keyword_at, open) in destructurings(code) { + let Some(close) = b + .index_at(open) + .and_then(|k| b.partner[k]) + .map(|close| b.at[close]) + else { + continue; + }; + // `/^\s*=(?![=>])/` over the next three characters. + let after = &code[close + 1..]; + let after = after + .char_indices() + .nth(3) + .map_or(after, |(at, _)| &after[..at]); + let rest = after.trim_start_matches(is_js_space); + if !(rest.starts_with('=') && !rest[1..].starts_with(['=', '>'])) { + continue; + } + for name in pattern_binders(&code[open + 1..close]) { + bind_in_block(scopes, &name, keyword_at, true); + } + } +} + +/// Each pattern variable, +/// `(?|\bwhen\b|&&|\|\|))`: +/// where its keyword starts and its name's byte range. +fn pattern_variables(code: &str) -> Vec<(usize, usize, usize)> { + let bytes = code.as_bytes(); + let mut found = Vec::new(); + let mut resume = 0; + for (keyword_at, keyword_end) in identifier_runs(code) { + if keyword_at < resume || !matches!(&code[keyword_at..keyword_end], "final" | "var") { + continue; + } + let spaced = skip_space(code, keyword_end); + if spaced == keyword_end { + continue; + } + // With a type written first, then without. + let mut names = Vec::with_capacity(2); + let type_end = spaced + + bytes[spaced..] + .iter() + .take_while(|b| is_name_byte(**b) || **b == b'.') + .count(); + if type_end > spaced { + let after = skip_space(code, type_end); + if bytes.get(after) == Some(&b'?') { + let named = skip_space(code, after + 1); + if named > after + 1 { + names.push(named); + } + } else if after > type_end { + names.push(after); + } + } + names.push(spaced); + for name_at in names { + if let Some(name_end) = identifier_end(bytes, name_at) + && pattern_variable_follows(code, name_end) + { + found.push((keyword_at, name_at, name_end)); + resume = name_end; + break; + } + } + } + found +} + +/// `(?=\s*(?:[)\]},:]|\?(?!\s*[\w$])|=>|\bwhen\b|&&|\|\|))` at byte `at`, right +/// after a name. +fn pattern_variable_follows(code: &str, at: usize) -> bool { + let rest = code[at..].trim_start_matches(is_js_space); + if rest.starts_with([')', ']', '}', ',', ':']) + || rest.starts_with("=>") + || rest.starts_with("&&") + || rest.starts_with("||") + { + return true; + } + if let Some(after) = rest.strip_prefix('?') { + return !after + .trim_start_matches(is_js_space) + .bytes() + .next() + .is_some_and(is_name_byte); + } + // `\bwhen\b`: blanks were skipped, or the name ends with `$`. + let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_'; + rest.strip_prefix("when") + .is_some_and(|after| !after.starts_with(is_word)) + && (rest.len() < code.len() - at || !code[..at].ends_with(is_word)) +} + +/// Each destructuring declaration's head, +/// `(? Vec<(usize, usize)> { + static HEAD: OnceLock = OnceLock::new(); + let head = HEAD.get_or_init(|| js_regex(r"(?:final|var)\s*(?:[A-Z][A-Za-z0-9_$]*\s*)?[(\[{]")); + let bytes = code.as_bytes(); + let mut found = Vec::new(); + let mut from = 0; + while let Some(m) = head.find_at(code, from) { + if m.start() > 0 && (is_name_byte(bytes[m.start() - 1]) || bytes[m.start() - 1] == b'.') { + from = m.start() + 1; + continue; + } + found.push((m.start(), m.end() - 1)); + from = m.end(); + } + found +} + +/// Whether the token before a name followed by `=` / `;` makes it a +/// declaration: `final` / `var` / `const`, a type (`Foo`, `Foo?`, `List` +/// — type arguments are blanked — or a function type ending `Function(…)`). +fn is_declaration_type(code: &str, b: &Brackets, before: Token<'_>) -> bool { + if let Some(word) = before.word { + return DECLARATION_KEYWORDS.contains(&word) || !is_keyword(word); + } + let Some(at) = before.at else { + return false; + }; + match code.as_bytes()[at] { + b'?' => { + at > 0 && { + let previous = code.as_bytes()[at - 1]; + is_name_byte(previous) || previous == b')' + } + } + b')' => b + .index_at(at) + .and_then(|k| b.partner[k]) + .is_some_and(|open| token_before(code, b.at[open]).word == Some("Function")), + _ => false, + } +} + +/// Whether the `{` at `brace` opens a class, mixin, enum or extension body. +fn is_type_body(code: &str, brace: usize) -> bool { + static TYPE_BODY_HEAD: OnceLock = OnceLock::new(); + let head = TYPE_BODY_HEAD.get_or_init(|| { + js_regex( + r"^\s*(?:@[A-Za-z0-9_$.]+(?:\s*\((?:[^()]|\([^()]*\))*\))?\s*)*(?:(?:abstract|base|final|sealed|interface|mixin|augment)\s+)*(?:class|mixin|enum|extension)(?-u:\b)", + ) + }); + let from = code[..brace].rfind([';', '{', '}']).map_or(0, |at| at + 1); + head.is_match(&code[from..brace]) +} + +/// The index of the `{` right around byte `at` when it holds statements (no +/// class or extension body). +fn enclosing_block(code: &str, b: &Brackets, at: usize) -> Option { + let k = b.innermost(code.as_bytes(), at)?; + (code.as_bytes()[b.at[k]] == b'{' && !is_type_body(code, b.at[k])).then_some(k) +} + +/// Where the expression or statement starting at `from` ends: the first `;` +/// (and, for an expression, `,`) outside the brackets it opens, or the close +/// of the bracket around it — the end of a `=> …` body in an argument list, +/// a switch arm or a declaration. +fn end_of_statement(code: &str, b: &Brackets, from: usize, expression: bool) -> usize { + let bytes = code.as_bytes(); + let mut k = b.first_from(from); + let mut pos = from; + loop { + let next = b.at.get(k).copied().unwrap_or(code.len()); + if let Some(stop) = bytes[pos.min(next)..next] + .iter() + .position(|c| *c == b';' || (expression && *c == b',')) + { + return pos + stop; + } + if k >= b.at.len() { + return code.len(); + } + if matches!(bytes[next], b')' | b']' | b'}') { + return next; + } + let Some(close) = b.partner[k] else { + return code.len(); + }; + pos = b.at[close] + 1; + k = close + 1; + } +} + +// ---------------------------------------------------------------- type arguments + +/// The code with type-argument lists blanked — `Map`, `(x) +/// =>`, `[]` — so their commas never end an argument and their angle +/// brackets never read as comparisons. A `<` opens one when it follows a +/// name with no space (`List`), or when what it closes is followed by +/// `(`, `[`, `{` or `.` (a generic function or a typed literal). +fn blank_type_arguments(code: &str) -> String { + if !code.contains('<') { + return code.to_string(); + } + let bytes = code.as_bytes(); + let mut out = String::with_capacity(code.len()); + let mut copied = 0; + let mut next_open = code.find('<'); + while let Some(i) = next_open { + if let Some(end) = type_arguments_end(code, i) { + let glued = i > 0 && is_name_byte(bytes[i - 1]); + let after = bytes.get(skip_space(code, end + 1)).copied(); + if glued || matches!(after, Some(b'(' | b'[' | b'{' | b'.')) { + out.push_str(&code[copied..i]); + // Whole characters: the span opens and closes on `<` / `>`. + for c in code[i..=end].chars() { + match c { + '\n' | '\r' => out.push(c), + _ => out.extend(std::iter::repeat_n(' ', c.len_utf8())), + } + } + copied = end + 1; + next_open = code[end + 1..].find('<').map(|at| end + 1 + at); + continue; + } + } + next_open = code[i + 1..].find('<').map(|at| i + 1 + at); + } + out.push_str(&code[copied..]); + out +} + +/// The `>` closing a type-argument list opened at `open`, when everything +/// inside reads as type syntax. +fn type_arguments_end(code: &str, open: usize) -> Option { + let mut angles = 0i64; + let mut parens = 0i64; + for (offset, ch) in code[open..].char_indices().take(400) { + let i = open + offset; + match ch { + '<' => angles += 1, + '>' => { + if code[..i].ends_with('=') { + return None; + } + angles -= 1; + if angles == 0 { + return (parens == 0).then_some(i); + } + } + '(' | '{' => parens += 1, + ')' | '}' => { + parens -= 1; + if parens < 0 { + return None; + } + } + c if c.is_ascii_alphanumeric() + || matches!(c, '_' | '$' | '.' | ',' | '?') + || is_js_space(c) => {} + _ => return None, + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Whether `name` is bound, as a parameter or local, where `marker` + /// starts in `source`. + fn bound(source: &str, name: &str, marker: &str) -> bool { + let code = crate::dart_chains::blank_dart_code(source); + let at = source.find(marker).expect("marker"); + DartLocalScopes::of(&code) + .nearest(name, at) + .is_some_and(|scope| !scope.function) + } + + #[test] + fn a_parameter_binds_its_body_only() { + let source = "class Holder {\n void run(void Function() create) {\n create();\n }\n void other() {\n create();\n }\n}\n"; + assert!(bound(source, "create", "create();\n }\n void")); + assert!(!bound(source, "create", "create();\n }\n}")); + } + + #[test] + fn a_closure_and_a_loop_bind_only_their_bodies() { + let source = "void wire(List values) {\n values.forEach((notify) => print(notify));\n notify(1);\n for (final notify in values) {\n print(notify);\n }\n notify(2);\n}\n"; + assert!(bound(source, "notify", "notify));")); + assert!(!bound(source, "notify", "notify(1)")); + assert!(bound(source, "notify", "notify);\n }")); + assert!(!bound(source, "notify", "notify(2)")); + } + + #[test] + fn a_local_binds_from_its_declaration_and_a_field_binds_nothing() { + let source = "class Box {\n final listener = 0;\n void main() {\n listener(0);\n final build = () {};\n build();\n final (create, _) = (() => 0, 1);\n create();\n }\n}\n"; + assert!(!bound(source, "listener", "listener(0)")); + assert!(bound(source, "build", "build();")); + assert!(bound(source, "create", "create();")); + } + + #[test] + fn a_local_function_is_nearer_than_the_parameter_it_shadows() { + let source = "int outer(int Function() make) {\n int helper() {\n int make() => 2;\n return make();\n }\n return helper() + make();\n}\n"; + let code = crate::dart_chains::blank_dart_code(source); + let scopes = DartLocalScopes::of(&code); + let inner = source.find("make();").expect("inner call"); + let outer = source.find("make();\n}").expect("outer call"); + assert!( + scopes + .nearest("make", inner) + .is_some_and(|scope| scope.function) + ); + assert!( + scopes + .nearest("make", outer) + .is_some_and(|scope| !scope.function) + ); + } + + #[test] + fn pattern_variables_and_typed_locals_bind_their_names_not_their_types() { + let source = "void f(Object o) {\n if (o case final Box box?) {\n box();\n }\n switch (o) {\n case Foo(:final value) when value != null:\n value();\n }\n final Box? other = null;\n Box();\n}\n"; + assert!(bound(source, "box", "box();")); + assert!(bound(source, "value", "value();")); + assert!(!bound(source, "Box", "Box();")); + } + + #[test] + fn a_constructor_binds_its_parameters_through_its_initializer_list() { + let source = "class Foo {\n Foo(this.make, int Function() build) : _x = build() {\n build();\n }\n final int Function() make;\n final int _x;\n}\n"; + assert!(bound(source, "build", "build() {")); + assert!(bound(source, "build", "build();")); + } + + #[test] + fn a_control_head_and_a_calls_arguments_bind_nothing() { + let source = "class Foo {\n Foo(int a) : _b = make(other) {\n other();\n }\n final int _b;\n}\n\nvoid f(bool ready) {\n if (go) {\n go();\n }\n}\n"; + assert!(!bound(source, "other", "other();")); + assert!(!bound(source, "go", "go();")); + assert!(bound(source, "a", "make(other)")); + } + + #[test] + fn type_arguments_never_end_an_arrow_body() { + let source = "final f = (create) => wrap(create());\n"; + assert!(bound(source, "create", "create());")); + } + + #[test] + fn body_modifiers_are_read_within_eight_characters() { + assert_eq!(body_modifier("async {"), Some(6)); + assert_eq!(body_modifier("async* {"), Some(6)); + assert_eq!(body_modifier("sync* {"), Some(5)); + assert_eq!(body_modifier("sync {"), None); + assert_eq!(body_modifier("{"), None); + } + + #[test] + fn type_arguments_are_blanked_where_they_open() { + assert_eq!( + blank_type_arguments("Map m"), + format!("Map{}m", " ".repeat(14)) + ); + assert_eq!(blank_type_arguments("a < b && c > d"), "a < b && c > d"); + assert_eq!(blank_type_arguments("if (a < b) {}"), "if (a < b) {}"); + assert_eq!(blank_type_arguments("(x) => x"), " (x) => x"); + } +} diff --git a/crates/codegraph-resolve/src/dart_members.rs b/crates/codegraph-resolve/src/dart_members.rs new file mode 100644 index 00000000..5ad6eaf1 --- /dev/null +++ b/crates/codegraph-resolve/src/dart_members.rs @@ -0,0 +1,623 @@ +//! The Dart members a value of a type reaches (upstream #2338, #2360): getter +//! reads, the members an extension adds to a type, and a type's static +//! constants. +//! +//! A member is found as Dart finds it: the nearest one the type, or a type it +//! extends, mixes in or implements, declares, else one an extension `on` such +//! a type adds. An extension applies only where no instance member answers, +//! and an unnamed one only in its own library. +//! +//! A member read (`x.area`, `s?.label`, `Config.instance`) arrives as a +//! `references` ref named `.`. It runs code only when the +//! member is a getter, so it links, as a call, the getter the receiver's type +//! reaches, or nothing: a field read (fields mint no nodes), a getter of a type +//! outside the project, a receiver whose type is not written down. Never a +//! same-named getter found by name alone. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; + +use crate::dart_scope::{ + EXTENSION_RANK, HIERARCHY_LIMIT, dart_extension_named, dart_head_of, dart_supertypes_of, + in_order, is_dart_identifier, is_dart_member, is_dart_name_char, is_dart_type_kind, + is_js_space, member_owner, +}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// Past any lineage's depth (at most [`HIERARCHY_LIMIT`]): an extension on +/// a type parameter of its own is the least specific (upstream +/// `DART_UNIVERSAL_RANK`, #2376). +const UNIVERSAL_RANK: u64 = 100; + +/// The extension a Dart member is declared in: the types it is `on`, whether +/// it is named, and whether it is `on` a type parameter of its own +/// (`extension ObjectX on T`, which every type has) (upstream +/// `dartExtensionOwner`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionOwner { + pub on: Vec, + pub named: bool, + pub universal: bool, +} + +/// A Dart type's lineage: each type of it by supertype distance, and the +/// order the walk reached them in. +#[derive(Debug, Default)] +pub(crate) struct Lineage { + pub(crate) depths: HashMap, + pub(crate) order: Vec<(String, u64)>, +} + +impl Lineage { + pub(crate) fn get(&self, name: &str) -> Option<&u64> { + self.depths.get(name) + } +} + +/// A Dart type and every type it extends, mixes in or implements, by +/// supertype distance, at most [`HIERARCHY_LIMIT`] of them (upstream +/// `dartLineage`). +pub(crate) fn dart_lineage(type_name: &str, context: &dyn ResolutionContext) -> Arc { + let tables = context.dart_tables(); + if let Some(hit) = tables + .lineages + .lock() + .ok() + .and_then(|memo| memo.get(type_name).cloned()) + { + return hit; + } + let mut lineage = Lineage::default(); + let mut queue: VecDeque<(String, u64)> = VecDeque::from([(type_name.to_string(), 0)]); + while lineage.depths.len() < HIERARCHY_LIMIT { + let Some((name, depth)) = queue.pop_front() else { + break; + }; + if lineage.depths.contains_key(&name) { + continue; + } + lineage.depths.insert(name.clone(), depth); + lineage.order.push((name.clone(), depth)); + for sup in dart_supertypes_of(&name, context).iter() { + queue.push_back((sup.clone(), depth + 1)); + } + } + let lineage = Arc::new(lineage); + if let Ok(mut memo) = tables.lineages.lock() { + memo.insert(type_name.to_string(), Arc::clone(&lineage)); + } + lineage +} + +/// The extension `member` is declared in, or `None` for a class's, mixin's or +/// enum's member (upstream `dartExtensionOwner`): the class node whose +/// qualified name owns it and whose lines hold it, read as an extension's +/// head. +pub(crate) fn dart_extension_owner( + member: &Node, + context: &dyn ResolutionContext, +) -> Option> { + let tables = context.dart_tables(); + if let Some(hit) = tables + .extension_owners + .lock() + .ok() + .and_then(|memo| memo.get(&member.id).cloned()) + { + return hit; + } + let found = member + .qualified_name + .rsplit_once("::") + .filter(|(owner_qn, _)| !owner_qn.is_empty()) + .and_then(|(owner_qn, _)| { + in_order(context.get_nodes_in_file_shared(&member.file_path)) + .into_iter() + .find(|node| { + node.qualified_name == owner_qn + && node.kind == NodeKind::Class + && node.start_line <= member.start_line + && node.end_line >= member.start_line + }) + }) + .and_then(|owner| { + let named = dart_extension_named(&owner, context)?; + let on = dart_head_of(&owner, context).supers.clone(); + let universal = on.len() == 1 + && crate::member_types::class_type_parameters(&owner, context).contains(&on[0]); + Some(Arc::new(ExtensionOwner { + on, + named, + universal, + })) + }); + if let Ok(mut memo) = tables.extension_owners.lock() { + memo.insert(member.id.clone(), found.clone()); + } + found +} + +/// Whether a Dart member is a getter, declared `get `: a method its +/// readers run (upstream `isDartGetter`, which reads the declaration from its +/// start and the next line, for a return type that wraps). +pub(crate) fn is_dart_getter(node: &Node, context: &dyn ResolutionContext) -> bool { + if node.language != Language::Dart || node.kind != NodeKind::Method { + return false; + } + let tables = context.dart_tables(); + if let Some(hit) = tables + .getters + .lock() + .ok() + .and_then(|memo| memo.get(&node.id).copied()) + { + return hit; + } + let (signature, next) = crate::dart_scope::dart_member_head(node, context); + let getter = declares_getter(&format!("{signature} {next}"), &node.name); + if let Ok(mut memo) = tables.getters.lock() { + memo.insert(node.id.clone(), getter); + } + getter +} + +/// Whether `head` declares the getter `name` before any `=`, `{` or `;` +/// (upstream's `^[^={;]*?\bget\s+(?![\w$])`, JavaScript's `\b` and +/// `\s`). +pub fn declares_getter(head: &str, name: &str) -> bool { + let stop = head.find(['=', '{', ';']).unwrap_or(head.len()); + let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_'; + head.match_indices("get").any(|(at, _)| { + if at > stop || head[..at].chars().next_back().is_some_and(is_word) { + return false; + } + let rest = &head[at + 3..]; + let after = rest.trim_start_matches(is_js_space); + after.len() < rest.len() + && after.starts_with(name) + && !after[name.len()..] + .chars() + .next() + .is_some_and(is_dart_name_char) + }) +} + +/// The Dart member `name` a value of type `type_name` reaches (upstream +/// `dartMemberOf`): the nearest one the type or a type of its lineage +/// declares, else one an extension `on` such a type adds, an unnamed one only +/// in the reference's library. `accept` narrows the candidates (a read wants a +/// getter). Ties go to the reference's own file, then the nearest directory. +/// `min_depth` 1 skips the type's own members, as `super.` does (#2376). The +/// flag says whether the member is an extension's. A named constructor is +/// the type's, never a value's (KEEP-RUST: upstream's `dartMemberOf` reads +/// one as an instance member, so `x.value()` on a `Foo` reached `Foo.value`'s +/// constructor over an extension's `value()`). +pub(crate) fn dart_member_of( + type_name: &str, + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, + accept: &dyn Fn(&Node) -> bool, + min_depth: u64, +) -> Option<(Arc, bool)> { + let lineage = dart_lineage(type_name, context); + let depth_of = |t: &str| lineage.get(t).copied().filter(|depth| *depth >= min_depth); + let mut is_project_type: Option = None; + let mut best: Vec> = Vec::new(); + let mut best_rank = u64::MAX; + for member in context.get_nodes_by_name_shared(name) { + if member.language != Language::Dart + || !is_dart_member(&member) + || !accept(&member) + || is_dart_constructor(&member, context) + { + continue; + } + let rank = if let Some(extension) = dart_extension_owner(&member, context) { + if !extension.named + && !crate::dart_libraries::in_same_dart_library( + &reference.file_path, + &member.file_path, + context, + ) + { + continue; + } + // `extension ObjectX on T` reaches every type, after every + // extension that names one of its lineage, when the type is the + // project's, so what it declares is known: a List's own `cast` + // is not ObjectX's. + let on = if extension.universal { + let project = *is_project_type.get_or_insert_with(|| { + context + .get_nodes_by_name_shared(type_name) + .iter() + .any(|node| node.language == Language::Dart && is_dart_type_kind(node.kind)) + }); + if !project { + continue; + } + UNIVERSAL_RANK + } else { + let Some(on) = extension.on.iter().filter_map(|on| depth_of(on)).min() else { + continue; + }; + on + }; + EXTENSION_RANK + on + } else { + let Some(depth) = member_owner(&member.qualified_name).and_then(depth_of) else { + continue; + }; + depth + }; + if rank < best_rank { + best_rank = rank; + best = vec![member]; + } else if rank == best_rank { + best.push(member); + } + } + let own = best + .iter() + .find(|node| node.file_path == reference.file_path) + .cloned(); + let node = own.or_else(|| { + best.iter().cloned().reduce(|chosen, next| { + let proximity = |node: &Node| { + crate::name_matcher::compute_path_proximity(&reference.file_path, &node.file_path) + }; + if proximity(&next) > proximity(&chosen) { + next + } else { + chosen + } + }) + })?; + Some((node, best_rank >= EXTENSION_RANK)) +} + +/// A member an extension adds to `type_name` (`s.shout()` on an enum `Shape` +/// through `extension ShapeInfo on Shape`) when the type itself declares none +/// (upstream `dartExtensionMemberOf`). An inherited instance member is left to +/// the strategies that resolve it. +pub(crate) fn dart_extension_member_of( + type_name: &str, + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + let (node, via_extension) = dart_member_of(type_name, name, reference, context, &|_| true, 0)?; + via_extension.then_some(node) +} + +/// `x.area` / `Config.instance`: the receiver and member of a Dart member read. +pub(crate) fn member_read_parts(name: &str) -> Option<(&str, &str)> { + let (receiver, member) = name.split_once('.')?; + (is_dart_identifier(receiver) && is_dart_identifier(member)).then_some((receiver, member)) +} + +/// Whether a reference is a Dart member read (upstream `isDartMemberRead`): +/// `x.area`, which the extractor records as a `references` ref named +/// `.`. No other Dart reference carries a dotted name: a +/// type is one identifier, a static read names only its type, and a function +/// value has its own path. +pub fn is_dart_member_read(reference: &RefView) -> bool { + reference.language == Language::Dart + && reference.reference_kind == EdgeKind::References + && !reference.is_function_ref + && member_read_parts(&reference.reference_name).is_some() +} + +/// The getter a Dart member read runs (upstream `matchDartMemberRead`), or +/// nothing. A type named directly reads its static getter +/// (`Config.instance`); a variable is typed by its declaration, a parameter +/// or local in scope, else a field of the class the read is written in +/// (`final Box box;`). +pub fn match_dart_member_read( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let (receiver, member) = member_read_parts(&reference.reference_name)?; + // Most reads are of fields, which mint no nodes: a getter first. + if !context + .get_nodes_by_name_shared(member) + .iter() + .any(|node| is_dart_getter(node, context)) + { + return None; + } + let names_a_type = receiver.starts_with(|c: char| c.is_ascii_uppercase()) + && context + .get_nodes_by_name_shared(receiver) + .iter() + .any(|node| node.language == Language::Dart && is_dart_type_kind(node.kind)); + let type_name = if names_a_type { + receiver.to_string() + } else { + crate::name_matcher::infer_local_receiver_declaration(receiver, reference, context) + .map(|(name, _)| name) + .or_else(|| { + crate::member_types::infer_member_receiver_type(receiver, reference, context) + })? + }; + let (getter, _) = dart_member_of( + &type_name, + member, + reference, + context, + &|node| is_dart_getter(node, context), + 0, + )?; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: getter.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::InstanceMethod, + }) +} + +/// Whether a Dart method is a named constructor — `Foo.named(…)`, `factory +/// Foo.of(…)`, `const Foo.value(…)`, `const factory Foo.x() = _X;` — read +/// from its declaration (upstream #2380 `isDartConstructor`). Dart reaches +/// one only through its class (`Foo.value(…)`, the tear-off `Foo.value`), +/// never by a bare name: a parameter `value` passed on is not `const +/// BlocProvider.value(…)`. +pub(crate) fn is_dart_constructor(node: &Node, context: &dyn ResolutionContext) -> bool { + if node.language != Language::Dart || node.kind != NodeKind::Method { + return false; + } + let tables = context.dart_tables(); + if let Some(hit) = tables + .constructors + .lock() + .ok() + .and_then(|memo| memo.get(&node.id).copied()) + { + return hit; + } + let owner = member_owner(&node.qualified_name).unwrap_or(""); + let constructor = !owner.is_empty() + && declares_constructor( + &crate::dart_scope::dart_member_head(node, context).0, + owner, + &node.name, + ); + if let Ok(mut memo) = tables.constructors.lock() { + memo.insert(node.id.clone(), constructor); + } + constructor +} + +/// Whether a declaration's head opens a constructor `owner.name` +/// (`^(?:(?:const|factory|external)\s+)*owner\s*\.\s*name(?![\w$])`). +fn declares_constructor(head: &str, owner: &str, name: &str) -> bool { + let mut rest = head; + while let Some(after) = ["const", "factory", "external"] + .iter() + .find_map(|keyword| rest.strip_prefix(keyword)) + { + let spaced = after.trim_start_matches(is_js_space); + if spaced.len() == after.len() { + break; + } + rest = spaced; + } + let Some(rest) = rest.strip_prefix(owner) else { + return false; + }; + let Some(rest) = rest.trim_start_matches(is_js_space).strip_prefix('.') else { + return false; + }; + rest.trim_start_matches(is_js_space) + .strip_prefix(name) + .is_some_and(|after| !after.starts_with(is_dart_name_char)) +} + +/// Whether a member of a Dart type is reached through the type itself: a +/// static member, constant or enum value, or a named constructor or factory +/// (`factory ProviderContainer.test(…)`), not an instance member sharing a +/// constructor's name (`AsyncValue`'s `error` getter beside its +/// `AsyncValue.error(…)` factory, even declared on the line above it; +/// upstream `isDartStaticMember`). +pub(crate) fn is_dart_static_member(member: &Node, context: &dyn ResolutionContext) -> bool { + member.is_static + || member.kind == NodeKind::Constant + || member.kind == NodeKind::EnumMember + || is_dart_constructor(member, context) +} + +/// The static member `name` the Dart type `type_name` itself declares — a +/// static method or getter, a named constructor or factory, a static +/// constant, an enum value — preferring the file `file_path`, then the +/// nearest (upstream `dartStaticMember`). Static members are not inherited. +/// `is_owner` narrows the types of that name to the one meant (the one an +/// import prefix brings in, upstream #2389). +pub(crate) fn dart_static_member( + type_name: &str, + name: &str, + file_path: &str, + context: &dyn ResolutionContext, + is_owner: &dyn Fn(&Node) -> bool, +) -> Option> { + let owners: Vec> = context + .get_nodes_by_name_shared(type_name) + .into_iter() + .filter(|node| { + node.language == Language::Dart && is_dart_type_kind(node.kind) && is_owner(node) + }) + .collect(); + if owners.is_empty() { + return None; + } + let found: Vec> = context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|member| { + member.language == Language::Dart + && matches!( + member.kind, + NodeKind::Method + | NodeKind::Function + | NodeKind::Constant + | NodeKind::EnumMember + ) + && owners.iter().any(|owner| { + owner.file_path == member.file_path + && member.qualified_name == format!("{}::{name}", owner.qualified_name) + }) + && !crate::dart_chains::is_setter(member, context) + && is_dart_static_member(member, context) + }) + .collect(); + if found.len() <= 1 { + return found.into_iter().next(); + } + if let Some(own) = found.iter().find(|member| member.file_path == file_path) { + return Some(Arc::clone(own)); + } + found.into_iter().reduce(|chosen, next| { + let proximity = + |node: &Node| crate::name_matcher::compute_path_proximity(file_path, &node.file_path); + if proximity(&next) > proximity(&chosen) { + next + } else { + chosen + } + }) +} + +/// A Dart call through a type's name — `BlocProvider.value(…)`, riverpod's +/// `FutureProvider.autoDispose(…)` — is to one of that type's own +/// constructors or static members, or to nothing the project declares +/// (upstream #2380 `matchDartTypeMemberCall`). Dart inherits none of them, so +/// a guess past the type is always wrong: flutter_bloc's +/// `BlocProvider.value(…)` went to `RepositoryProvider`'s `value`, and a +/// static constant's callee is the value it holds (`static const autoDispose +/// = AutoDisposeFutureProviderBuilder();`). `None` when the receiver names no +/// Dart type of the project. KEEP-RUST: a call through a type written behind +/// an import prefix (`p.Box.create()`, `p.Box.named()`) is a chain's +/// link here, typed through the prefix ([`crate::dart_chains`]), where +/// upstream #2389 reads the prefix back from the line in this matcher +/// (`dartTypeCallPrefix`, for its `Box.named` record of such a call). +pub(crate) fn match_dart_type_member_call( + type_name: &str, + member: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + if !context + .get_nodes_by_name_shared(type_name) + .iter() + .any(|node| node.language == Language::Dart && is_dart_type_kind(node.kind)) + { + return None; + } + let own = dart_static_member(type_name, member, &reference.file_path, context, &|_| true); + Some( + own.filter(|own| own.kind != NodeKind::EnumMember) + .map(|own| ResolvedRef { + original: reference.clone(), + target_node_id: own.id.clone(), + confidence: 0.85, + resolved_by: ResolvedBy::QualifiedName, + }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Expected values from upstream's `isDartGetter` pattern on the same + /// heads. + #[test] + fn a_getter_is_declared_get_name_before_any_body() { + let cases: [(&str, &str, bool); 9] = [ + ("int get area => size * size;", "area", true), + ("static Square get unit => const Square(1);", "unit", true), + ("String get label => name.toUpperCase();", "label", true), + ("Map get table {", "table", true), + ("int get areaSquared => 1;", "area", false), + ("int grow() => size + 1;", "grow", false), + ("set area(int v) {}", "area", false), + ("int forget area() => 1;", "area", false), + ("int x = get area;", "area", false), + ]; + for (head, name, expected) in cases { + assert_eq!(declares_getter(head, name), expected, "{head:?}"); + } + } + + /// Expected values from upstream #2380's `isDartConstructor` pattern on + /// the same heads. + #[test] + fn a_constructor_is_declared_owner_dot_name_after_its_modifiers() { + let cases: [(&str, &str, &str, bool); 14] = [ + ( + "const BlocProvider.value({required T value})", + "BlocProvider", + "value", + true, + ), + ( + "const factory NewCarState.loading() = _Loading;", + "NewCarState", + "loading", + true, + ), + ( + "factory Base.named() => const _BaseImpl();", + "Base", + "named", + true, + ), + ("external const Foo.ext();", "Foo", "ext", true), + ( + "RepositoryProvider.value({required T value});", + "RepositoryProvider", + "value", + true, + ), + ("Foo . bar()", "Foo", "bar", true), + ("const factory Foo.bar()", "Foo", "bar", true), + ("const _$Foo.create()", "_$Foo", "create", true), + ("Object? get error => null;", "AsyncValue", "error", false), + ( + "static Family make() => Family._();", + "Family", + "make", + false, + ), + ("const Foo.barBaz()", "Foo", "bar", false), + ("const Foo.bar$x()", "Foo", "bar", false), + ("constFoo.bar()", "Foo", "bar", false), + ("@internal const Foo.bar()", "Foo", "bar", false), + ]; + for (head, owner, name, expected) in cases { + assert_eq!( + declares_constructor(head, owner, name), + expected, + "{head:?}" + ); + } + } + + #[test] + fn a_member_read_is_one_dotted_pair_of_names() { + assert_eq!(member_read_parts("x.area"), Some(("x", "area"))); + assert_eq!( + member_read_parts("Config.instance"), + Some(("Config", "instance")) + ); + assert_eq!( + member_read_parts("_box.\u{24}size"), + Some(("_box", "$size")) + ); + assert_eq!(member_read_parts("a.b.c"), None); + assert_eq!(member_read_parts("area"), None); + assert_eq!(member_read_parts("x."), None); + } +} diff --git a/crates/codegraph-resolve/src/dart_prefixes.rs b/crates/codegraph-resolve/src/dart_prefixes.rs new file mode 100644 index 00000000..000d94ed --- /dev/null +++ b/crates/codegraph-resolve/src/dart_prefixes.rs @@ -0,0 +1,127 @@ +//! A Dart call through an import prefix (upstream #2389) — `http.get(…)` +//! after `import 'package:http/http.dart' as http;`, `fmt.describe(…)`, +//! `kit.Widget()` — calls a top-level declaration a library imported with +//! that prefix exports, `export` chains and `show` / `hide` followed, or +//! nothing of the project: never a member found by its name. riverpod's +//! docs' `http.get(…)` calls went to a docs example's `Http::get`, by their +//! receiver's capitalized name. A parameter, local or member named like the +//! prefix hides it, as Dart's scopes nest. +//! +//! KEEP-RUST: a Dart reference is placed where its chain starts, so a +//! prefixed call's column is on the prefix (upstream's is past the name). + +use std::sync::Arc; + +use codegraph_core::types::{EdgeKind, Language, Node}; + +use crate::dart_libraries::{dart_import_prefixes, dart_prefix_sees, is_dart_library_decl}; +use crate::dart_scope::{in_order, is_dart_name_char, is_dart_type_kind, is_js_space}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// What a Dart call written `prefix.name(…)` through an import prefix links: +/// `Some` with the one top-level declaration the prefixed imports bring in +/// under the name, or nothing; `None` when the receiver is no import prefix +/// there, or a parameter, local or member of that name hides it (upstream +/// `matchDartPrefixedCall`). +pub(crate) fn match_dart_prefixed_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + if reference.language != Language::Dart || reference.reference_kind != EdgeKind::Calls { + return None; + } + let (prefix, name) = crate::dart_members::member_read_parts(&reference.reference_name)?; + if !dart_import_prefixes(&reference.file_path, context).contains(prefix) { + return None; + } + let Some(at) = prefixed_call_site(reference, prefix, name, context) else { + return Some(None); + }; + if is_dart_name_hidden(prefix, &reference.file_path, reference.line, at, context) { + return None; + } + let found = dart_prefixed_decls(prefix, name, &reference.file_path, context); + Some((found.len() == 1).then(|| ResolvedRef { + original: reference.clone(), + target_node_id: found[0].id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::Import, + })) +} + +/// The top-level Dart declarations named `name` that the imports written +/// with `prefix` bring into `file`'s library (upstream `dartPrefixedDecls`). +pub(crate) fn dart_prefixed_decls( + prefix: &str, + name: &str, + file: &str, + context: &dyn ResolutionContext, +) -> Vec> { + context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| { + is_dart_library_decl(node) + && dart_prefix_sees(file, prefix, &node.file_path, name, context) + }) + .collect() +} + +/// Where `prefix` starts in the code of a Dart call written `prefix.name(…)` +/// at the reference's place, or `None` when the call is written otherwise +/// (upstream `dartPrefixedCallSite`, which reads back from the name). +fn prefixed_call_site( + reference: &RefView, + prefix: &str, + name: &str, + context: &dyn ResolutionContext, +) -> Option { + let code = context.source_facts(&reference.file_path)?.dart_code(); + let text = code.text.as_str(); + let at = code.line_start(reference.line)? + usize::try_from(reference.column).ok()?; + let rest = text.get(at..)?.strip_prefix(prefix)?; + let rest = rest.trim_start_matches(is_js_space).strip_prefix('.')?; + let rest = rest.trim_start_matches(is_js_space).strip_prefix(name)?; + if rest.starts_with(is_dart_name_char) { + return None; + } + // `x.p.name(…)` is a member chain, whatever `p` names. + (!text[..at].trim_end_matches(is_js_space).ends_with('.')).then_some(at) +} + +/// Whether a parameter or local around offset `at` of `file`, or a member of +/// the type around line `line`, is named `name`: it hides a library-level +/// name there, an import prefix among them, as Dart's scopes nest (upstream +/// `isDartNameHidden`). +pub(crate) fn is_dart_name_hidden( + name: &str, + file: &str, + line: i64, + at: usize, + context: &dyn ResolutionContext, +) -> bool { + if crate::dart_local_scope::is_dart_locally_bound(file, name, at, context) { + return true; + } + let mut own: Option> = None; + for node in in_order(context.get_nodes_in_file_shared(file)) { + if is_dart_type_kind(node.kind) + && node.start_line <= line + && node.end_line >= line + && own + .as_ref() + .is_none_or(|own| node.start_line >= own.start_line) + { + own = Some(node); + } + } + let Some(own) = own else { + return false; + }; + let qualified_name = format!("{}::{name}", own.qualified_name); + crate::member_types::class_member_type(&own, name, context).is_some() + || context + .get_nodes_by_name_shared(name) + .iter() + .any(|node| node.file_path == own.file_path && node.qualified_name == qualified_name) +} diff --git a/crates/codegraph-resolve/src/dart_scope.rs b/crates/codegraph-resolve/src/dart_scope.rs new file mode 100644 index 00000000..8d5cf024 --- /dev/null +++ b/crates/codegraph-resolve/src/dart_scope.rs @@ -0,0 +1,1033 @@ +//! Dart calls written without a receiver and the class hierarchy they stay +//! in (upstream #2145), core method names on untyped receivers (#2207) and +//! unnamed extensions (#2237). +//! +//! A bare Dart call (`emit(…)`, `refresh()`) reaches a method only through +//! the class it is written in: that class's own members, what it extends, +//! mixes in or implements, or an extension `on` one of those types, the +//! nearest one winning; outside any class, no method. The hierarchy is read +//! from each type declaration's head, comments and type arguments dropped. +//! An unnamed `extension on X` applies only where it is declared. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::types::{RefView, ResolutionContext}; + +/// How many types a call site's hierarchy reaches at most (upstream's 40). +pub const HIERARCHY_LIMIT: usize = 40; + +/// How many lines past a declaration's first a head is read from +/// (upstream's 40). +const HEAD_LINES: i64 = 40; + +/// How many lines below a chain's start its later links are looked for. +pub(crate) const CHAIN_LINES: i64 = 40; + +/// Past any real member's depth: an extension member only applies when no +/// instance member does (upstream `DART_EXTENSION_RANK`). +pub(crate) const EXTENSION_RANK: u64 = 1000; + +/// No path from the call site's classes to a member's owner. +const OUT_OF_SCOPE: u64 = u64::MAX; + +/// Methods of Dart's String, List, Iterable, Map and Set, names a project +/// type rarely carries itself (upstream #2207 `DART_STD_METHODS`). +pub(crate) const DART_STD_METHODS: [&str; 61] = [ + "endsWith", + "startsWith", + "contains", + "split", + "substring", + "trim", + "trimLeft", + "trimRight", + "toLowerCase", + "toUpperCase", + "replaceAll", + "replaceFirst", + "replaceRange", + "indexOf", + "lastIndexOf", + "padLeft", + "padRight", + "codeUnitAt", + "allMatches", + "firstMatch", + "hasMatch", + "addAll", + "removeAt", + "removeWhere", + "removeLast", + "retainWhere", + "insertAll", + "where", + "whereType", + "forEach", + "toList", + "toSet", + "join", + "reduce", + "fold", + "any", + "every", + "firstWhere", + "lastWhere", + "singleWhere", + "containsKey", + "containsValue", + "putIfAbsent", + "sublist", + "take", + "takeWhile", + "skip", + "skipWhile", + "expand", + "cast", + "compareTo", + "elementAt", + "followedBy", + "asMap", + "getRange", + "setAll", + "fillRange", + "shuffle", + "sort", + "indexWhere", + "lastIndexWhere", +]; + +/// JavaScript's `\s`. +pub(crate) fn is_js_space(c: char) -> bool { + codegraph_core::generated_header::is_js_whitespace(c) +} + +/// An ASCII word character or `$` (`[\w$]`). +pub(crate) fn is_dart_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '$' +} + +/// Whether `name` is written as a Dart identifier (`^[A-Za-z_$][\w$]*$`). +pub(crate) fn is_dart_identifier(name: &str) -> bool { + name.chars() + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_' || first == '$') + && name.chars().all(is_dart_name_char) +} + +/// Whether a node kind is one a Dart type declaration takes (upstream +/// `DART_TYPE_KINDS`; the extractor files mixins and extensions as classes). +pub fn is_dart_type_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Struct | NodeKind::Trait + ) +} + +/// A member of a Dart type: a method, or an abstract member written without +/// a body, which the extractor records as a function the type owns +/// (upstream `isDartMember`), read from the node alone: a function declared +/// in a body counts too ([`is_dart_type_member`]). +pub fn is_dart_member(node: &Node) -> bool { + node.kind == NodeKind::Method + || (node.kind == NodeKind::Function && node.qualified_name.contains("::")) +} + +/// A member of a Dart type (upstream `isDartMember`). KEEP-RUST: the +/// extractor qualifies a function declared in a body by the function around +/// it (`outer::make`, upstream's is `make`), so a function a same-file +/// function or method encloses is a local one, no member: a bare call in +/// there reaches it, as v0.54.0 linked it (W4-DART-03c, a W4-DART-01 +/// regression upstream #2379's fixture shows). +pub(crate) fn is_dart_type_member(node: &Node, context: &dyn ResolutionContext) -> bool { + node.kind == NodeKind::Method + || (node.kind == NodeKind::Function + && node.qualified_name.contains("::") + && !crate::name_matcher::is_in_function_body(node, context)) +} + +/// The name of the type that owns a member, the last segment before its +/// own name, or `None` for a top-level declaration. +pub fn member_owner(qualified_name: &str) -> Option<&str> { + let (owner, _) = qualified_name.rsplit_once("::")?; + Some(owner.rsplit("::").next().unwrap_or(owner)) +} + +/// Line `line` (1-based) of `file`, as stored. +pub(crate) fn source_line( + file: &str, + line: i64, + context: &dyn ResolutionContext, +) -> Option { + let facts = context.source_facts(file)?; + let index = usize::try_from(line).ok()?.checked_sub(1)?; + facts.raw_line(index).map(str::to_string) +} + +/// Lines `first..=last` (1-based) of `file`, joined by newlines. +fn source_lines(file: &str, first: i64, last: i64, context: &dyn ResolutionContext) -> String { + let Some(facts) = context.source_facts(file) else { + return String::new(); + }; + let mut out = Vec::new(); + for line in first.max(1)..=last { + let Some(text) = usize::try_from(line - 1) + .ok() + .and_then(|index| facts.raw_line(index)) + else { + break; + }; + out.push(text.trim_end_matches('\r').to_string()); + } + out.join("\n") +} + +/// Whether a Dart reference is written without a receiver at its call site +/// (upstream `isReceiverLessDartCall`): the extractor keeps one receiver +/// level, so the later links of a chain (`LoginState().withEmail(e) +/// .withPassword(p)`) arrive as bare names, their line showing +/// `.withPassword(` all the same. A spread's `...items()` is no receiver. +/// Read by [`crate::dart_chains::receiver_less_at`]. +pub(crate) fn is_receiver_less_dart_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let Some(facts) = context.source_facts(&reference.file_path) else { + return true; + }; + crate::dart_chains::receiver_less_at( + &facts.dart_code(), + &reference.reference_name, + reference.line, + reference.column, + ) +} + +/// A Dart `calls` reference written as a bare identifier at its call site. +pub(crate) fn is_dart_bare_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { + reference.language == Language::Dart + && reference.reference_kind == EdgeKind::Calls + && is_dart_identifier(&reference.reference_name) + && is_receiver_less_dart_call(reference, context) +} + +/// What a Dart type declaration's head names: its supertypes (what it +/// extends, mixes in or implements, or an extension or mixin sits `on`) and +/// whether it is an extension, whose supertypes are the types it extends. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DartHead { + pub supers: Vec, + pub extension: bool, +} + +/// `text` with `/* … */` and then `// …` comments replaced by a space +/// (upstream's two replacements). +fn strip_dart_comments(text: &str) -> String { + let mut blockless = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = rest.find("/*") { + let Some(end) = rest[at + 2..].find("*/") else { + break; + }; + blockless.push_str(&rest[..at]); + blockless.push(' '); + rest = &rest[at + 2 + end + 2..]; + } + blockless.push_str(rest); + let mut out = String::with_capacity(blockless.len()); + let mut rest = blockless.as_str(); + while let Some(at) = rest.find("//") { + out.push_str(&rest[..at]); + out.push(' '); + let end = rest[at..].find('\n').map_or(rest.len(), |end| at + end); + rest = &rest[end..]; + } + out.push_str(rest); + out +} + +/// The position of the first whole word of `words` in `text` (ASCII word +/// boundaries, as JavaScript's `\b`), and the word. +fn first_word<'w>(text: &str, words: &[&'w str]) -> Option<(usize, &'w str)> { + let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_'; + let bytes = text.as_bytes(); + (0..text.len()) + .filter(|&at| text.is_char_boundary(at)) + .find_map(|at| { + if at > 0 && text[..at].chars().next_back().is_some_and(is_word) { + return None; + } + words.iter().find_map(|word| { + let end = at + word.len(); + (bytes.get(at..end) == Some(word.as_bytes()) + && !text[end..].chars().next().is_some_and(is_word)) + .then_some((at, *word)) + }) + }) +} + +/// A Dart type declaration's head read from its text (the declaration's +/// lines, at most its first 41; upstream `dartHeadOf`): comments and type +/// arguments are dropped first, the head ends at its body's `{` or a `;`, +/// and the supertypes are the names after the first `extends`, `with`, +/// `implements`, `on` or `=`. +pub fn dart_head(text: &str) -> DartHead { + let text = strip_dart_comments(text); + let mut depth = 0usize; + let mut flat = String::new(); + for ch in text.chars() { + if ch == '<' { + depth += 1; + } else if ch == '>' { + depth = depth.saturating_sub(1); + } else if depth == 0 { + if ch == '{' || ch == ';' { + break; + } + flat.push(ch); + } + } + let keyword = first_word(&flat, &["class", "mixin", "extension", "enum"]); + let head = keyword.map_or(flat.as_str(), |(at, _)| &flat[at..]); + // The leftmost `extends`, `with`, `implements`, `on` or `=`. + let word = first_word(head, &["extends", "with", "implements", "on"]); + let clause_start = match (word, head.find('=')) { + (Some((at, _)), Some(equals)) if equals < at => Some(equals + 1), + (Some((at, word)), _) => Some(at + word.len()), + (None, equals) => equals.map(|equals| equals + 1), + }; + let clause = clause_start.map_or("", |start| &head[start..]); + let mut supers = Vec::new(); + let mut chars = clause.char_indices().peekable(); + while let Some((at, c)) = chars.next() { + if !(c.is_ascii_alphabetic() || c == '_' || c == '$') { + continue; + } + let mut end = at + c.len_utf8(); + while let Some(&(next, n)) = chars.peek() { + if !is_dart_name_char(n) { + break; + } + end = next + n.len_utf8(); + chars.next(); + } + let word = &clause[at..end]; + if !matches!(word, "extends" | "with" | "implements" | "on") { + supers.push(word.to_string()); + } + } + let extension = + keyword.is_some_and(|(_, word)| word == "extension") && !is_extension_type(head); + DartHead { supers, extension } +} + +/// Whether a head starting `extension` declares an `extension type` +/// (`^extension\s+type\b`). +fn is_extension_type(head: &str) -> bool { + let Some(rest) = head.strip_prefix("extension") else { + return false; + }; + let after = rest.trim_start_matches(is_js_space); + after.len() < rest.len() + && after.strip_prefix("type").is_some_and(|after| { + !after + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') + }) +} + +/// Per-pass memos of the Dart hierarchy: each declaration's head, each type +/// name's supertypes, and each call site's hierarchy. +#[derive(Debug, Default)] +pub struct DartTables { + heads: Mutex>>, + supers: Mutex>>>, + hierarchies: Mutex>>, + /// Each type name's lineage ([`crate::dart_members`]). + pub(crate) lineages: Mutex>>, + /// Each member's extension, by node id, `None` for a type's own member. + pub(crate) extension_owners: + Mutex>>>, + /// Whether each method is a getter, by node id. + pub(crate) getters: Mutex>, + /// Whether each method is a setter, by node id ([`crate::dart_chains`]). + pub(crate) setters: Mutex>, + /// Whether each method is a named constructor, by node id + /// ([`crate::dart_members::is_dart_constructor`]). + pub(crate) constructors: Mutex>, + /// Each function's declared return type as a chain reads it, by node id. + pub(crate) return_types: Mutex>>>, +} + +/// The types around a call site, by supertype distance. +type Hierarchy = HashMap; + +impl DartTables { + pub fn new() -> Self { + Self::default() + } +} + +/// `decl`'s head, read once per pass. +pub(crate) fn dart_head_of(decl: &Node, context: &dyn ResolutionContext) -> Arc { + let tables = context.dart_tables(); + if let Some(hit) = tables + .heads + .lock() + .ok() + .and_then(|memo| memo.get(&decl.id).cloned()) + { + return hit; + } + let last = decl.end_line.min(decl.start_line + HEAD_LINES); + let head = Arc::new(dart_head(&source_lines( + &decl.file_path, + decl.start_line, + last, + context, + ))); + if let Ok(mut memo) = tables.heads.lock() { + memo.insert(decl.id.clone(), Arc::clone(&head)); + } + head +} + +/// `nodes` in source order (start line, column, id). +pub(crate) fn in_order(mut nodes: Vec>) -> Vec> { + nodes.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + nodes +} + +/// The names every Dart declaration of `type_name` extends, mixes in, +/// implements or sits `on` (upstream `dartSupertypesOf`), the declarations +/// in path and line order. +pub(crate) fn dart_supertypes_of( + type_name: &str, + context: &dyn ResolutionContext, +) -> Arc> { + let tables = context.dart_tables(); + if let Some(hit) = tables + .supers + .lock() + .ok() + .and_then(|memo| memo.get(type_name).cloned()) + { + return hit; + } + let mut decls: Vec> = context + .get_nodes_by_name_shared(type_name) + .into_iter() + .filter(|node| node.language == Language::Dart && is_dart_type_kind(node.kind)) + .collect(); + decls.sort_by(|a, b| { + (&a.file_path, a.start_line, &a.id).cmp(&(&b.file_path, b.start_line, &b.id)) + }); + let names = Arc::new( + decls + .iter() + .flat_map(|decl| dart_head_of(decl, context).supers.clone()) + .collect::>(), + ); + if let Ok(mut memo) = tables.supers.lock() { + memo.insert(type_name.to_string(), Arc::clone(&names)); + } + names +} + +/// Every type the classes around a Dart call site are, by supertype +/// distance, at most [`HIERARCHY_LIMIT`] of them (upstream +/// `dartHierarchyAt`). +fn dart_hierarchy_at(reference: &RefView, context: &dyn ResolutionContext) -> Arc { + let tables = context.dart_tables(); + let key = (reference.file_path.clone(), reference.line); + if let Some(hit) = tables + .hierarchies + .lock() + .ok() + .and_then(|memo| memo.get(&key).cloned()) + { + return hit; + } + let mut depths: Hierarchy = HashMap::new(); + let mut queue: VecDeque<(String, u64)> = + in_order(context.get_nodes_in_file_shared(&reference.file_path)) + .into_iter() + .filter(|node| { + is_dart_type_kind(node.kind) + && node.start_line <= reference.line + && node.end_line >= reference.line + }) + .map(|node| (node.name.clone(), 0)) + .collect(); + while depths.len() < HIERARCHY_LIMIT { + let Some((name, depth)) = queue.pop_front() else { + break; + }; + if depths.contains_key(&name) { + continue; + } + depths.insert(name.clone(), depth); + for sup in dart_supertypes_of(&name, context).iter() { + queue.push_back((sup.clone(), depth + 1)); + } + } + let depths = Arc::new(depths); + if let Ok(mut memo) = tables.hierarchies.lock() { + memo.insert(key, Arc::clone(&depths)); + } + depths +} + +/// How many supertype steps separate the classes around a Dart call site +/// from `method`'s owner (upstream `dartMemberDepth`): 0 for their own +/// member or a top-level function, [`OUT_OF_SCOPE`] when the owner is not in +/// their hierarchy. An extension's member is in scope when the extension is +/// `on` a type of the hierarchy, ranked after every real member. +fn dart_member_depth(method: &Node, reference: &RefView, context: &dyn ResolutionContext) -> u64 { + let Some(owner) = member_owner(&method.qualified_name) else { + return 0; + }; + let hierarchy = dart_hierarchy_at(reference, context); + if let Some(&own) = hierarchy.get(owner) { + return own; + } + let decl = in_order(context.get_nodes_in_file_shared(&method.file_path)) + .into_iter() + .find(|node| { + node.name == owner + && is_dart_type_kind(node.kind) + && node.start_line <= method.start_line + && node.end_line >= method.end_line + }); + let Some(decl) = decl else { + return OUT_OF_SCOPE; + }; + let head = dart_head_of(&decl, context); + if !head.extension { + return OUT_OF_SCOPE; + } + head.supers + .iter() + .filter_map(|on| hierarchy.get(on)) + .min() + .map_or(OUT_OF_SCOPE, |on| EXTENSION_RANK + on) +} + +/// Whether a bare Dart call can reach `method` (upstream +/// `isDartMethodInScope`): a member of the class it is written in, or of +/// what that class extends, mixes in or implements, an extension `on` one of +/// those included. Outside any class (`main`), no method. +pub(crate) fn is_dart_method_in_scope( + method: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + dart_member_depth(method, reference, context) < OUT_OF_SCOPE +} + +/// Of the in-scope members a bare Dart call could mean, the nearest +/// (upstream `nearestDartMembers`): a subclass's override, or the class that +/// implements what an interface only declares. +pub(crate) fn nearest_dart_members( + candidates: Vec>, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Vec> { + if candidates + .iter() + .filter(|node| is_dart_type_member(node, context)) + .count() + < 2 + { + return candidates; + } + let depths: HashMap = candidates + .iter() + .filter(|node| is_dart_type_member(node, context)) + .map(|node| (node.id.clone(), dart_member_depth(node, reference, context))) + .collect(); + let nearest = depths.values().copied().min().unwrap_or(OUT_OF_SCOPE); + candidates + .into_iter() + .filter(|node| depths.get(&node.id).is_none_or(|depth| *depth == nearest)) + .collect() +} + +/// The line a Dart member's signature starts on, from where it starts, and +/// the line after it: past the annotations and `//` comments its node opens +/// with. KEEP-RUST: this grammar puts the annotations of a member with a body +/// inside the declaration its node is made of (`@visibleForTesting` above +/// `factory ProviderContainer.test(…)`), where upstream's node starts at the +/// signature, which is where upstream's getter, setter and constructor tests +/// read from (W4-DART-03c). +pub(crate) fn dart_member_head(node: &Node, context: &dyn ResolutionContext) -> (String, String) { + let text = source_lines( + &node.file_path, + node.start_line, + node.start_line + HEAD_LINES, + context, + ); + let first_end = text.find('\n').unwrap_or(text.len()); + let mut from = usize::try_from(node.start_column.max(0)) + .unwrap_or(0) + .min(first_end); + while !text.is_char_boundary(from) { + from -= 1; + } + let text = &text[from..]; + let rest = &text[leading_annotations() + .find(text) + .map_or(0, |found| found.end())..]; + let mut lines = rest.splitn(3, '\n'); + let signature = lines.next().unwrap_or("").to_string(); + let next = lines.next().unwrap_or("").to_string(); + (signature, next) +} + +/// Leading annotations and `//` comments of a declaration's head. +fn leading_annotations() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"^[\s\x{FEFF}]*(?:(?:@[0-9A-Za-z_$.]+(?:[\s\x{FEFF}]*\((?:[^()]|\([^()]*\))*\))?|//[^\n]*)[\s\x{FEFF}]*)*", + ) + .expect("dart annotations") + }) +} + +/// An `extension` head with no name: `extension on X`, or a generic +/// `extension on X`. +fn unnamed_extension() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"^extension[\s\x{FEFF}]*(?:<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)?[\s\x{FEFF}]*on(?:[^0-9A-Za-z_]|$)", + ) + .expect("dart unnamed extension") + }) +} + +/// The Dart `extension` declaration a class node stands for, read from its +/// head past the annotations it starts with (upstream `dartExtensionDecl`): +/// `Some(false)` for an unnamed `extension on X`, `None` for a class, mixin +/// or enum, or an `extension type`. +pub(crate) fn dart_extension_named(node: &Node, context: &dyn ResolutionContext) -> Option { + if node.language != Language::Dart || node.kind != NodeKind::Class { + return None; + } + let text = source_lines( + &node.file_path, + node.start_line, + node.end_line.min(node.start_line + 5), + context, + ); + extension_named_in(&text) +} + +/// [`dart_extension_named`] of a declaration's first lines. +fn extension_named_in(text: &str) -> Option { + let head = &text[leading_annotations() + .find(text) + .map_or(0, |found| found.end())..]; + let rest = head.strip_prefix("extension")?; + if rest + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') + { + return None; + } + if is_extension_type(head) { + return None; + } + Some(!unnamed_extension().is_match(head)) +} + +/// Whether a Dart method belongs to an unnamed `extension on X`, which +/// applies only in its own library (upstream `isDartUnnamedExtensionMember`; +/// the resolver asks [`crate::dart_libraries::in_same_dart_library`]). +pub(crate) fn is_dart_unnamed_extension_member( + method: &Node, + context: &dyn ResolutionContext, +) -> bool { + if method.language != Language::Dart || method.kind != NodeKind::Method { + return false; + } + let Some((owner_qn, _)) = method.qualified_name.rsplit_once("::") else { + return false; + }; + if owner_qn.is_empty() { + return false; + } + in_order(context.get_nodes_in_file_shared(&method.file_path)) + .into_iter() + .find(|node| { + node.qualified_name == owner_qn + && node.kind == NodeKind::Class + && node.start_line <= method.start_line + && node.end_line >= method.start_line + }) + .is_some_and(|owner| dart_extension_named(&owner, context) == Some(false)) +} + +/// `extension Name on Type {` on a declaration's first line, the type. +fn extension_on() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"(?:^|[^0-9A-Za-z_])extension[\s\x{FEFF}]+[0-9A-Za-z_]*[\s\x{FEFF}]*(?:<[^>]*>)?[\s\x{FEFF}]*on[\s\x{FEFF}]+([0-9A-Za-z_<>, ?]+?)[\s\x{FEFF}]*\{", + ) + .expect("dart extension on") + }) +} + +/// The owner a receiver must be named after for a Dart method: for an +/// extension's member, the type it is `on` (upstream #2207 +/// `receiverNamesOwner`), as `Owner::name`; `None` keeps the method's own. +pub(crate) fn dart_receiver_owner( + method: &Node, + context: &dyn ResolutionContext, +) -> Option { + if method.language != Language::Dart { + return None; + } + let owner = member_owner(&method.qualified_name).filter(|owner| !owner.is_empty())?; + let mut decls: Vec> = context + .get_nodes_by_name_shared(owner) + .into_iter() + .filter(|node| node.language == Language::Dart && node.file_path == method.file_path) + .collect(); + decls.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + let decl = decls.first()?; + let line = source_line(&decl.file_path, decl.start_line, context)?; + Some(format!("{}::{}", extension_on_in(&line)?, method.name)) +} + +/// The type an `extension Name on Type {` line is on, type arguments and +/// `?` dropped (`Rx` reads `RxString`). +fn extension_on_in(line: &str) -> Option { + let on = extension_on().captures(line)?.get(1)?.as_str(); + Some( + on.chars() + .filter(|c| !matches!(c, '<' | '>' | ',' | ' ' | '?')) + .collect(), + ) +} + +/// What another file's Dart resolution reads of one type declaration's head +/// (W4-DART-01): the supertypes it names, whether it is an extension, and +/// the type its name's first declaration line puts an extension `on`, type +/// arguments kept ([`dart_receiver_owner`]). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct HeadEntry { + pub name: String, + pub extension: bool, + pub supers: Vec, + pub on: Option, +} + +/// Key prefix of the Dart head rows (W4-DART-01). +pub const HEAD_ROW_KEY_PREFIX: &str = "dart_heads:"; + +/// The `project_metadata` key of `file_path`'s Dart head row. +pub fn head_row_key(file_path: &str) -> String { + format!("{HEAD_ROW_KEY_PREFIX}{file_path}") +} + +/// A Dart file's head row: every type declaration's head, in source order +/// (positions are no part of it, so an edit above a class changes nothing). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeadRow { + pub entries: Vec, +} + +impl HeadRow { + /// The row of a Dart file whose text is `source` and whose nodes are + /// `nodes`: what [`dart_head`] reads of each of its type declarations. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let lines: Vec<&str> = source.split('\n').collect(); + let mut decls: Vec<&Node> = nodes + .iter() + .filter(|node| node.language == Language::Dart && is_dart_type_kind(node.kind)) + .collect(); + decls.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + let mut in_file: Vec<&Node> = nodes + .iter() + .filter(|node| node.language == Language::Dart) + .collect(); + in_file.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + let line_at = |line: i64| -> &str { + usize::try_from(line - 1) + .ok() + .and_then(|index| lines.get(index)) + .map_or("", |text| text.trim_end_matches('\r')) + }; + let entries = decls + .into_iter() + .map(|decl| { + let first = usize::try_from(decl.start_line.max(1) - 1).unwrap_or(0); + let last = usize::try_from(decl.end_line.min(decl.start_line + HEAD_LINES)) + .unwrap_or(0) + .min(lines.len()); + let text = lines + .get(first..last.max(first)) + .unwrap_or_default() + .iter() + .map(|line| line.trim_end_matches('\r')) + .collect::>() + .join("\n"); + let head = dart_head(&text); + // As `dart_receiver_owner` reads it: the first declaration of + // the name in the file, of any kind. + let on = in_file + .iter() + .find(|node| node.name == decl.name) + .and_then(|first| extension_on_in(line_at(first.start_line))); + HeadEntry { + name: decl.name.clone(), + extension: head.extension, + supers: head.supers, + on, + } + }) + .collect(); + Self { entries } + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("dart head row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Expected values from upstream's `dartHeadOf` on the same text. + #[test] + fn a_dart_head_names_its_supertypes_past_comments_and_type_arguments() { + let cases: [(&str, &[&str], bool); 9] = [ + ("class CounterCubit extends Cubit {", &["Cubit"], false), + ( + "abstract class BlocBase implements Emittable {", + &["Emittable"], + false, + ), + ("mixin SingleMixin on Listenable {", &["Listenable"], false), + ( + "class ListNotifierSingle = ListNotifier with SingleMixin;", + &["ListNotifier", "SingleMixin"], + false, + ), + ( + "extension NotifierX on AnyNotifier {", + &["AnyNotifier"], + true, + ), + ( + "class Notifier<\n // the state\n S\n> //\n extends\n AnyNotifier<\n S\n > {", + &["AnyNotifier"], + false, + ), + ("extension on String {", &["String"], true), + ( + "extension type Meters(int value) implements num {", + &["num"], + false, + ), + ( + "@freezed /* x */ sealed class A extends B with C, D implements E {", + &["B", "C", "D", "E"], + false, + ), + ]; + for (text, supers, extension) in cases { + let found = dart_head(text); + assert_eq!(found.supers, supers, "{text:?}"); + assert_eq!(found.extension, extension, "{text:?}"); + } + } + + /// Expected values from upstream's `dartHeadOf`, `dartExtensionDecl` and + /// `receiverNamesOwner` patterns, run under Node on the same text + /// (/tmp/v055-dart-tools/heads.js). + #[test] + fn the_head_readers_match_upstreams_patterns() { + type Case<'a> = (&'a str, &'a [&'a str], bool, Option, Option<&'a str>); + let cases: [Case; 27] = [ + ( + "class A extends B with D implements E, F {", + &["B", "D", "E", "F"], + false, + None, + None, + ), + ( + "abstract base class X> extends W {", + &["W"], + false, + None, + None, + ), + ( + "class Q = R with S, T;", + &["R", "S", "T"], + false, + None, + None, + ), + ( + "mixin M on Base, Other {", + &["Base", "Other"], + false, + None, + None, + ), + ( + "enum E with M implements I { a, b }", + &["M", "I"], + false, + None, + None, + ), + ( + "extension on String {", + &["String"], + true, + Some(false), + Some("String"), + ), + ( + "extension on List {", + &["List"], + true, + Some(false), + None, + ), + ( + "extension Named on Map {", + &["Map"], + true, + Some(true), + Some("MapStringT"), + ), + ( + "@internal\nextension ContainerReadElement on ProviderContainer {", + &["ProviderContainer"], + true, + Some(true), + None, + ), + ( + "@Deprecated('x (y)')\n// note\nextension on Foo {", + &["Foo"], + true, + Some(false), + None, + ), + ( + "extension type Meters(int v) implements num {", + &["num"], + false, + None, + None, + ), + ( + "extension type const Id(String v) {", + &[], + false, + None, + None, + ), + ("class Plain {", &[], false, None, None), + ( + "class /* c */ A /* d */ extends /* e */ B {", + &["B"], + false, + None, + None, + ), + ( + "class A extends B // trailing\n implements C {", + &["B", "C"], + false, + None, + None, + ), + ("sealed class Result {", &[], false, None, None), + ( + "class A extends B>> with C {", + &["B", "C"], + false, + None, + None, + ), + ("class $Foo extends $Bar {", &["$Bar"], false, None, None), + ( + "class A implements B, C {", + &["B", "C"], + false, + None, + None, + ), + ("mixin class MC {", &[], false, None, None), + ("extensionFoo on X {", &["X"], false, None, None), + ("class A extends B with on {", &["B"], false, None, None), + ("/* x */ class C extends D {", &["D"], false, None, None), + ( + "extension X on Y {", + &["Y"], + true, + Some(true), + Some("YZ"), + ), + ("class A extends B {", &["B"], false, None, None), + ("extension\non Foo {", &["Foo"], true, Some(false), None), + ( + " extension on Bar {", + &["Bar"], + true, + Some(false), + Some("Bar"), + ), + ]; + for (text, supers, extension, named, on) in cases { + let head = dart_head(text); + assert_eq!(head.supers, supers, "{text:?}"); + assert_eq!(head.extension, extension, "{text:?}"); + assert_eq!(extension_named_in(text), named, "{text:?}"); + let first = text.split('\n').next().unwrap_or_default(); + assert_eq!(extension_on_in(first).as_deref(), on, "{text:?}"); + } + } + + #[test] + fn a_dart_identifier_is_a_word_or_dollar_name() { + assert!(is_dart_identifier("emit")); + assert!(is_dart_identifier("$foo")); + assert!(is_dart_identifier("_x1")); + assert!(!is_dart_identifier("a.b")); + assert!(!is_dart_identifier("1a")); + } +} diff --git a/crates/codegraph-resolve/src/destructured_calls.rs b/crates/codegraph-resolve/src/destructured_calls.rs new file mode 100644 index 00000000..4be574c2 --- /dev/null +++ b/crates/codegraph-resolve/src/destructured_calls.rs @@ -0,0 +1,330 @@ +//! A bare JS-family call through a name destructured from a call's result +//! (upstream #2163 `matchDestructuredCallResult`). +//! +//! `const { getDefaultActivityRoute } = useDefaultActivity(); +//! getDefaultActivityRoute()` binds the name locally, which rules out every +//! cross-file candidate, so the call resolved to nothing. It is the function +//! the callee returns under that key: one declared in the callee's own body, +//! else a top-level one of the callee's module it returns. The callee is +//! resolved through the file's imports, else found in the same file, else the +//! project's one function of that name (an alias the import resolver cannot +//! follow, like Nuxt's `~/`), and its source must return the key. A later +//! declaration of the name at the call's scope shadows the binding. +//! +//! The file's destructuring statements are read once per pass (upstream +//! re-blanks the text above each call; #2362's lesson: memoise per file). + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +use codegraph_core::types::{EdgeKind, Node, NodeKind}; +use regex::Regex; + +use crate::name_matcher::{range_within, same_language_family, same_range}; +use crate::source_facts::SourceFacts; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// One `const { … } = f(…)` statement. +#[derive(Debug)] +struct Binding { + /// Just past the `(` of its call. + end: usize, + /// The function it calls. + callee: String, + /// Where the block around it closes (the code's length at top level). + scope_end: usize, +} + +/// A file's destructuring statements, read once. +#[derive(Debug, Default)] +pub struct DestructuredFile { + bindings: Vec, + /// Each name a binding introduces → (binding, the key it reads), in file + /// order. + by_name: HashMap>, +} + +/// The function a bare call through a destructured name reaches, if any. +pub(crate) fn match_destructured_call_result( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let facts = context.source_facts(&reference.file_path)?; + let file = facts.js_destructured_calls(read_destructured_file)?; + let candidates = file.by_name.get(&reference.reference_name)?; + let code = facts.ts_blanked(); + let cut = call_offset(&facts, reference)?; + // The nearest binding above the call whose block is still open there. + let (binding, key) = candidates.iter().rev().find_map(|(index, key)| { + let binding = &file.bindings[*index]; + (binding.end <= cut && cut <= binding.scope_end).then_some((binding, key)) + })?; + destructured_call_target( + &binding.callee, + key, + code.get(binding.end..cut).unwrap_or_default(), + reference, + context, + ) +} + +/// The byte offset of the reference in its file's source. +fn call_offset(facts: &SourceFacts, reference: &RefView) -> Option { + let index = usize::try_from(reference.line).ok()?.checked_sub(1)?; + let start = facts.lines().raw_line_start(index)?; + let line = facts.raw_line(index)?; + let mut column = usize::try_from(reference.column) + .unwrap_or(0) + .min(line.len()); + while !line.is_char_boundary(column) { + column -= 1; + } + Some(start + column) +} + +fn read_destructured_file(facts: &SourceFacts) -> Option { + static PRESENT: OnceLock = OnceLock::new(); + static BINDING: OnceLock = OnceLock::new(); + static IDENTIFIER: OnceLock = OnceLock::new(); + static DEFAULT: OnceLock = OnceLock::new(); + if !PRESENT + .get_or_init(|| Regex::new(r"(?-u:\b)(?:const|let|var)\s*\{").expect("destructuring")) + .is_match(facts.source()) + { + return None; + } + let code = facts.ts_blanked(); + let binding = BINDING.get_or_init(|| { + Regex::new( + r"(?-u:\b)(?:const|let|var)\s*\{([^{}]*)\}\s*=\s*(?:await\s+)?([A-Za-z_$][0-9A-Za-z_$]*)\s*(?:<[^<>()]*>)?\s*\(", + ) + .expect("destructuring binding") + }); + let identifier = + IDENTIFIER.get_or_init(|| Regex::new(r"^[A-Za-z_$][0-9A-Za-z_$]*$").expect("identifier")); + let default = DEFAULT.get_or_init(|| Regex::new(r"\s*=.*$").expect("default")); + let found: Vec<(usize, usize, String, String)> = binding + .captures_iter(code) + .filter_map(|captures| { + let whole = captures.get(0)?; + Some(( + whole.start(), + whole.end(), + captures[2].to_string(), + captures[1].to_string(), + )) + }) + .collect(); + // Each binding's block: the `}` closing the innermost `{` open at its + // start, found in one pass over the code. + let mut open: Vec = Vec::new(); + let mut innermost: Vec> = vec![None; found.len()]; + let mut closes: HashMap = HashMap::new(); + let mut next = 0; + for (at, byte) in code.bytes().enumerate() { + while next < found.len() && found[next].0 == at { + innermost[next] = open.last().copied(); + next += 1; + } + match byte { + b'{' => open.push(at), + b'}' => { + if let Some(brace) = open.pop() { + closes.insert(brace, at); + } + } + _ => {} + } + } + let mut file = DestructuredFile::default(); + for (index, (_, end, callee, pattern)) in found.into_iter().enumerate() { + let scope_end = innermost[index].map_or(code.len(), |brace| { + closes.get(&brace).copied().unwrap_or(code.len()) + }); + file.bindings.push(Binding { + end, + callee, + scope_end, + }); + for part in pattern.split(',') { + let mut sides = part + .split(':') + .map(|side| default.replace(side.trim(), "").into_owned()); + let key = sides.next().unwrap_or_default(); + let local = sides.next().unwrap_or_else(|| key.clone()); + if identifier.is_match(&key) { + file.by_name.entry(local).or_default().push((index, key)); + } + } + } + Some(file) +} + +/// The function `callee` returns under `key`, for a bare call `rest` (the +/// code between the binding and the call) does not shadow. +fn destructured_call_target( + callee_name: &str, + key: &str, + rest: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let name = regex::escape(&reference.reference_name); + let shadow = Regex::new(&format!( + r"(?-u:\b)(?:const|let|var|function|class)\s+(?:{name}(?-u:\b)|\{{[^}}]*(?-u:\b){name}(?-u:\b))" + )) + .ok()?; + if shadow.is_match(rest) { + return None; + } + let imported = crate::import_resolver::resolve_via_import( + &RefView { + reference_name: callee_name.to_string(), + reference_kind: EdgeKind::Calls, + ..reference.clone() + }, + context, + ) + .and_then(|resolved| context.get_node_by_id_shared(&resolved.target_node_id)); + // Through the import; else the same file's; else the one function of that + // name in the project (an alias the import resolver cannot follow, like + // Nuxt's `~/composables/…`). The returned key is checked below either way. + let holders: Vec> = context + .get_nodes_by_name_shared(callee_name) + .into_iter() + .filter(|n| { + matches!( + n.kind, + NodeKind::Function | NodeKind::Constant | NodeKind::Variable + ) && same_language_family(n.language, reference.language) + }) + .collect(); + let callee = imported + .or_else(|| { + holders + .iter() + .find(|n| n.file_path == reference.file_path) + .cloned() + }) + .or_else(|| (holders.len() == 1).then(|| Arc::clone(&holders[0])))?; + if !same_language_family(callee.language, reference.language) { + return None; + } + let facts = context.source_facts(&callee.file_path)?; + let first = usize::try_from(callee.start_line).ok()?.checked_sub(1)?; + let last = usize::try_from(callee.end_line) + .ok()? + .min(facts.line_count()); + if first >= last { + return None; + } + let callee_text = facts.join_lines(first, last, "\n"); + let returns = Regex::new(&format!( + r"(?s)(?-u:\b)return\s*\{{.*?(?-u:\b){}(?-u:\b)", + regex::escape(key) + )) + .ok()?; + if !returns.is_match(&callee_text) { + return None; + } + let callable = |n: &Node| { + matches!( + n.kind, + NodeKind::Function | NodeKind::Method | NodeKind::Constant | NodeKind::Variable + ) + }; + let is_function = |n: &Node| matches!(n.kind, NodeKind::Function | NodeKind::Method); + let in_file = context.get_nodes_in_file_shared(&callee.file_path); + // Declared in the callee's own body, not inside a function of it. + let inner: Vec<&Arc> = in_file + .iter() + .filter(|n| { + n.name == key + && callable(n) + && n.id != callee.id + && range_within(n, &callee) + && !in_file.iter().any(|f| { + f.id != callee.id + && f.id != n.id + && is_function(f) + && range_within(f, &callee) + && range_within(n, f) + && !same_range(f, n) + }) + }) + .collect(); + // Else a top-level one of the callee's module. + let pool: Vec<&Arc> = if inner.is_empty() { + in_file + .iter() + .filter(|n| { + n.name == key + && callable(n) + && !n.qualified_name.contains("::") + && !in_file.iter().any(|f| { + is_function(f) && f.id != n.id && range_within(n, f) && !same_range(f, n) + }) + }) + .collect() + } else { + inner + }; + // A function before a value, in file order otherwise. + let target = pool + .iter() + .find(|n| n.kind == NodeKind::Function) + .or_else(|| pool.first())?; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: target.id.clone(), + confidence: 0.85, + resolved_by: ResolvedBy::InstanceMethod, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::read_destructured_file; + use crate::source_facts::SourceFacts; + + #[test] + fn bindings_name_their_keys_callees_and_blocks() { + let source = "const { login, signOut: leave, retries = 3 } = useAuth();\nfunction go() {\n const { t } = await useI18n();\n return t('x');\n}\nconst { 'quoted': q } = load();\n"; + let facts = SourceFacts::new(Arc::from(source)); + let file = read_destructured_file(&facts).expect("destructuring"); + let key = |local: &str| -> Vec<(String, String)> { + file.by_name + .get(local) + .map(|found| { + found + .iter() + .map(|(index, key)| (file.bindings[*index].callee.clone(), key.clone())) + .collect() + }) + .unwrap_or_default() + }; + assert_eq!(key("login"), [("useAuth".to_string(), "login".to_string())]); + assert_eq!( + key("leave"), + [("useAuth".to_string(), "signOut".to_string())] + ); + assert_eq!( + key("retries"), + [("useAuth".to_string(), "retries".to_string())] + ); + assert_eq!(key("t"), [("useI18n".to_string(), "t".to_string())]); + // A quoted key is no identifier. + assert!(key("q").is_empty()); + // The top-level binding's block is the whole file; `t`'s is `go`'s body. + assert_eq!(file.bindings[0].scope_end, facts.ts_blanked().len()); + assert_eq!( + file.bindings[1].scope_end, + source + .find("}\nconst { 'quoted'") + .expect("go's closing brace") + ); + assert!(read_destructured_file(&SourceFacts::new(Arc::from("const a = b();\n"))).is_none()); + } +} diff --git a/crates/codegraph-resolve/src/erlang_imports.rs b/crates/codegraph-resolve/src/erlang_imports.rs new file mode 100644 index 00000000..b499d37f --- /dev/null +++ b/crates/codegraph-resolve/src/erlang_imports.rs @@ -0,0 +1,238 @@ +//! Erlang calls written without a module across modules (upstream #2178 (b), +//! in the shape at `b635dd46`): another module's function is called bare +//! only through `-import(Mod, [f/N])`, or from a `.hrl` a module includes; +//! with neither, nothing the project declares. cowboy's +//! `-import(req_SUITE, [do_get/3])` went to compress_SUITE's `do_get/3`. + +use std::sync::OnceLock; + +use codegraph_core::generated_header::is_js_whitespace; +use regex::Regex; + +/// What one `-import(Mod, [f/N, …]).` attribute brings in: the module and +/// each `(name, arity)` its list names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ErlangImport { + pub(crate) module: String, + pub(crate) functions: Vec<(String, String)>, +} + +/// `/^-import\(\s*'?([A-Za-z_][\w@]*)'?\s*,\s*\[([^\]]*)\]\s*\)\s*\./gm`. +fn import_pattern() -> &'static Regex { + static PATTERN: OnceLock = OnceLock::new(); + PATTERN.get_or_init(|| { + Regex::new(r"(?m)^-import\(\s*'?([A-Za-z_][A-Za-z0-9_@]*)'?\s*,\s*\[([^\]]*)\]\s*\)\s*\.") + .expect("Erlang import pattern") + }) +} + +/// The `-import` attributes of an Erlang source, in order (upstream reads +/// them only when the text has `-import`). +pub(crate) fn erlang_imports_of(source: &str) -> Vec { + if !source.contains("-import") { + return Vec::new(); + } + import_pattern() + .captures_iter(source) + .map(|captures| ErlangImport { + module: captures[1].to_string(), + functions: listed_functions(&captures[2]), + }) + .collect() +} + +/// The `name/arity` entries of an import list, each read as Erlang writes +/// it: a quoted atom as one name (`'foo,bar'`, which may hold a comma or a +/// slash; `\'` escapes a quote), its quotes dropped as the extractor drops +/// them, else the bare atom; then `/` and the arity's digits. An entry that +/// reads otherwise is passed over to the next comma outside quotes, and a +/// `%` comment outside quotes is read as the blanks it stands for. +/// KEEP-RUST: upstream tests each called function against the list with +/// `(?:^|[\s,])'?name'?\s*/\s*arity\b`, which also finds `foo/2` inside +/// `'x,foo'/2`. +fn listed_functions(list: &str) -> Vec<(String, String)> { + let list = blank_comments(list); + let list = list.as_str(); + let bytes = list.as_bytes(); + let mut entries = Vec::new(); + let mut at = 0; + while at < bytes.len() { + if bytes[at] == b',' || list[at..].starts_with(is_js_whitespace) { + at += list[at..].chars().next().map_or(1, char::len_utf8); + continue; + } + let (entry, end) = read_entry(list, at); + entries.extend(entry); + at = next_comma(list, end); + } + entries +} + +/// `list` with each `%` comment outside a quoted atom blanked to the end +/// of its line. +fn blank_comments(list: &str) -> String { + let mut out = String::with_capacity(list.len()); + let (mut quoted, mut escaped, mut comment) = (false, false, false); + for c in list.chars() { + if comment { + if c == '\n' { + comment = false; + out.push(c); + } else { + out.push(' '); + } + continue; + } + if quoted { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '\'' { + quoted = false; + } + } else if c == '\'' { + quoted = true; + } else if c == '%' { + comment = true; + out.push(' '); + continue; + } + out.push(c); + } + out +} + +/// One `name/arity` entry starting at `at`, and where reading it stopped. +fn read_entry(list: &str, at: usize) -> (Option<(String, String)>, usize) { + let bytes = list.as_bytes(); + let (name, mut at) = if bytes[at] == b'\'' { + let mut end = at + 1; + while end < bytes.len() && bytes[end] != b'\'' { + end += if bytes[end] == b'\\' { 2 } else { 1 }; + } + if end >= bytes.len() { + return (None, bytes.len()); + } + (&list[at + 1..end], end + 1) + } else { + let len = list[at..] + .find(|c: char| c == ',' || c == '/' || is_js_whitespace(c)) + .unwrap_or(list.len() - at); + (&list[at..at + len], at + len) + }; + at += list[at..].len() - list[at..].trim_start_matches(is_js_whitespace).len(); + if !list[at..].starts_with('/') { + return (None, at); + } + at += 1; + at += list[at..].len() - list[at..].trim_start_matches(is_js_whitespace).len(); + let digits = list[at..].len() + - list[at..] + .trim_start_matches(|c: char| c.is_ascii_digit()) + .len(); + let bounded = !list[at + digits..].starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_'); + let entry = (!name.is_empty() && digits > 0 && bounded) + .then(|| (name.to_string(), list[at..at + digits].to_string())); + (entry, at + digits) +} + +/// The first comma at or after `at` outside a quoted atom, or the end. +fn next_comma(list: &str, mut at: usize) -> usize { + let bytes = list.as_bytes(); + let mut quoted = false; + while at < bytes.len() { + match bytes[at] { + b'\\' if quoted => at += 1, + b'\'' => quoted = !quoted, + b',' if !quoted => return at, + _ => {} + } + at += 1; + } + bytes.len() +} + +/// The module an Erlang file's `-import` attributes bring `name/arity` in +/// from, the first that does (upstream `erlangImportedModule`). +pub(crate) fn imported_module<'a>( + imports: &'a [ErlangImport], + name: &str, + arity: &str, +) -> Option<&'a str> { + imports + .iter() + .find(|import| { + import + .functions + .iter() + .any(|(listed, listed_arity)| listed == name && listed_arity == arity) + }) + .map(|import| import.module.as_str()) +} + +/// Whether `path` is an Erlang header (`/\.hrl$/`). +pub(crate) fn is_erlang_header(path: &str) -> bool { + path.ends_with(".hrl") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_import_attribute_is_read_as_written() { + let source = "-module(m).\n-import(req_SUITE, [do_get/3]).\n-import('ct_helper',\n [config/2,\n 'doc' / 1]).\n -import(indented, [x/1]).\n-import(broken, [y/1])\n"; + let imports = erlang_imports_of(source); + assert_eq!( + imports, + [ + ErlangImport { + module: "req_SUITE".to_string(), + functions: vec![("do_get".to_string(), "3".to_string())], + }, + ErlangImport { + module: "ct_helper".to_string(), + functions: vec![ + ("config".to_string(), "2".to_string()), + ("doc".to_string(), "1".to_string()), + ], + }, + ] + ); + assert_eq!(imported_module(&imports, "doc", "1"), Some("ct_helper")); + assert_eq!(imported_module(&imports, "do_get", "2"), None); + assert!(erlang_imports_of("-module(m).\n").is_empty()); + } + + #[test] + fn a_quoted_atom_is_one_name_whatever_it_holds() { + let pairs = |list: &str| -> Vec<(String, String)> { listed_functions(list) }; + let expect = |entries: &[(&str, &str)]| -> Vec<(String, String)> { + entries + .iter() + .map(|(name, arity)| ((*name).to_string(), (*arity).to_string())) + .collect() + }; + assert_eq!( + pairs("'foo,bar'/1, 'a/b'/2, plain/0, 'it\\'s'/3"), + expect(&[ + ("foo,bar", "1"), + ("a/b", "2"), + ("plain", "0"), + ("it\\'s", "3") + ]) + ); + // No `foo/2` inside the quoted atom, a malformed entry passed over. + assert_eq!( + pairs("'x,foo'/2, junk, ok/12x, f/1"), + expect(&[("x,foo", "2"), ("f", "1")]) + ); + // A comment between entries, one holding a comma, and a `%` inside a + // quoted atom, which is no comment. + assert_eq!( + pairs("foo/1, % note, not/9\n bar/2, '100%'/0 % trailing\n"), + expect(&[("foo", "1"), ("bar", "2"), ("100%", "0")]) + ); + } +} diff --git a/crates/codegraph-resolve/src/framework.rs b/crates/codegraph-resolve/src/framework.rs index e850874c..99c1ae47 100644 --- a/crates/codegraph-resolve/src/framework.rs +++ b/crates/codegraph-resolve/src/framework.rs @@ -93,6 +93,15 @@ pub trait FrameworkResolver: Sync { None } + /// The languages whose references `resolve()` sees, when they are not + /// [`Self::languages`] (upstream #2415 `resolveLanguages`): a framework's + /// conventions are its languages', so Strategy 1 asks only the resolvers + /// that read a reference's language. Defaults to [`Self::languages`]; a + /// resolver that declares neither stays universal. + fn resolve_languages(&self) -> Option<&[Language]> { + self.languages() + } + /// Detect whether the project uses this `FrameworkResolver`'s target — called /// once at startup (`detect`, `types.ts:163`). fn detect(&self, context: &dyn ResolutionContext) -> bool; @@ -127,6 +136,14 @@ pub trait FrameworkResolver: Sync { None } + /// Whether [`Self::extract`] of `file_path` reads files other than the one + /// it is handed, so its references can change while that file does not: a + /// sync re-extracts such a file rather than reusing its stored references + /// (W5-09e). Defaults to `false`. + fn extraction_reads_other_files(&self, _file_path: &str) -> bool { + false + } + /// Cross-file finalization pass run once after all extraction completes /// (`postExtract?`, `types.ts:183-196`). Returns nodes with mutated fields /// (node `id` preserved) for the orchestrator to persist. `None` when the diff --git a/crates/codegraph-resolve/src/frameworks/godot.rs b/crates/codegraph-resolve/src/frameworks/godot.rs index 41b751b8..abc4fc9b 100644 --- a/crates/codegraph-resolve/src/frameworks/godot.rs +++ b/crates/codegraph-resolve/src/frameworks/godot.rs @@ -201,6 +201,12 @@ impl FrameworkResolver for GodotResolver { false } + /// `project.godot`'s autoloads name scripts by `uid://`, which it maps + /// through every `.tscn` and `.gd.uid` file of the project. + fn extraction_reads_other_files(&self, file_path: &str) -> bool { + godot_project::is_project_godot(file_path) + } + /// Per-file Godot parsing dispatch. /// /// L1 (T3): when the file's basename is `project.godot`, delegate to diff --git a/crates/codegraph-resolve/src/frameworks/react.rs b/crates/codegraph-resolve/src/frameworks/react.rs index 315ca566..45fdb983 100644 --- a/crates/codegraph-resolve/src/frameworks/react.rs +++ b/crates/codegraph-resolve/src/frameworks/react.rs @@ -26,6 +26,18 @@ impl FrameworkResolver for ReactResolver { Some(&LANGS) } + /// Components, hooks and contexts are a script's (upstream #2211): halo's + /// Java `import org.springframework…SecurityContext` is no React context. + fn resolve_languages(&self) -> Option<&[Language]> { + const LANGS: [Language; 4] = [ + Language::TypeScript, + Language::JavaScript, + Language::Tsx, + Language::Jsx, + ]; + Some(&LANGS) + } + // Ports reactResolver.detect (react.ts:14-32). fn detect(&self, context: &dyn ResolutionContext) -> bool { if let Some(package_json) = context.read_file("package.json") { diff --git a/crates/codegraph-resolve/src/frameworks/tauri.rs b/crates/codegraph-resolve/src/frameworks/tauri.rs index 6c120b36..420dcab3 100644 --- a/crates/codegraph-resolve/src/frameworks/tauri.rs +++ b/crates/codegraph-resolve/src/frameworks/tauri.rs @@ -120,6 +120,20 @@ impl FrameworkResolver for TauriResolver { None } + /// Its `invoke` references are written in the JS-family files `extract()` + /// reads, and its commands in Rust (upstream #2415: a resolver lists the + /// languages its references are written in). + fn resolve_languages(&self) -> Option<&[Language]> { + const LANGS: [Language; 5] = [ + Language::TypeScript, + Language::Tsx, + Language::JavaScript, + Language::Jsx, + Language::Rust, + ]; + Some(&LANGS) + } + /// Ordered cheapest-first, returning `true` on the first hit. /// /// Probes 1-2 use `file_exists` rather than a `get_all_files` basename scan diff --git a/crates/codegraph-resolve/src/frameworks/vue.rs b/crates/codegraph-resolve/src/frameworks/vue.rs index 18a82da9..30df789b 100644 --- a/crates/codegraph-resolve/src/frameworks/vue.rs +++ b/crates/codegraph-resolve/src/frameworks/vue.rs @@ -84,6 +84,20 @@ impl FrameworkResolver for VueResolver { } // Ports vueResolver.resolve (vue.ts:103-188). + /// A Vue app's macros, auto-imports and components are its scripts' + /// (upstream #2210): mealie's Python `QueryFilterBuilder(...)` is not the + /// `QueryFilterBuilder.vue` component. + fn resolve_languages(&self) -> Option<&[Language]> { + const LANGS: [Language; 5] = [ + Language::Vue, + Language::JavaScript, + Language::TypeScript, + Language::Tsx, + Language::Jsx, + ]; + Some(&LANGS) + } + fn resolve(&self, reference: &RefView, context: &dyn ResolutionContext) -> Option { let name = &reference.reference_name; @@ -92,8 +106,12 @@ impl FrameworkResolver for VueResolver { return Some(self_resolved(reference, 1.0)); } - // Pattern 2: Nuxt auto-imported composables (vue.ts:114-122). - if NUXT_AUTO_IMPORTS.contains(&name.as_str()) { + // Pattern 2: Nuxt auto-imported composables (vue.ts:114-122), unless + // the file declares its own function of that name, which shadows the + // auto-import: elk's lists page calls its own `clearError(true)`, not + // Nuxt's (upstream #2357). + if NUXT_AUTO_IMPORTS.contains(&name.as_str()) && !declares_own_function(reference, context) + { return Some(self_resolved(reference, 1.0)); } @@ -231,6 +249,21 @@ fn dep_present(pkg: &serde_json::Value, keys: &[&str]) -> bool { } /// A ref that resolves to its own source node (compiler macros / auto-imports). +/// Does the reference's own file declare a function (or a const holding one) +/// of the referenced name (upstream #2357 `declaresOwnFunction`)? +fn declares_own_function(reference: &RefView, context: &dyn ResolutionContext) -> bool { + context + .get_nodes_in_file_shared(&reference.file_path) + .iter() + .any(|node| { + node.name == reference.reference_name + && matches!( + node.kind, + NodeKind::Function | NodeKind::Constant | NodeKind::Variable + ) + }) +} + fn self_resolved(reference: &RefView, confidence: f64) -> ResolvedRef { ResolvedRef { original: reference.clone(), diff --git a/crates/codegraph-resolve/src/go_calls.rs b/crates/codegraph-resolve/src/go_calls.rs new file mode 100644 index 00000000..5f55b034 --- /dev/null +++ b/crates/codegraph-resolve/src/go_calls.rs @@ -0,0 +1,560 @@ +//! How a Go call is written, read back from its line (W4-GO-03). +//! +//! The extractor keeps one receiver level of a call (#1496), and records a +//! call on an expression — `child.Flags().String("f", …)`, +//! `w.Header().Get(…)` — by its bare name at the column where the expression +//! starts. What can answer such a call is read from the source instead +//! (upstream #2167's Go half, with #2177's later-line links and `Header()`). + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; + +use crate::types::{RefView, ResolutionContext}; + +/// How a bare-named Go call is written (upstream `rustGoCallShape`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum GoCallShape { + /// After `::`: left to the path strategies. + Path, + /// No `.` before it: a function, a conversion, a closure. + Bare, + /// After a `.`, on the receiver written before it, with call and type + /// arguments dropped (`c.Flags`, `JSON`). + Chained { receiver: String }, +} + +/// Methods of Go's standard types and interfaces — `fmt.Stringer`, `error`, +/// `http.ResponseWriter`, `sync` locks, `reflect`, `time` (upstream +/// `GO_STD_METHODS`). Ones a project type often carries itself (`Get`, `Set`, +/// `Close`, `Value`, `Next`) are left out: gin's `c.Set(…)` is its Context's. +pub(crate) const GO_STD_METHODS: [&str; 33] = [ + "String", + "Error", + "Unwrap", + "Is", + "As", + "Header", + "WriteHeader", + "WriteString", + "Lock", + "Unlock", + "RLock", + "RUnlock", + "Err", + "Deadline", + "Int", + "Bool", + "Float64", + "Int64", + "Uint64", + "Bytes", + "Len", + "Cap", + "Seconds", + "Unix", + "Before", + "After", + "Equal", + "IsNil", + "IsValid", + "Elem", + "NumField", + "Interface", + "Kind", +]; + +/// Whether `name` is one of Go's standard-library method names. +pub(crate) fn is_go_std_method(name: &str) -> bool { + GO_STD_METHODS.contains(&name) +} + +fn is_identifier(name: &str) -> bool { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +/// The offset of the first `name(` (or `name::<`, `name!`) in `line` that no +/// word character or `$` precedes (upstream's +/// `(? Option { + line.match_indices(name).map(|(at, _)| at).find(|&at| { + let free = line[..at] + .chars() + .next_back() + .is_none_or(|before| !(is_word_char(before) || before == '$')); + let after = line[at + name.len()..].trim_start(); + free && (after.starts_with('(') || after.starts_with("::<") || after.starts_with('!')) + }) +} + +/// How the Go call `reference` is written at its site, `None` when it is no +/// bare-named Go call or its line does not spell it. +pub(crate) fn go_call_shape( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let name = reference.reference_name.as_str(); + if reference.language != Language::Go + || reference.reference_kind != EdgeKind::Calls + || !is_identifier(name) + { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + let index = usize::try_from(reference.line.checked_sub(1)?).ok()?; + let first = facts.raw_line(index)?; + let column = usize::try_from(reference.column.max(0)).ok()?; + let mut line = first; + let mut start = if first + .get(column..) + .is_some_and(|rest| rest.starts_with(name)) + { + Some(column) + } else { + call_of(first, name) + }; + // A link further down a chain the call's line starts — `New()\n\t.Use(…)` + // — is recorded at the chain's first line (upstream #2177). + let mut next = index + 1; + while start.is_none() && next < index + 21 { + let Some(candidate) = facts.raw_line(next) else { + break; + }; + next += 1; + if !candidate.trim_start().starts_with('.') { + continue; + } + if let Some(at) = call_of(candidate, name) { + // Named after the chain's head. + if candidate[..at].trim() == "." { + let head = first.trim_end().trim_end_matches(['?', ';']); + return Some(GoCallShape::Chained { + receiver: go_receiver_name(head), + }); + } + line = candidate; + start = Some(at); + } + } + let before = &line[..start?]; + let trimmed = before.trim_end(); + if trimmed.ends_with("::") { + return Some(GoCallShape::Path); + } + if !trimmed.ends_with('.') { + return Some(GoCallShape::Bare); + } + Some(GoCallShape::Chained { + receiver: go_receiver_name(before), + }) +} + +/// The receiver a Go `….name(` is written on, as its dotted identifiers with +/// arguments dropped — `c.Flags()` → `c.Flags`, a composite literal +/// `(JSON{data})` → `JSON` — read backwards to the expression's start +/// (upstream `rustGoReceiverName`). +fn go_receiver_name(text: &str) -> String { + let src = text.trim_end(); + let src = src.strip_suffix('.').unwrap_or(src).trim_end(); + let chars: Vec = src.chars().collect(); + let mut out: Vec = Vec::new(); + let mut i = chars.len(); + while i > 0 { + let ch = chars[i - 1]; + if matches!(ch, ')' | ']' | '}') { + let open = match ch { + ')' => '(', + ']' => '[', + _ => '{', + }; + let mut depth = 0usize; + let mut j = i; + let mut found = None; + while j > 0 { + let at = chars[j - 1]; + if at == ch { + depth += 1; + } else if at == open { + depth -= 1; + if depth == 0 { + found = Some(j - 1); + break; + } + } + j -= 1; + } + let Some(open_at) = found else { + return String::new(); + }; + // `(JSON{data})` — a parenthesized composite literal names its + // type. + if ch == ')' && out.is_empty() { + let group: String = chars[open_at..i].iter().collect(); + if let Some(literal) = composite_literal_type(&group) { + return literal; + } + } + i = open_at; + } else if is_word_char(ch) || matches!(ch, '$' | '.' | ':' | '!') { + if ch != '!' { + out.push(ch); + } + i -= 1; + } else { + break; + } + } + out.reverse(); + let out: String = out.into_iter().collect(); + out.trim_matches(['.', ':']).to_string() +} + +/// The type of a parenthesized composite literal, `(&JSON{data})` → `JSON` +/// (upstream's `/^\(\s*&?\s*([A-Za-z_][\w.]*)\s*\{/`). +fn composite_literal_type(group: &str) -> Option { + let rest = group.strip_prefix('(')?.trim_start(); + let rest = rest.strip_prefix('&').unwrap_or(rest).trim_start(); + let first = rest.chars().next()?; + if !(first.is_ascii_alphabetic() || first == '_') { + return None; + } + let end = rest + .find(|c: char| !(is_word_char(c) || c == '.')) + .unwrap_or(rest.len()); + rest[end..] + .trim_start() + .starts_with('{') + .then(|| rest[..end].to_string()) +} + +/// Whether a Go call of that shape can mean `node` (upstream +/// `isRustGoCallTarget`, its Go half). A bare call never reaches a method, +/// which Go reaches only through a receiver. A chained call reaches a method, +/// never a free function, and a standard-library method name only on a +/// receiver named after its owner: cobra's `c.Flags().String(…)` is no test +/// type's `String`, but `c.Root().Name()` is `Command::Name`. On +/// `w.Header()` or `r.Header`, `Get` / `Set` / … are net/http's Header map's +/// (upstream #2177). +pub(crate) fn is_go_call_target(node: &Node, shape: &GoCallShape) -> bool { + let member = node.kind == NodeKind::Method; + match shape { + GoCallShape::Path => true, + GoCallShape::Bare => !member, + GoCallShape::Chained { receiver } => { + if !member { + return node.kind != NodeKind::Function; + } + if node.language == Language::Go + && (receiver == "Header" || receiver.ends_with(".Header")) + && matches!( + node.name.as_str(), + "Get" | "Set" | "Add" | "Del" | "Values" | "Clone" | "Write" + ) + { + return false; + } + !is_go_std_method(&node.name) + || matches!(receiver.as_str(), "self" | "Self") + || (!receiver.is_empty() + && crate::kotlin_scope::shares_receiver_word(receiver, node)) + } + } +} + +/// How many lines past a Go call's first its chain is read on (upstream +/// `GO_CHAIN_LINES`). +const GO_CHAIN_LINES: usize = 30; + +/// One link of a Go expression's chain (upstream `GoChainLink`). +#[derive(Debug, Clone, PartialEq, Eq)] +enum ChainLink { + /// The operand: a name or a parenthesized expression. + Operand(String), + /// `.name`. + Select(String), + /// `.(T)`, with the type as written. + Assert(String), + /// `(…)`. + Call, + /// `[…]`. + Index, +} + +/// The type a Go call's receiver is asserted to, as written (`KVServer`, +/// `*pipe`, `storage.Store`; upstream `goAssertedType`, #2444). Every call of a +/// chain records the chain's start, so the call is a `.name(…)` link of the +/// chain read from there, and the link before it is the assertion. Two links +/// of one name in a chain (`b.(*Builder).Add(1).Add(2)`) are told apart by +/// nothing, and both are taken for the one made through the assertion: that +/// call is there either way. `None` when no link of the name follows an +/// assertion, or no chain starts at the column. +pub(crate) fn go_asserted_type( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let facts = context.source_facts(&reference.file_path)?; + let index = usize::try_from(reference.line.checked_sub(1)?).ok()?; + if index >= facts.line_count() { + return None; + } + let line = facts.line(index); + let column = usize::try_from(reference.column.max(0)).ok()?; + if column >= line.len() || !line.is_char_boundary(column) { + return None; + } + // Most calls through an expression assert nothing: no `.(` after the + // column, nor a chain that goes on past the line. + if !line[column..].contains(".(") && !line.trim_end().ends_with('.') { + return None; + } + let end = (index + GO_CHAIN_LINES + 1).min(facts.line_count()); + let text = facts.join_lines(index, end, "\n"); + let links = go_chain_links(&text, column); + links.windows(3).find_map(|window| match window { + [ + ChainLink::Assert(asserted), + ChainLink::Select(name), + ChainLink::Call, + ] if *name == reference.reference_name => Some(asserted.clone()), + _ => None, + }) +} + +/// The links of the Go expression that starts at `start`: its operand (a name +/// or a parenthesized expression), then each selector, type assertion, call +/// and index. A line break ends it, except right after a `.`, where Go +/// inserts no semicolon (upstream `goChainLinks`). +fn go_chain_links(text: &str, start: usize) -> Vec { + let bytes = text.as_bytes(); + let mut links = Vec::new(); + let mut i = start; + let name = |i: &mut usize| -> Option { + let first = *bytes.get(*i)?; + if !(first.is_ascii_alphabetic() || first == b'_') { + return None; + } + let begin = *i; + while *i < bytes.len() && (bytes[*i].is_ascii_alphanumeric() || bytes[*i] == b'_') { + *i += 1; + } + Some(text[begin..*i].to_string()) + }; + let group = |i: &mut usize| -> Option { + let close = go_closing_bracket(text, *i)?; + let inner = text[*i + 1..close].to_string(); + *i = close + 1; + Some(inner) + }; + let operand = if bytes.get(i) == Some(&b'(') { + group(&mut i) + } else { + name(&mut i) + }; + let Some(operand) = operand else { + return links; + }; + links.push(ChainLink::Operand(operand)); + loop { + while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') { + i += 1; + } + match bytes.get(i) { + Some(b'.') => { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if bytes.get(i) == Some(&b'(') { + match group(&mut i) { + Some(asserted) => { + links.push(ChainLink::Assert(asserted.trim().to_string())) + } + None => break, + } + } else { + match name(&mut i) { + Some(member) => links.push(ChainLink::Select(member)), + None => break, + } + } + } + Some(b'(') => { + // A call whose arguments run past what was read is still a + // call. + links.push(ChainLink::Call); + if group(&mut i).is_none() { + break; + } + } + Some(b'[') => { + links.push(ChainLink::Index); + if group(&mut i).is_none() { + break; + } + } + _ => break, + } + } + links +} + +/// The bracket closing the one at `open`, past Go strings, runes and +/// comments; `None` when it does not close (upstream `goClosingBracket`). +fn go_closing_bracket(text: &str, open: usize) -> Option { + let bytes = text.as_bytes(); + let mut depth = 0usize; + let mut i = open; + while i < bytes.len() { + match bytes[i] { + quote @ (b'"' | b'\'') => { + i += 1; + while i < bytes.len() && bytes[i] != quote && bytes[i] != b'\n' { + if bytes[i] == b'\\' { + i += 1; + } + i += 1; + } + } + b'`' => { + i += 1 + text[i + 1..].find('`')?; + } + b'/' if bytes.get(i + 1) == Some(&b'/') => { + i += text[i..].find('\n')?; + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i += 2 + text[i + 2..].find("*/")?; + i += 1; + } + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(i); + } + } + _ => {} + } + i += 1; + } + None +} + +/// An asserted type as written: `KVServer`, `*pipe`, `storage.Store`, +/// `*List[int]` → `(qualifier, name)`; `None` for a type literal +/// (`interface{ Flush() }`; upstream's +/// `/^\*?\s*(?:([A-Za-z_]\w*)\s*\.\s*)?([A-Za-z_]\w*)\s*(?:\[[\s\S]*\])?$/`). +pub(crate) fn asserted_type_name(asserted: &str) -> Option<(Option<&str>, &str)> { + let rest = asserted.strip_prefix('*').unwrap_or(asserted).trim_start(); + let word = |text: &str| -> usize { + let bytes = text.as_bytes(); + if !bytes + .first() + .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_') + { + return 0; + } + bytes + .iter() + .position(|b| !(b.is_ascii_alphanumeric() || *b == b'_')) + .unwrap_or(bytes.len()) + }; + let first_end = word(rest); + if first_end == 0 { + return None; + } + let (first, mut tail) = rest.split_at(first_end); + let mut result = (None, first); + let after = tail.trim_start(); + if let Some(after_dot) = after.strip_prefix('.') { + let after_dot = after_dot.trim_start(); + let second_end = word(after_dot); + if second_end == 0 { + return None; + } + let (second, rest) = after_dot.split_at(second_end); + result = (Some(first), second); + tail = rest; + } + let tail = tail.trim(); + if tail.is_empty() || (tail.starts_with('[') && tail.ends_with(']')) { + Some(result) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_chain_is_read_through_assertions_calls_and_indexes() { + let text = "srv.(KVServer).Range(ctx, in)"; + assert_eq!( + go_chain_links(text, 0), + [ + ChainLink::Operand("srv".into()), + ChainLink::Assert("KVServer".into()), + ChainLink::Select("Range".into()), + ChainLink::Call, + ] + ); + let text = "c.Reader.(*pipe).Close() // a \")\" here"; + assert_eq!( + go_chain_links(text, 0)[2], + ChainLink::Assert("*pipe".into()) + ); + let text = "b := srv.(*Builder).\n\t\tAdd(1)"; + assert_eq!( + go_chain_links(text, 5), + [ + ChainLink::Operand("srv".into()), + ChainLink::Assert("*Builder".into()), + ChainLink::Select("Add".into()), + ChainLink::Call, + ] + ); + assert_eq!( + go_chain_links("items[i].(fmt.Stringer).String()", 0)[1], + ChainLink::Index + ); + assert_eq!(go_closing_bracket("(a, \"(\", ')', `)`)", 0), Some(17)); + } + + #[test] + fn an_asserted_type_is_a_name_or_a_literal() { + assert_eq!(asserted_type_name("KVServer"), Some((None, "KVServer"))); + assert_eq!(asserted_type_name("*pipe"), Some((None, "pipe"))); + assert_eq!( + asserted_type_name("storage.Store"), + Some((Some("storage"), "Store")) + ); + assert_eq!(asserted_type_name("*List[int]"), Some((None, "List"))); + assert_eq!(asserted_type_name("interface{ Flush() }"), None); + } + + #[test] + fn a_receiver_is_read_back_without_its_arguments() { + assert_eq!(go_receiver_name("\tc.GetLogger()."), "c.GetLogger"); + assert_eq!(go_receiver_name("child.Flags()."), "child.Flags"); + assert_eq!(go_receiver_name("return (JSON{data})."), "JSON"); + assert_eq!(go_receiver_name("x := (&Render{w: w})."), "Render"); + assert_eq!(go_receiver_name("\tw.Header()."), "w.Header"); + assert_eq!(go_receiver_name("items[i]."), "items"); + assert_eq!(go_receiver_name("f(a, b)."), "f"); + } + + #[test] + fn a_call_of_a_name_is_found_past_receivers() { + assert_eq!(call_of("\tc.GetLogger().Error(\"x\")", "Error"), Some(15)); + assert_eq!(call_of("\tWalk(v, n)", "Walk"), Some(1)); + assert_eq!(call_of("\tmyWalk(v)", "Walk"), None); + assert_eq!(call_of("\tWalk := 1", "Walk"), None); + } +} diff --git a/crates/codegraph-resolve/src/go_packages.rs b/crates/codegraph-resolve/src/go_packages.rs new file mode 100644 index 00000000..8a28e3a4 --- /dev/null +++ b/crates/codegraph-resolve/src/go_packages.rs @@ -0,0 +1,1781 @@ +//! Go packages (upstream #2361, for #2323 and #2322; the field chains of +//! #1276/#1316). +//! +//! Go keeps one package per directory, and a Go type name is unique only +//! within its package: each package may have its own `server`, `handler` or +//! `DAO`. A type's methods are declared in its own package, so a receiver +//! typed by its declaration reaches the methods its declaring package has +//! (the caller's own for a bare type, the imported one for `store.Manager`), +//! then those of the types its declaration embeds. A field chain +//! `s.db.CreateItem()` reaches the method on the type the field's declaration +//! names, in the package that declares it. An import path names a package of +//! one of the project's modules: the module declaring the longest prefix of +//! it, the importing file's own module breaking a tie; a module under +//! `testdata/`, or under a directory named with a leading `_` or `.`, serves +//! only its own files. A name written through one of the project's packages +//! is that package's. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; + +use crate::types::{GoModule, ImportMapping, RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// The memos of one resolution pass. +#[derive(Debug, Default)] +pub struct GoTables { + embeds: Mutex>>>, + package_dirs: Mutex>>, +} + +impl GoTables { + pub fn new() -> Self { + Self::default() + } +} + +/// A Go type a declaration names, with the directory of the package that +/// declares it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoTypeRef { + pub name: String, + pub pkg_dir: String, +} + +/// Go builtin and predeclared types, which never carry a project method. +pub(crate) const GO_BUILTIN_FIELD_TYPES: [&str; 26] = [ + "string", + "bool", + "byte", + "rune", + "error", + "any", + "int", + "int8", + "int16", + "int32", + "int64", + "uint", + "uint8", + "uint16", + "uint32", + "uint64", + "uintptr", + "float32", + "float64", + "complex64", + "complex128", + "chan", + "map", + "func", + "struct", + "interface", +]; + +fn is_word_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +/// `^[A-Za-z_]\w*$` with ASCII `\w`. +fn is_identifier(text: &str) -> bool { + text.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') + && text.bytes().all(is_word_byte) +} + +/// The directory of a Go file, which is its package (`""` at the project +/// root; upstream `goPackageDir`). +pub fn package_dir_of(file_path: &str) -> String { + crate::pathutil::dirname(&file_path.replace('\\', "/")) +} + +/// Whether the go tool ignores a module directory: one under `testdata/` or a +/// directory named with a leading `_` or `.`. +fn is_ignored_module_dir(dir: &str) -> bool { + dir.split('/').any(|segment| { + segment == "testdata" || segment.starts_with('_') || segment.starts_with('.') + }) +} + +/// The module of the nearest `go.mod` at or above `dir`. +fn nearest_module<'m>(dir: &str, modules: &'m [GoModule]) -> Option<&'m GoModule> { + modules + .iter() + .filter(|module| { + module.dir.is_empty() + || dir == module.dir + || dir.starts_with(&format!("{}/", module.dir)) + }) + .max_by_key(|module| module.dir.len()) +} + +/// The module an import path belongs to (upstream `findGoModuleForImport`): +/// the one whose module path equals it or is a `/`-bounded prefix of it, the +/// longest such path winning, as Go has it; two declaring the same path +/// prefer `own`, the importing file's module. A module the go tool ignores +/// answers only its own files. +fn owning_go_module<'m>( + import_path: &str, + modules: &'m [GoModule], + own: Option<&GoModule>, +) -> Option<&'m GoModule> { + let is_own = |module: &GoModule| own.is_some_and(|own| own.dir == module.dir); + let mut best: Option<&GoModule> = None; + for module in modules + .iter() + .filter(|module| !is_ignored_module_dir(&module.dir) || is_own(module)) + { + if import_path != module.module_path + && !import_path.starts_with(&format!("{}/", module.module_path)) + { + continue; + } + let longer = best.is_none_or(|best| module.module_path.len() > best.module_path.len()); + let tie_to_own = best + .is_some_and(|best| module.module_path.len() == best.module_path.len()) + && is_own(module); + if longer || tie_to_own { + best = Some(module); + } + } + best +} + +/// The project directory of `import_path` inside `module`: the module's own +/// directory followed by the rest of the import path. +fn package_dir_in(import_path: &str, module: &GoModule) -> String { + let below = import_path + .strip_prefix(&module.module_path) + .unwrap_or_default() + .trim_start_matches('/'); + match (module.dir.as_str(), below) { + ("", rest) => rest.to_string(), + (dir, "") => dir.to_string(), + (dir, rest) => format!("{dir}/{rest}"), + } +} + +/// The project directory of the Go package `import_path` names, when it is a +/// package of one of the project's modules, else `None` (the standard +/// library, a third-party module, a project with no `go.mod`; upstream +/// `getGoPackageDir`). The module declaring the longest prefix of the path +/// takes it; two declaring the same path prefer `from_file`'s own. +pub fn go_import_package_dir( + import_path: &str, + from_file: Option<&str>, + context: &dyn ResolutionContext, +) -> Option { + let modules = context.get_go_modules(); + let own = from_file.and_then(|file| nearest_module(&package_dir_of(file), &modules)); + let key = ( + import_path.to_string(), + own.map(|module| module.dir.clone()).unwrap_or_default(), + ); + let tables = context.go_tables(); + if let Some(hit) = tables + .package_dirs + .lock() + .ok() + .and_then(|memo| memo.get(&key).cloned()) + { + return hit; + } + let dir = owning_go_module(import_path, &modules, own) + .map(|module| package_dir_in(import_path, module)); + if let Ok(mut memo) = tables.package_dirs.lock() { + memo.insert(key, dir.clone()); + } + dir +} + +/// The project directory of the package `file_path` imports as `qualifier` +/// (upstream `goImportPackageDir`): `None` when the file has no such import, +/// `Some(None)` when the import is no package of the project's modules. +pub(crate) fn go_qualifier_package_dir( + qualifier: &str, + file_path: &str, + context: &dyn ResolutionContext, +) -> Option> { + let imports = context.get_import_mappings(file_path, Language::Go); + let import = imports + .iter() + .find(|import| import.local_name == qualifier)?; + Some(go_import_package_dir( + &import.source, + Some(file_path), + context, + )) +} + +/// The package directory that declares a Go type spelled `raw` in +/// `file_path` (upstream `goDeclaredTypePackage`): the file's own for a bare +/// name (`server`, `*Store`), the imported package for a qualified one +/// (`store.Manager`) when it is a project package. `None` when that cannot be +/// told, which leaves resolution as it was. +pub(crate) fn go_declared_type_package( + raw: Option<&str>, + file_path: &str, + context: &dyn ResolutionContext, +) -> Option { + let name: String = raw? + .chars() + .filter(|c| !matches!(c, '*' | '&') && !c.is_whitespace()) + .collect(); + match name.split_once('.') { + None => Some(package_dir_of(file_path)), + Some((qualifier, _)) => go_qualifier_package_dir(qualifier, file_path, context).flatten(), + } +} + +/// The identifier right before a trailing `.` of `prefix`, itself not +/// written after another word or `.`, a variadic `...` aside (upstream's +/// `/(?:^|[^\w.]|\.{3})([A-Za-z_]\w*)\.$/`, #2408: `...chunks.Meta` is +/// written through `chunks`). +fn qualifier_before(prefix: &str) -> Option<&str> { + let head = prefix.strip_suffix('.')?; + let start = head + .bytes() + .rposition(|b| !is_word_byte(b)) + .map_or(0, |at| at + 1); + let qualifier = &head[start..]; + if !is_identifier(qualifier) { + return None; + } + if head[..start].ends_with('.') && !head[..start].ends_with("...") { + return None; + } + Some(qualifier) +} + +/// Whether `name` is written on `line` with nothing before it but a non-word +/// character other than `.` (upstream's `(? bool { + line.match_indices(name).any(|(at, _)| { + let before_ok = line[..at] + .bytes() + .next_back() + .is_none_or(|b| !is_word_byte(b) && b != b'.'); + let after_ok = line[at + name.len()..] + .bytes() + .next() + .is_none_or(|b| !is_word_byte(b)); + before_ok && after_ok + }) +} + +/// The first qualifier `name` is written through on `line` (upstream's +/// `/(?:^|[^\w.])([A-Za-z_]\w*)\.name\b/`). +fn first_qualifier_of<'l>(line: &'l str, name: &str) -> Option<&'l str> { + let dotted = format!(".{name}"); + line.match_indices(&dotted).find_map(|(at, _)| { + let after_ok = line[at + dotted.len()..] + .bytes() + .next() + .is_none_or(|b| !is_word_byte(b)); + if !after_ok { + return None; + } + qualifier_before(&line[..=at]) + }) +} + +/// The package name a Go reference is written through, as spelled — the +/// operand of the selector it is written as, or else the qualifier right +/// before the name at the reference's column, or the line's only spelling of +/// the name — read from its line, since the index keeps only the name +/// (upstream `goRefQualification`'s `written`, #2397, #2448). +fn go_written_qualifier(reference: &RefView, context: &dyn ResolutionContext) -> Option { + if reference.language != Language::Go || reference.reference_kind == EdgeKind::Imports { + return None; + } + let name = reference.reference_name.rsplit('.').next()?; + if !is_identifier(name) { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + let index = usize::try_from(reference.line.checked_sub(1)?) + .ok() + .filter(|&index| index < facts.line_count())?; + let line = facts.line(index); + let at = usize::try_from(reference.column.max(0)).ok()?; + if let Some(written) = dotted_qualifier(&reference.reference_name, line, at, name) { + return written.map(str::to_string); + } + if line.get(at..).is_some_and(|rest| rest.starts_with(name)) { + qualifier_before(&line[..at]).map(str::to_string) + } else if !written_bare(line, name) { + first_qualifier_of(line, name).map(str::to_string) + } else { + None + } +} + +/// What a dotted Go reference is written through: upstream records a +/// selector of one or two hops (`pkg.F`, `x.f.M`) dotted, and only its first +/// segment can be a package, whatever else the line writes — `perr` in +/// `klog.Error(perr.Error())`, `cmp` in `cmp.Diff(…)` beside a `Diff:` +/// (upstream #2448). The extractor keeps a chain's last receiver segment +/// (#1496), so the operand is read back at the column, on to the next line +/// after a line-final `.`. A chain of more hops, which upstream records bare, +/// is read as a bare name is (`None`); a chain whose root is no plain name — a +/// call's result (`Chain(…).Handler`, `makeOuter().bar.Run`), a conversion, an +/// index — has no package. Only where the line shows no chain at the column +/// is the stored name's head taken, as upstream takes it. +fn dotted_qualifier<'r>( + reference_name: &'r str, + line: &'r str, + column: usize, + name: &str, +) -> Option> { + let (head, _) = reference_name.split_once('.')?; + let stored_head = Some(is_identifier(head).then_some(head)); + let Some(before) = line.get(..column) else { + return stored_head; + }; + // A reference recorded at its own name with its whole path written + // before it — a method value `middleware.Logger`, `c.store.Fetch` — is + // written through that path's first segment, as upstream reads it. + if line[column..].starts_with(name) + && let Some(start) = (column + name.len()).checked_sub(reference_name.len()) + && line.get(start..column + name.len()) == Some(reference_name) + { + let lead = line[..start].trim_end_matches([' ', '\t']); + let free = lead + .bytes() + .next_back() + .is_none_or(|byte| !(is_word_byte(byte) || byte == b'.')); + return if free { stored_head } else { Some(None) }; + } + // A column inside a chain: its root is not on show. + if before.trim_end_matches([' ', '\t']).ends_with('.') { + return Some(None); + } + // A conversion `(*pkg.T)(x)` spells its type, the stored name, inside + // the parentheses; any other parenthesized root has no package. + if let Some(inner) = line[column..].strip_prefix('(') { + let inner = inner.trim_start(); + let inner = inner.strip_prefix(['*', '&']).unwrap_or(inner).trim_start(); + let converts = inner + .strip_prefix(reference_name) + .is_some_and(|after| after.trim_start().starts_with(')')); + return if converts { stored_head } else { Some(None) }; + } + let (links, end) = selector_chain(line, column); + if links.is_empty() { + return stored_head; + } + let rest = line[end..].trim_start(); + // The links a chain has, counting the call's own name. + let hops = if links.last() == Some(&name) { + links.len() + } else if rest.starts_with(['(', '[']) { + return Some(None); + } else if rest == "." { + links.len() + 1 + } else { + return stored_head; + }; + match hops { + 2 | 3 => Some(Some(links[0])), + 0 | 1 => stored_head, + _ => None, + } +} + +/// What a Go reference is written through (upstream `GoQualification`). +#[derive(Debug, Default)] +pub(crate) struct GoQualification { + /// The package name written before the reference's name, as spelled. + pub(crate) written: Option, + /// The file's import that name is. + pub(crate) imported: Option, + /// For a call through a parameter or local that takes an import's name, + /// the type it is declared as, when its declaration writes one + /// (`*tokenJWT`, `clock.PassiveClock`)… + pub(crate) declared_type: Option, + /// …and the import that type is written through — `clock` in a + /// parameter `clock clock.PassiveClock`, `atomic` in `cache := + /// &atomic.Bool{}` — or its value comes from: `reflect` in `value := + /// reflect.New(t)`. + pub(crate) type_package: Option, +} + +/// What a Go reference is written through (upstream `goRefQualification`): +/// the package name it spells and the import that name is. A call through a +/// parameter or local that takes the import's name is a call on that +/// variable (#2448): etcd's `jwt, err := newTokenProviderJWT(…)`, then +/// `jwt.assign(…)` beside `import "github.com/golang-jwt/jwt/v5"`; a testify +/// suite's `func (suite *DaoTestSuite) …` beside testify's `suite`. A type's +/// qualifier is always a package, and a value read through one +/// (`quota.Name`) is no call. +pub(crate) fn go_ref_qualification( + reference: &RefView, + context: &dyn ResolutionContext, +) -> GoQualification { + let Some(written) = go_written_qualifier(reference, context) else { + return GoQualification::default(); + }; + let imports = context.get_import_mappings(&reference.file_path, Language::Go); + let imported = imports + .iter() + .find(|import| import.local_name == written) + .cloned(); + if imported.is_some() + && reference.reference_name.contains('.') + && reference.reference_kind == EdgeKind::Calls + && let Some(local) = context + .source_facts(&reference.file_path) + .and_then(|facts| { + facts + .go_scope_index() + .local_decl(&written, reference.line, reference.column) + }) + { + // A value a package outside the project hands out is of a type it + // declares: kubernetes' `clock := clocktesting.NewFakePassiveClock(…)` + // has no project method `Now`. + let qualifier = local + .declared_type + .as_deref() + .and_then(written_type_package) + .or(local.from.as_deref()); + let type_package = qualifier.and_then(|qualifier| { + imports + .iter() + .find(|import| import.local_name == qualifier) + .cloned() + }); + return GoQualification { + written: None, + imported: None, + declared_type: local.declared_type, + type_package, + }; + } + GoQualification { + written: Some(written), + imported, + declared_type: None, + type_package: None, + } +} + +/// The package a declared type is written through: `clock` in +/// `clock.PassiveClock`, `*pkg.T`, `&pkg.T` (upstream's +/// `/^[*&\s]*([A-Za-z_]\w*)\.[A-Za-z_]\w*$/`). +fn written_type_package(declared: &str) -> Option<&str> { + let rest = declared.trim_start_matches(|c: char| c == '*' || c == '&' || c.is_whitespace()); + let (qualifier, name) = rest.split_once('.')?; + (is_identifier(qualifier) && is_identifier(name)).then_some(qualifier) +} + +/// The import a Go reference is written through — `context` in +/// `context.Context`, `store` in `store.Manager` (upstream `goRefQualifier`). +/// `None` for a name written bare, through anything that is not one of the +/// file's imports, or through a variable named like one. +pub(crate) fn go_ref_qualifier( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + go_ref_qualification(reference, context).imported +} + +/// The type a Go call's receiver, a parameter or local named like an import, +/// is declared as (`logger logger.Interface`), when its declaration writes +/// one (upstream #2448). +pub(crate) fn go_ref_declared_type( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + go_ref_qualification(reference, context).declared_type +} + +/// Whether a Go reference is written through a package that is none of its +/// file's imports as the index knows them (upstream `isGoUnknownQualified`, +/// #2397): `clientv3` in `clientv3.KV` under an unaliased `import +/// "go.etcd.io/etcd/client/v3"`, a package named neither by its path's last +/// element (`v3`) nor by the name goimports assumes for it (`client`). Which +/// package that is cannot be told from here. +pub(crate) fn is_go_unknown_qualified( + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let qualification = go_ref_qualification(reference, context); + qualification.written.is_some() && qualification.imported.is_none() +} + +/// Whether a Go reference is written through an imported package from +/// outside the project's modules — `context.Context`, `fmt.Errorf`, a +/// third-party `gin.H` — or is a call on a variable declared as such a +/// package's type: kubernetes' `clock.Now()` on a parameter `clock +/// clock.PassiveClock` (upstream `isGoExternalQualified`, #2233, #2448, with +/// #2361's package directories). +pub(crate) fn is_go_external_qualified( + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let qualification = go_ref_qualification(reference, context); + let Some(import) = qualification.imported.or(qualification.type_package) else { + return false; + }; + !(import.source.starts_with('.') + || import.source.contains("/internal/") + || go_import_package_dir(&import.source, Some(&reference.file_path), context).is_some()) +} + +/// What a Go type position — a parameter or result type, a composite +/// literal's type, a type an alias or a defined type names — names: a type, +/// which Go reads from one package (upstream `goTypePositionTarget`, #2416). A +/// method or a function is never it; Go reaches those only through a value or +/// a package. Whichever strategy found a declaration of the name, the type is +/// the one of that name in the reference's own package for a bare name, or in +/// the imported project package for `pkg.T`; without one there, a method or a +/// function of the name is nothing the reference means. etcd's `func (ti +/// *treeIndex) KeyIndex(keyi *keyIndex) *keyIndex` linked both `keyIndex` +/// types to the method `treeIndex.keyIndex` beside it. A bare name that found +/// another package's type means its own package's type of that name when +/// there is one. +pub(crate) fn go_type_position_target( + mut result: ResolvedRef, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let Some(target) = context.get_node_by_id_shared(&result.target_node_id) else { + return Some(result); + }; + if target.language != Language::Go { + return Some(result); + } + let is_type = is_go_type_kind(target.kind); + // A composite literal keeps its package in the name (`config_util.URL`); + // a parameter type leaves it on the line. + let (qualifier, name) = match reference.reference_name.rsplit_once('.') { + Some((qualifier, name)) => (Some(qualifier), name), + None => (None, reference.reference_name.as_str()), + }; + // The package's directory: `Some(None)` for a package outside the + // project, `None` for a qualifier that is none of the file's imports as + // indexed. + let mut bare = false; + let pkg_dir: Option> = match qualifier { + Some(qualifier) => go_qualifier_package_dir(qualifier, &reference.file_path, context), + None => match go_written_qualifier(reference, context) { + None => { + bare = true; + Some(Some(package_dir_of(&reference.file_path))) + } + Some(written) => context + .get_import_mappings(&reference.file_path, Language::Go) + .iter() + .find(|import| import.local_name == written) + .map(|import| { + go_import_package_dir(&import.source, Some(&reference.file_path), context) + }), + }, + }; + let pkg_dir = pkg_dir.flatten(); + if is_type && (!bare || pkg_dir.as_deref() == Some(package_dir_of(&target.file_path).as_str())) + { + return Some(result); + } + let types = pkg_dir + .map(|dir| package_types(name, &dir, context)) + .unwrap_or_default(); + if let Some(first) = + crate::name_matcher::prefer_call_site_file(types, &reference.file_path).first() + { + result.target_node_id = first.id.clone(); + return Some(result); + } + is_type.then_some(result) +} + +/// A Go call made through a type assertion — `srv.(KVServer).Range(ctx, +/// in)`, `c.Reader.(*pipe).Close()`, `v.(storage.Store).Fetch(k)` — calls a +/// method of the asserted type: its own, the one its interface declares, or +/// one embedding promotes into it (upstream `matchGoAssertedCall`, #2444). The +/// call reaches the resolver by its bare name, at the column where its +/// receiver expression starts, and name matching took any method of that +/// name: every gRPC handler etcd generates went to the `UnimplementedKVServer` +/// stub beside the `KVServer` interface. The type is the one Go finds: in the +/// call's own package or a package it dot-imports for a bare name, in the +/// imported package for a qualified one, and an alias is the type it names. A +/// type found in none — from outside the project (`http.Flusher`), predeclared +/// (`error`) — an alias of such a type, or a type literal links nothing. +/// `None` when the call is not made through an assertion. +pub(crate) fn match_go_asserted_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + if reference.language != Language::Go + || reference.reference_kind != EdgeKind::Calls + || !is_identifier(&reference.reference_name) + { + return None; + } + let asserted = crate::go_calls::go_asserted_type(reference, context)?; + let Some((qualifier, type_name)) = crate::go_calls::asserted_type_name(&asserted) else { + return Some(None); + }; + let declares = |dir: &str| !package_types(type_name, dir, context).is_empty(); + let dir = match qualifier { + Some(qualifier) => { + go_qualifier_package_dir(qualifier, &reference.file_path, context).flatten() + } + None => std::iter::once(package_dir_of(&reference.file_path)) + .chain(go_dot_import_dirs(&reference.file_path, context)) + .find(|dir| declares(dir)), + }; + let Some(dir) = dir.filter(|dir| declares(dir)) else { + return Some(None); + }; + // An alias of a type from outside the project (`type Ctx = + // context.Context`) has that type's methods, none the project declares. + if package_types(type_name, &dir, context) + .iter() + .all(|declaration| matches!(alias_target(declaration, context), Some(None))) + { + return Some(None); + } + Some(crate::name_matcher::resolve_method_on_type( + type_name, + &reference.reference_name, + reference, + context, + 0.9, + ResolvedBy::InstanceMethod, + Some(&dir), + 0, + )) +} + +/// The project packages a Go file dot-imports (`import . +/// "example.com/app/storage"`), by directory: their names it reads bare +/// (upstream `goDotImportDirs`). +fn go_dot_import_dirs(file_path: &str, context: &dyn ResolutionContext) -> Vec { + let Some(facts) = context.source_facts(file_path) else { + return Vec::new(); + }; + let mut dirs = Vec::new(); + for index in 0..facts.line_count() { + let line = facts.line(index); + // The imports come before any other declaration. + if ["func", "type", "var", "const"].iter().any(|keyword| { + line.strip_prefix(keyword).is_some_and(|rest| { + rest.chars() + .next() + .is_none_or(|c| !(c.is_alphanumeric() || c == '_')) + }) + }) { + break; + } + let rest = line.trim_start(); + let rest = rest + .strip_prefix("import") + .filter(|rest| rest.starts_with(char::is_whitespace)) + .map_or(rest, str::trim_start); + let Some(rest) = rest.strip_prefix('.') else { + continue; + }; + if !rest.starts_with(char::is_whitespace) { + continue; + } + let Some(path) = rest + .trim_start() + .strip_prefix('"') + .and_then(|rest| rest.split_once('"')) + .map(|(path, _)| path) + else { + continue; + }; + if let Some(dir) = go_import_package_dir(path, Some(file_path), context) { + dirs.push(dir); + } + } + dirs +} + +/// Whether `candidate` lives in the project package a Go reference is written +/// through, when it is one (upstream `isInGoQualifierPackage`): +/// `store.Manager` names the `Manager` of the directory the `store` import +/// maps to, never a same-named symbol of another package (#2322). +pub(crate) fn is_in_go_qualifier_package( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + // A package never holds a method: a call or method value landing on one + // is made through a variable that shadows the import — prometheus's + // `labels := &maxHeap{}`, then `labels.get()`. + if candidate.kind == NodeKind::Method + && (reference.reference_kind == EdgeKind::Calls || reference.is_function_ref) + { + return true; + } + let Some(import) = go_ref_qualifier(reference, context) else { + return true; + }; + go_import_package_dir(&import.source, Some(&reference.file_path), context) + .is_none_or(|dir| package_dir_of(&candidate.file_path) == dir) +} + +/// The node kinds a Go `type` declaration produces (upstream `GO_TYPE_KINDS`). +fn is_go_type_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Struct | NodeKind::Interface | NodeKind::TypeAlias + ) +} + +/// The declarations of the Go type `type_name` in the package at `pkg_dir`. +fn package_types( + type_name: &str, + pkg_dir: &str, + context: &dyn ResolutionContext, +) -> Vec> { + context + .get_nodes_by_name_shared(type_name) + .into_iter() + .filter(|node| { + node.language == Language::Go + && is_go_type_kind(node.kind) + && package_dir_of(&node.file_path) == pkg_dir + }) + .collect() +} + +/// A Go declaration line without its `//` and single-line `/* */` comments. +pub(crate) fn strip_go_line_comments(line: &str) -> String { + let line = line.split("//").next().unwrap_or(line); + let mut out = String::with_capacity(line.len()); + let mut rest = line; + while let Some(open) = rest.find("/*") { + out.push_str(&rest[..open]); + match rest[open + 2..].find("*/") { + Some(close) => rest = &rest[open + 2 + close + 2..], + None => { + rest = ""; + break; + } + } + } + out.push_str(rest); + out +} + +/// What a Go struct or interface declaration's text embeds, as written: each +/// member that is nothing but a type (`BaseAPI`, `*cached.BaseManager`, +/// `ReadView`, optionally tagged), one per line or `;`-separated, as +/// `(qualifier, name)`. +pub fn written_embeddings(declaration: &str) -> Vec<(Option, String)> { + let (Some(open), Some(close)) = (declaration.find('{'), declaration.rfind('}')) else { + return Vec::new(); + }; + if close <= open { + return Vec::new(); + } + let mut body = declaration[open + 1..close].to_string(); + // Members of a nested anonymous struct are not this type's. + while let Some(inner) = innermost_braces(&body) { + body.replace_range(inner, ""); + } + body.split(['\n', ';']) + .filter_map(embedded_member) + .collect() +} + +/// The range of the first `{…}` with no brace inside (upstream's +/// `/\{[^{}]*\}/`). +fn innermost_braces(text: &str) -> Option> { + let mut open = None; + for (at, byte) in text.bytes().enumerate() { + match byte { + b'{' => open = Some(at), + b'}' => { + if let Some(start) = open { + return Some(start..at + 1); + } + } + _ => {} + } + } + None +} + +/// A struct or interface member that is nothing but a type (upstream's +/// `/^\s*\*?\s*([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?\s*(?:\[[^\]]*\])?\s*(?:`[^`]*`|"[^"]*")?\s*$/`). +fn embedded_member(member: &str) -> Option<(Option, String)> { + let mut rest = member.trim_start(); + rest = rest.strip_prefix('*').unwrap_or(rest).trim_start(); + let first_end = rest + .bytes() + .position(|b| !is_word_byte(b)) + .unwrap_or(rest.len()); + let first = &rest[..first_end]; + if !is_identifier(first) { + return None; + } + rest = &rest[first_end..]; + let mut qualifier = None; + let mut name = first.to_string(); + if let Some(after) = rest.strip_prefix('.') { + let end = after + .bytes() + .position(|b| !is_word_byte(b)) + .unwrap_or(after.len()); + let second = &after[..end]; + if !is_identifier(second) { + return None; + } + qualifier = Some(first.to_string()); + name = second.to_string(); + rest = &after[end..]; + } + rest = rest.trim_start(); + if let Some(after) = rest.strip_prefix('[') { + let close = after.find(']')?; + rest = after[close + 1..].trim_start(); + } + for quote in ['`', '"'] { + if let Some(after) = rest.strip_prefix(quote) { + let close = after.find(quote)?; + rest = after[close + 1..].trim_start(); + break; + } + } + rest.trim().is_empty().then_some((qualifier, name)) +} + +/// The project types a Go struct or interface embeds, with the directory of +/// the package that declares each (upstream `goEmbeddedTypes`). An embedded +/// type from outside the project's packages (`suite.Suite`, `sync.Mutex`) +/// has no project methods and is left out, as is one whose package cannot be +/// told. +pub(crate) fn embedded_types( + type_node: &Node, + context: &dyn ResolutionContext, +) -> Arc> { + let tables = context.go_tables(); + if let Some(hit) = tables + .embeds + .lock() + .ok() + .and_then(|memo| memo.get(&type_node.id).cloned()) + { + return hit; + } + let mut out = Vec::new(); + if let Some(facts) = context.source_facts(&type_node.file_path) { + let start = usize::try_from(type_node.start_line.saturating_sub(1)).unwrap_or(0); + let end = usize::try_from(type_node.end_line.max(type_node.start_line)) + .unwrap_or(0) + .min(facts.line_count()); + let declaration: Vec = (start..end) + .map(|index| strip_go_line_comments(facts.line(index))) + .collect(); + for (qualifier, name) in written_embeddings(&declaration.join("\n")) { + let pkg_dir = match qualifier { + Some(qualifier) => { + go_qualifier_package_dir(&qualifier, &type_node.file_path, context).flatten() + } + None => Some(package_dir_of(&type_node.file_path)), + }; + if let Some(pkg_dir) = pkg_dir { + out.push(GoTypeRef { name, pkg_dir }); + } + } + } + let out = Arc::new(out); + if let Ok(mut memo) = tables.embeds.lock() { + memo.insert(type_node.id.clone(), Arc::clone(&out)); + } + out +} + +/// `method_name` on the Go type `type_name` the package at `pkg_dir` +/// declares (upstream `resolveGoMethodInPackage`, #2323): only that +/// package's `::` counts, then the types its +/// declaration embeds. `None` when the package declares no such type (a dot +/// import, a file that is not indexed): the caller then resolves by name as +/// before. `Some(None)` is no edge. +#[allow(clippy::too_many_arguments)] +pub(crate) fn resolve_go_method_in_package( + type_name: &str, + method_name: &str, + pkg_dir: &str, + matches: &[Arc], + reference: &RefView, + context: &dyn ResolutionContext, + confidence: f64, + resolved_by: ResolvedBy, + depth: u32, +) -> Option> { + let own: Vec> = matches + .iter() + .filter(|method| package_dir_of(&method.file_path) == pkg_dir) + .cloned() + .collect(); + if !own.is_empty() { + if reference.is_function_ref && own.len() != 1 { + return Some(None); + } + let ordered = crate::name_matcher::prefer_call_site_file(own, &reference.file_path); + return Some(Some(ResolvedRef { + original: reference.clone(), + target_node_id: ordered[0].id.clone(), + confidence, + resolved_by, + })); + } + let types = package_types(type_name, pkg_dir, context); + if types.is_empty() { + return None; + } + if depth >= 4 { + return Some(None); + } + let mut unplaced = 0; + for declaration in &types { + // An alias is the type it names, and has that type's methods + // (upstream #2417). + if let Some(aliased) = alias_target(declaration, context) { + match aliased { + Some(target) => { + if let Some(via) = crate::name_matcher::resolve_method_on_type( + &target.name, + method_name, + reference, + context, + confidence, + resolved_by, + Some(&target.pkg_dir), + depth + 1, + ) { + return Some(Some(via)); + } + } + None => unplaced += 1, + } + continue; + } + for embedded in embedded_types(declaration, context).iter() { + if let Some(via) = crate::name_matcher::resolve_method_on_type( + &embedded.name, + method_name, + reference, + context, + confidence, + resolved_by, + Some(&embedded.pkg_dir), + depth + 1, + ) { + return Some(Some(via)); + } + } + } + // An alias of a type from outside the project (`type Ctx = + // context.Context`) or of no named type declares nothing here: the method + // is looked up by name, as it was before aliases had nodes. + if unplaced == types.len() { + return None; + } + Some(None) +} + +/// The type a Go alias names, with the directory of the package that +/// declares it (upstream `goAliasTarget`, #2417): `mvccpb.Event` for `type +/// Event = mvccpb.Event`, `Local` for `type Ptr = *Local`, `List` for `type +/// Items[T any] = List[T]`, read from the declaration as an embedding is. +/// `Some(None)` for an alias of anything else: a type from outside the +/// project's packages, a predeclared one, a `func(…)` or `map[…]…`. `None` +/// when the node is no alias: a struct, an interface, or a defined type +/// (`type Dur int`), which declares a type of its own. +fn alias_target(type_node: &Node, context: &dyn ResolutionContext) -> Option> { + if type_node.kind != NodeKind::TypeAlias { + return None; + } + let facts = context.source_facts(&type_node.file_path)?; + let start = usize::try_from(type_node.start_line.saturating_sub(1)).unwrap_or(0); + let end = usize::try_from(type_node.end_line.max(type_node.start_line)) + .unwrap_or(0) + .min(facts.line_count()); + let column = usize::try_from(type_node.start_column.max(0)).unwrap_or(0); + // From the alias's name, where the node starts, to the end of its type. + let declaration = (start..end) + .map(|index| { + let line = facts.line(index); + let line = if index == start { + line.get(column..).unwrap_or_default() + } else { + line + }; + strip_go_line_comments(line) + }) + .collect::>() + .join(" "); + let rest = &declaration[alias_head_end(&declaration)?..]; + Some(alias_named_type(rest).and_then(|(qualifier, name)| { + let pkg_dir = match &qualifier { + Some(qualifier) => { + go_qualifier_package_dir(qualifier, &type_node.file_path, context).flatten() + } + None => Some(package_dir_of(&type_node.file_path)), + }?; + (qualifier.is_some() || !GO_BUILTIN_FIELD_TYPES.contains(&name.as_str())) + .then_some(GoTypeRef { name, pkg_dir }) + })) +} + +/// Where the type of an alias declaration starts, past `Name =` or `Name[T +/// any] =` (upstream's `/^\s*[A-Za-z_]\w*\s*(?:\[[^\]]*\])?\s*=/`); `None` for +/// a defined type, which has no `=` there. +fn alias_head_end(declaration: &str) -> Option { + let mut at = declaration.len() - declaration.trim_start().len(); + let bytes = declaration.as_bytes(); + if at >= bytes.len() || !(bytes[at].is_ascii_alphabetic() || bytes[at] == b'_') { + return None; + } + while at < bytes.len() && is_word_byte(bytes[at]) { + at += 1; + } + while at < bytes.len() && bytes[at].is_ascii_whitespace() { + at += 1; + } + if at < bytes.len() && bytes[at] == b'[' { + let close = declaration[at..].find(']')?; + at += close + 1; + while at < bytes.len() && bytes[at].is_ascii_whitespace() { + at += 1; + } + } + (at < bytes.len() && bytes[at] == b'=').then_some(at + 1) +} + +/// The named type an alias's right-hand side is: `T`, `*T` or `pkg.T`, each +/// perhaps with type arguments (upstream's +/// `/^\s*(?:\*\s*)?([A-Za-z_]\w*)(?:\s*\.\s*([A-Za-z_]\w*))?\s*(?:\[.*\])?\s*;?\s*$/`), +/// as `(qualifier, name)`. +fn alias_named_type(text: &str) -> Option<(Option, String)> { + let mut rest = text.trim_start(); + if let Some(after) = rest.strip_prefix('*') { + rest = after.trim_start(); + } + let word = |text: &str| -> usize { + text.bytes() + .position(|b| !is_word_byte(b)) + .unwrap_or(text.len()) + }; + let first_end = word(rest); + let first = &rest[..first_end]; + if !is_identifier(first) { + return None; + } + rest = &rest[first_end..]; + let mut result = (None, first.to_string()); + let trimmed = rest.trim_start(); + if let Some(after) = trimmed.strip_prefix('.') { + let after = after.trim_start(); + let end = word(after); + let second = &after[..end]; + if !is_identifier(second) { + return None; + } + result = (Some(first.to_string()), second.to_string()); + rest = &after[end..]; + } + let mut tail = rest.trim(); + if tail.starts_with('[') { + // `(?:\[.*\])?`: a bracketed run to the last `]`. + let close = tail.rfind(']')?; + tail = tail[close + 1..].trim(); + } + let tail = tail.strip_suffix(';').unwrap_or(tail).trim(); + tail.is_empty().then_some(result) +} + +/// The type a field of `declaration` (a Go struct) is declared with, as +/// written (upstream's `\bfield\s+\*?\[?\]?([A-Za-z_][\w.]*)`, read line by +/// line with comments stripped). +pub fn written_field_type(declaration_lines: &[String], field: &str) -> Option { + for line in declaration_lines { + let mut from = 0; + while let Some(found) = line[from..].find(field) { + let at = from + found; + from = at + field.len(); + if line[..at].bytes().next_back().is_some_and(is_word_byte) { + continue; + } + let mut rest = &line[at + field.len()..]; + if rest.bytes().next().is_some_and(is_word_byte) { + continue; + } + let trimmed = rest.trim_start(); + if trimmed.len() == rest.len() { + continue; + } + rest = trimmed; + rest = rest.strip_prefix('*').unwrap_or(rest); + rest = rest.strip_prefix('[').unwrap_or(rest); + rest = rest.strip_prefix(']').unwrap_or(rest); + if !rest.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + continue; + } + let end = rest + .bytes() + .position(|b| !is_word_byte(b) && b != b'.') + .unwrap_or(rest.len()); + return Some(rest[..end].to_string()); + } + } + None +} + +/// A Go call through a two-hop field chain, `base.field.method()`, which the +/// extractor records by its last receiver segment (`field.method`, KEEP-RUST +/// #1496) at the column where the chain starts: the chain read back from the +/// line, `(base, field)`, when it is that and nothing longer or shorter +/// (upstream emits `base.field.method` for exactly this receiver shape, +/// #1276). +pub(crate) fn go_call_chain( + reference: &RefView, + receiver: &str, + method: &str, + context: &dyn ResolutionContext, +) -> Option<(String, String)> { + if reference.language != Language::Go || !is_identifier(receiver) { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + let index = usize::try_from(reference.line.checked_sub(1)?) + .ok() + .filter(|&index| index < facts.line_count())?; + let line = facts.line(index); + let column = usize::try_from(reference.column.max(0)).ok()?; + let (links, end) = selector_chain(line, column); + let [base, field, called] = links.as_slice() else { + return None; + }; + (*field == receiver && *called == method && line[end..].trim_start().starts_with('(')) + .then(|| (base.to_string(), field.to_string())) +} + +/// Whether a Go reference `receiver.member` is a link inside a longer +/// selector chain, `s.db.member`, whose receiver is a field rather than the +/// chain's operand: no package qualifies it. +pub(crate) fn is_go_chain_link(reference: &RefView, context: &dyn ResolutionContext) -> bool { + let Some((receiver, _)) = reference.reference_name.split_once('.') else { + return false; + }; + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let Some(index) = reference + .line + .checked_sub(1) + .and_then(|line| usize::try_from(line).ok()) + .filter(|&index| index < facts.line_count()) + else { + return false; + }; + let line = facts.line(index); + let Ok(column) = usize::try_from(reference.column.max(0)) else { + return false; + }; + let (links, _) = selector_chain(line, column); + links.len() >= 3 && links.first().is_some_and(|operand| *operand != receiver) +} + +/// The names of the selector chain a Go expression at `column` of `line` +/// starts with — its operand, then each `.name` — and where it ends. +fn selector_chain(line: &str, column: usize) -> (Vec<&str>, usize) { + let bytes = line.as_bytes(); + let word_end = |start: usize| { + let mut end = start; + while end < bytes.len() && is_word_byte(bytes[end]) { + end += 1; + } + end + }; + let starts_word = + |at: usize| at < bytes.len() && (bytes[at].is_ascii_alphabetic() || bytes[at] == b'_'); + if !starts_word(column) || (column > 0 && is_word_byte(bytes[column - 1])) { + return (Vec::new(), column); + } + let mut end = word_end(column); + let mut links = vec![&line[column..end]]; + loop { + let mut at = end; + while at < bytes.len() && matches!(bytes[at], b' ' | b'\t') { + at += 1; + } + if at >= bytes.len() || bytes[at] != b'.' { + break; + } + at += 1; + while at < bytes.len() && matches!(bytes[at], b' ' | b'\t') { + at += 1; + } + if !starts_word(at) { + break; + } + let next = word_end(at); + links.push(&line[at..next]); + end = next; + } + (links, end) +} + +/// Resolve a Go two-hop field chain `base.field.method` (upstream +/// `matchGoFieldChainCall`, #1276, scoped by package since #2361): `base`'s +/// type from the enclosing scope, `field`'s declared type from that struct's +/// own declaration lines (the declaring package's struct first), and the +/// method validated on the field's type in the package declaring it. A field +/// whose type has no project node (`sql.DB`) links nothing. +pub(crate) fn go_field_chain_call( + base: &str, + field: &str, + method: &str, + reference: &RefView, + context: &dyn ResolutionContext, + confidence: f64, + resolved_by: ResolvedBy, +) -> Option { + let (base_type, written) = + crate::name_matcher::infer_local_receiver_declaration(base, reference, context)?; + let mut structs: Vec> = context + .get_nodes_by_name_shared(&base_type) + .into_iter() + .filter(|node| { + matches!(node.kind, NodeKind::Struct | NodeKind::Class) && node.language == Language::Go + }) + .collect(); + // The struct the declaring package has is the one the base's type means: + // Go type names repeat across packages, and another package's + // same-named struct with a same-named field is not this one (#2323). + if let Some(base_pkg) = go_declared_type_package(Some(&written), &reference.file_path, context) + { + let declared: Vec> = structs + .iter() + .filter(|node| package_dir_of(&node.file_path) == base_pkg) + .cloned() + .collect(); + if !declared.is_empty() { + structs = declared; + } + } + for declaration in crate::name_matcher::prefer_call_site_file(structs, &reference.file_path) { + let Some(facts) = context.source_facts(&declaration.file_path) else { + continue; + }; + let start = usize::try_from(declaration.start_line.saturating_sub(1)).unwrap_or(0); + let end = usize::try_from(declaration.end_line.max(0)) + .unwrap_or(0) + .min(facts.line_count()); + let lines: Vec = (start..end) + .map(|index| strip_go_line_comments(facts.line(index))) + .collect(); + for line in &lines { + let Some(raw_type) = written_field_type(std::slice::from_ref(line), field) else { + continue; + }; + // The package declaring the field's type: the struct's own for a + // bare name, the imported one for a package-qualified name, which + // is followed only when it is one of the project's packages. + let pkg_dir = match raw_type.split_once('.') { + Some((qualifier, _)) => { + match go_qualifier_package_dir(qualifier, &declaration.file_path, context) + .flatten() + { + Some(dir) => dir, + None => continue, + } + } + None => package_dir_of(&declaration.file_path), + }; + let Some(type_name) = raw_type.rsplit('.').next() else { + continue; + }; + if !type_name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') + || GO_BUILTIN_FIELD_TYPES.contains(&type_name) + { + continue; + } + if let Some(resolved) = crate::name_matcher::resolve_method_on_type( + type_name, + method, + reference, + context, + confidence, + resolved_by, + Some(&pkg_dir), + 0, + ) { + return Some(resolved); + } + } + } + None +} + +/// Key prefix of the Go head rows (W4-GO-01): one `project_metadata` row per +/// Go file with a type declaration, written by every full index and +/// migration and kept current by sync. +pub const HEAD_ROW_KEY_PREFIX: &str = "go_heads:"; + +/// The `project_metadata` key of `file_path`'s head row. +pub fn head_row_key(file_path: &str) -> String { + format!("{HEAD_ROW_KEY_PREFIX}{file_path}") +} + +/// One Go type declaration as resolution reads its text: what it embeds (by +/// name), every name it mentions, a digest of the text itself, so a sync +/// tells a changed field type from an unchanged one, and the import path +/// each package qualifier it writes (`dep` in `dep.Manager`) binds in its +/// file, so a sync tells a retargeted import too. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub struct HeadEntry { + pub name: String, + pub embeds: Vec, + pub mentions: Vec, + pub digest: String, + /// `qualifier=import path`, sorted. + pub imports: Vec, +} + +/// A Go file's head row: every struct, interface and type alias, sorted. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HeadRow { + pub entries: Vec, +} + +impl HeadRow { + /// The row of a file whose text is `source` and whose nodes are `nodes`. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let lines: Vec<&str> = source.lines().collect(); + let file_imports = crate::import_resolver::extract_import_mappings(source, Language::Go); + let mut entries: Vec = nodes + .iter() + .filter(|node| node.language == Language::Go && is_go_type_kind(node.kind)) + .map(|node| { + let start = usize::try_from(node.start_line.saturating_sub(1)).unwrap_or(0); + let end = usize::try_from(node.end_line.max(node.start_line)) + .unwrap_or(0) + .min(lines.len()); + let declaration = lines + .get(start..end.max(start)) + .unwrap_or_default() + .iter() + .map(|line| strip_go_line_comments(line)) + .collect::>() + .join("\n"); + let mut embeds: Vec = written_embeddings(&declaration) + .into_iter() + .map(|(_, name)| name) + .collect(); + embeds.sort(); + embeds.dedup(); + let mut imports: Vec = written_qualifiers(&declaration) + .into_iter() + .filter_map(|qualifier| { + file_imports + .iter() + .find(|import| import.local_name == qualifier) + .map(|import| format!("{qualifier}={}", import.source)) + }) + .collect(); + imports.sort(); + imports.dedup(); + HeadEntry { + name: node.name.clone(), + embeds, + mentions: mentioned_names(&declaration), + digest: format!("{:016x}", fnv1a(declaration.as_bytes())), + imports, + } + }) + .collect(); + entries.sort(); + Self { entries } + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("go head row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +/// The identifiers a declaration's text writes right before a `.` and +/// another identifier: the package qualifiers of its `pkg.Type` names. +fn written_qualifiers(text: &str) -> Vec<&str> { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut at = 0; + while at < bytes.len() { + if !(bytes[at].is_ascii_alphabetic() || bytes[at] == b'_') + || (at > 0 && (is_word_byte(bytes[at - 1]) || bytes[at - 1] == b'.')) + { + at += 1; + continue; + } + let mut end = at; + while end < bytes.len() && is_word_byte(bytes[end]) { + end += 1; + } + if end + 1 < bytes.len() + && bytes[end] == b'.' + && (bytes[end + 1].is_ascii_alphabetic() || bytes[end + 1] == b'_') + { + out.push(&text[at..end]); + } + at = end; + } + out +} + +/// Every identifier a declaration's text holds, sorted, the predeclared +/// types aside. +fn mentioned_names(text: &str) -> Vec { + let mut names: Vec = text + .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .filter(|word| is_identifier(word) && !GO_BUILTIN_FIELD_TYPES.contains(word)) + .map(str::to_string) + .collect(); + names.sort(); + names.dedup(); + names +} + +/// The 64-bit FNV-1a hash: a digest stable across builds. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + fn module(path: &str, dir: &str) -> GoModule { + GoModule { + module_path: path.to_string(), + dir: dir.to_string(), + } + } + + #[test] + fn owning_go_module_prefers_the_longest_matching_module_path() { + let modules = vec![module("ex/a", "a"), module("ex/a/sub", "a/sub")]; + assert_eq!( + owning_go_module("ex/a/sub/pkg", &modules, None).map(|m| m.dir.as_str()), + Some("a/sub"), + "the nested module must win over its prefix" + ); + assert_eq!( + owning_go_module("ex/a/other", &modules, None).map(|m| m.dir.as_str()), + Some("a") + ); + assert!(owning_go_module("github.com/x/y", &modules, None).is_none()); + } + + #[test] + fn owning_go_module_rejects_a_shared_path_prefix_that_is_not_a_segment() { + let modules = vec![module("ex/a", "a")]; + assert!( + owning_go_module("ex/abc", &modules, None).is_none(), + "`ex/abc` is a different module, not a package inside `ex/a`" + ); + } + + #[test] + fn owning_go_module_breaks_a_tie_to_the_own_module_and_skips_ignored_ones() { + let modules = vec![ + module("ex/svc", "internal/testdata/stub"), + module("ex/svc", "v1"), + module("ex/svc", "v2"), + ]; + assert_eq!( + owning_go_module("ex/svc/pkg", &modules, None).map(|m| m.dir.as_str()), + Some("v1") + ); + assert_eq!( + owning_go_module("ex/svc/pkg", &modules, Some(&modules[2])).map(|m| m.dir.as_str()), + Some("v2") + ); + // The testdata module answers only its own files. + assert_eq!( + owning_go_module("ex/svc/pkg", &modules[..1], None).map(|m| m.dir.as_str()), + None + ); + assert_eq!( + owning_go_module("ex/svc/pkg", &modules[..1], Some(&modules[0])) + .map(|m| m.dir.as_str()), + Some("internal/testdata/stub") + ); + } + + #[test] + fn go_package_dir_joins_module_dir_with_the_path_below_it() { + assert_eq!(package_dir_in("ex/a", &module("ex/a", "a")), "a"); + assert_eq!(package_dir_in("ex/a/pkg", &module("ex/a", "a")), "a/pkg"); + assert_eq!(package_dir_in("ex/m/pkg", &module("ex/m", "")), "pkg"); + assert_eq!( + package_dir_in("ex/m", &module("ex/m", "")), + "", + "a root module's own package lives in the project root" + ); + } + + #[test] + fn a_row_holds_each_type_with_its_embeddings_mentions_and_digest() { + let text = "package h\n\nimport dep \"example.com/app/store1\"\n\ntype server struct {\n\tBaseAPI // the base\n\tstore *store\n\tdb *dep.Manager\n}\n\ntype store struct{}\n"; + let node = |name: &str, kind: NodeKind, lines: (i64, i64)| Node { + id: format!("{}:{name}", kind.as_str()), + kind, + name: name.to_string(), + qualified_name: name.to_string(), + file_path: "h/server.go".to_string(), + language: Language::Go, + start_line: lines.0, + end_line: lines.1, + start_column: 5, + end_column: 1, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + }; + let nodes = [ + node("server", NodeKind::Struct, (5, 9)), + node("store", NodeKind::Struct, (11, 11)), + ]; + let row = HeadRow::of(text, &nodes); + assert_eq!(row.entries.len(), 2); + assert_eq!(row.entries[0].name, "server"); + assert_eq!(row.entries[0].embeds, ["BaseAPI"]); + assert_eq!( + row.entries[0].mentions, + ["BaseAPI", "Manager", "db", "dep", "server", "store", "type"] + ); + assert_eq!(row.entries[0].imports, ["dep=example.com/app/store1"]); + let retyped = HeadRow::of(&text.replace("store *store", "store *cache"), &nodes); + assert_ne!(retyped.entries[0].digest, row.entries[0].digest); + // The same declaration through a retargeted import is another entry. + let retargeted = HeadRow::of(&text.replace("store1", "store2"), &nodes); + assert_eq!(retargeted.entries[0].digest, row.entries[0].digest); + assert_ne!(retargeted.entries[0], row.entries[0]); + assert_eq!(HeadRow::decode(&row.encode()), Some(row)); + } + + #[test] + fn a_dotted_reference_is_written_through_its_operand() { + let line = "\tklog.Error(perr.Error())"; + assert_eq!( + dotted_qualifier("klog.Error", line, 1, "Error"), + Some(Some("klog")) + ); + assert_eq!( + dotted_qualifier("perr.Error", line, 12, "Error"), + Some(Some("perr")) + ); + // Two hops, kept as their last receiver segment. + let line = "\t\tzap.String(\"id\", t.ID.String()),"; + assert_eq!( + dotted_qualifier("ID.String", line, 19, "String"), + Some(Some("t")) + ); + // Three hops are recorded bare upstream: read as a bare name. + let line = "\te.cfg.logger.Error(\"x\", zap.Error(err))"; + assert_eq!(dotted_qualifier("logger.Error", line, 1, "Error"), None); + // A factory call's result has no package; a bare name is read from + // its line. + // The root of the chain is a call's result: no package, whatever the + // stored name's head is, on its line or above a link on the next. + assert_eq!( + dotted_qualifier("bar.Run", "\tmakeOuter().bar.Run()", 1, "Run"), + Some(None) + ); + assert_eq!( + dotted_qualifier("bar.Run", "\tmakeOuter().", 1, "Run"), + Some(None) + ); + // A chain going on below a line-final `.`. + assert_eq!(dotted_qualifier("x.Foo", "\tx.", 1, "Foo"), Some(Some("x"))); + // A conversion through a package, recorded at its parenthesis; a + // parenthesized value has no package. + let line = "\tdelResp := (*v3.DeleteResponse)(resp)"; + assert_eq!( + dotted_qualifier("v3.DeleteResponse", line, 12, "DeleteResponse"), + Some(Some("v3")) + ); + assert_eq!( + dotted_qualifier("x.Close", "\t(x).Close()", 1, "Close"), + Some(None) + ); + // A method value recorded at its member, its path before it. + let line = "\tr.Use(middleware.Logger)"; + assert_eq!( + dotted_qualifier("middleware.Logger", line, 18, "Logger"), + Some(Some("middleware")) + ); + let line = "\tgo Submit(c.store.Fetch)"; + assert_eq!( + dotted_qualifier("c.store.Fetch", line, 19, "Fetch"), + Some(Some("c")) + ); + assert_eq!( + dotted_qualifier("store.Fetch", "\tSubmit(c.store.Fetch)", 16, "Fetch"), + Some(None) + ); + // A line that shows no chain at the column: the stored name's head. + assert_eq!( + dotted_qualifier("pkga.FuncX", "package main", 0, "FuncX"), + Some(Some("pkga")) + ); + let line = "\tr.Mount(\"/w\", Chain(func(next http.Handler) http.Handler {"; + assert_eq!( + dotted_qualifier("Chain().Handler", line, 15, "Handler"), + Some(None) + ); + assert_eq!( + dotted_qualifier("Message", "func f(src proto.Message)", 17, "Message"), + None + ); + } + + #[test] + fn an_alias_names_one_type_or_none() { + assert_eq!(alias_head_end("Event = mvccpb.Event"), Some(7)); + assert_eq!(alias_head_end("Items[T any] = List[T]"), Some(14)); + assert_eq!(alias_head_end("Dur int"), None); + assert_eq!( + alias_named_type(" mvccpb.Event"), + Some((Some("mvccpb".to_string()), "Event".to_string())) + ); + assert_eq!( + alias_named_type(" *Local"), + Some((None, "Local".to_string())) + ); + assert_eq!( + alias_named_type(" List[T]"), + Some((None, "List".to_string())) + ); + assert_eq!(alias_named_type(" map[string]*KeyValue"), None); + assert_eq!(alias_named_type(" func(ctx context.Context) error"), None); + assert_eq!(alias_named_type(" []WatchResponse"), None); + } + + #[test] + fn a_module_dir_the_go_tool_ignores_is_told() { + assert!(is_ignored_module_dir("internal/testdata/stub")); + assert!(is_ignored_module_dir("_examples/x")); + assert!(is_ignored_module_dir(".hidden")); + assert!(!is_ignored_module_dir("")); + assert!(!is_ignored_module_dir("tools/lint")); + } + + #[test] + fn the_nearest_module_is_the_deepest_one_above() { + let modules = [ + module("ex/root", ""), + module("ex/svc", "svc"), + module("ex/svc2", "svc2"), + ]; + assert_eq!( + nearest_module("svc/internal", &modules).map(|m| m.dir.as_str()), + Some("svc") + ); + assert_eq!( + nearest_module("svc2", &modules).map(|m| m.dir.as_str()), + Some("svc2") + ); + assert_eq!( + nearest_module("web", &modules).map(|m| m.dir.as_str()), + Some("") + ); + assert_eq!( + package_dir_in("ex/svc/internal/store", &modules[1]), + "svc/internal/store" + ); + assert_eq!(package_dir_in("ex/root", &modules[0]), ""); + } + + #[test] + fn a_qualifier_is_read_before_the_name_or_from_the_line() { + assert_eq!(qualifier_before("\tdb *store."), Some("store")); + assert_eq!(qualifier_before("x.store."), None); + assert_eq!(qualifier_before("("), None); + assert!(written_bare("func f(c Context) {", "Context")); + assert!(!written_bare("func f(c context.Context) {", "Context")); + assert_eq!( + first_qualifier_of("func f(c context.Context) {", "Context"), + Some("context") + ); + assert_eq!(first_qualifier_of("a.b.Context", "Context"), None); + } + + #[test] + fn embeddings_are_the_members_that_are_only_a_type() { + let declaration = "type server struct {\n\tBaseAPI\n\t*cached.BaseManager `json:\"-\"`\n\tservice *domain.Service\n\tinner struct {\n\t\tHidden\n\t}\n\tList[int]\n}"; + assert_eq!( + written_embeddings(declaration), + [ + (None, "BaseAPI".to_string()), + (Some("cached".to_string()), "BaseManager".to_string()), + (None, "List".to_string()), + ] + ); + assert_eq!( + written_embeddings("type EphemeralKV struct{ RemoteKV }"), + [(None, "RemoteKV".to_string())] + ); + } + + #[test] + fn a_field_type_is_read_from_its_declaration_line() { + let lines = [ + "\tservice *domain.Service".to_string(), + "\tstore *store".to_string(), + ]; + assert_eq!( + written_field_type(&lines, "service").as_deref(), + Some("domain.Service") + ); + assert_eq!( + written_field_type(&lines, "store").as_deref(), + Some("store") + ); + assert_eq!(written_field_type(&lines, "serv"), None); + assert_eq!( + written_field_type(&["\titems []Item".to_string()], "items").as_deref(), + Some("Item") + ); + } + + #[test] + fn a_selector_chain_is_read_from_its_start() { + assert_eq!( + selector_chain("\treturn s.db.CreateItem(name)", 8).0, + ["s", "db", "CreateItem"] + ); + assert_eq!(selector_chain("x := a . b(c)", 5).0, ["a", "b"]); + assert!(selector_chain("ab.c()", 1).0.is_empty()); + } +} diff --git a/crates/codegraph-resolve/src/go_scope.rs b/crates/codegraph-resolve/src/go_scope.rs new file mode 100644 index 00000000..4b37037b --- /dev/null +++ b/crates/codegraph-resolve/src/go_scope.rs @@ -0,0 +1,849 @@ +//! The parameters and locals a Go function declares, read from its file +//! (upstream #2448's scope reader, W4-GO-03). +//! +//! A call through a parameter or local that takes an import's name — etcd's +//! `jwt, err := newTokenProviderJWT(…)` beside `import ".../jwt/v5"`, a +//! testify suite's `suite` receiver — is a call on that variable, not into the +//! package. Go gives an identifier the innermost block that declares it, from +//! the end of its declaration on: a header's names live in its statement's +//! blocks (`else` included), a `select` case's in its clause, a parameter in +//! its function's body. The file is read once, comments and the contents of +//! strings blanked; where each name is bound is worked out the first time it +//! is asked for. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use regex::Regex; + +/// What declares a Go name (upstream `GoDecl['kind']`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeclKind { + /// A statement's local: `x := …`, `var x T`. + Local, + /// An `if` / `for` / `switch` header's. + Header, + /// A `select` / `switch` case's. + Case, + /// A receiver, parameter or result of the function whose body opens at + /// `body`. + Param, +} + +/// One declaration of a Go name (upstream `GoDecl`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GoDecl { + /// Offset of the declared name in the blanked code. + at: usize, + kind: DeclKind, + body: Option, + /// The declared type as written, when the declaration writes one. + pub(crate) declared_type: Option, + /// The package a `:=` takes its value from: `clocktesting` in `clock := + /// clocktesting.NewFakeClock(…)`. + pub(crate) from: Option, +} + +type Scope = (usize, usize, GoDecl); + +/// A Go file's blocks and the names its functions declare (upstream +/// `GoScopeIndex`). +#[derive(Debug)] +pub(crate) struct GoScopeIndex { + /// The source with CRLF read as LF, comments and string contents blanked, + /// offsets kept. + code: String, + line_starts: Vec, + /// Offsets of every `{` and `}`, with the innermost block open after each. + events: Vec, + open: Vec>, + close: HashMap, + decls: HashMap>, + /// Where each declared name is bound, read on demand. + scopes: Mutex>>>, +} + +/// Go's type keywords: `chan`, `func`, `interface`, `map`, `struct`. +fn is_type_keyword(word: &str) -> bool { + matches!(word, "chan" | "func" | "interface" | "map" | "struct") +} + +fn is_ident_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +impl GoScopeIndex { + /// Read `source` (upstream `readGoScopes(blankGoCode(…))`). + pub(crate) fn read(source: &str) -> Self { + let joined = source.lines().collect::>().join("\n"); + read_go_scopes(blank_go_code(&joined)) + } + + /// The parameter or local `name` is at line `line` (1-based), column + /// `column`, if one is: the innermost scope holding the position + /// (upstream `goLocalDecl`). + pub(crate) fn local_decl(&self, name: &str, line: i64, column: i64) -> Option { + if !self.decls.contains_key(name) { + return None; + } + let start = usize::try_from(line.checked_sub(1)?) + .ok() + .and_then(|index| self.line_starts.get(index).copied()) + .unwrap_or(self.code.len()); + let at = start + usize::try_from(column.max(0)).unwrap_or(0); + self.scopes_of(name) + .iter() + .filter(|(from, to, _)| at > *from && at < *to) + .max_by_key(|(from, _, _)| *from) + .map(|(_, _, decl)| decl.clone()) + } + + fn scopes_of(&self, name: &str) -> Arc> { + if let Some(hit) = self + .scopes + .lock() + .expect("go scopes lock") + .get(name) + .cloned() + { + return hit; + } + static CLAUSE: OnceLock = OnceLock::new(); + let clause = CLAUSE.get_or_init(|| { + Regex::new(r"\n[ \t]*(?:case\b|default[ \t]*:)").expect("go case clause") + }); + let mut scopes: Vec = Vec::new(); + for decl in self.decls.get(name).into_iter().flatten() { + if decl.kind == DeclKind::Param { + let body = decl.body.unwrap_or(0); + let end = self.close.get(&body).copied().unwrap_or(self.code.len()); + scopes.push((body, end, decl.clone())); + continue; + } + // Outside every block it is a package-level name, which no import + // shares. + let Some(block) = self.block_at(decl.at) else { + continue; + }; + if decl.kind == DeclKind::Header { + for (start, end) in self.header_blocks(decl.at) { + scopes.push((start, end, decl.clone())); + } + continue; + } + let from = statement_end(&self.code, decl.at); + let mut to = block.1; + if decl.kind == DeclKind::Case { + for found in clause.find_iter(&self.code[from.min(self.code.len())..]) { + let at = from + found.start(); + if at >= to { + break; + } + if self.block_at(at + 1).map(|(open, _)| open) == Some(block.0) { + to = at; + break; + } + } + } + if from < to { + scopes.push((from, to, decl.clone())); + } + } + let scopes = Arc::new(scopes); + self.scopes + .lock() + .expect("go scopes lock") + .insert(name.to_string(), Arc::clone(&scopes)); + scopes + } + + /// The innermost `{…}` holding an offset, as `(open, close)` — `None` + /// outside every block (upstream `goBlockAt`). + fn block_at(&self, offset: usize) -> Option<(usize, usize)> { + let at = self.events.partition_point(|&event| event < offset); + let open = at.checked_sub(1).and_then(|at| self.open[at])?; + Some(( + open, + self.close.get(&open).copied().unwrap_or(self.code.len()), + )) + } + + /// The blocks of the `if` / `for` / `switch` statement whose header + /// declares at `at`: gofmt ends a header's line with its block's `{` (a + /// composite literal's in the header has more on its line), and an `else` + /// chain keeps the header's names (upstream `goHeaderBlocks`). + fn header_blocks(&self, at: usize) -> Vec<(usize, usize)> { + static ELSE: OnceLock = OnceLock::new(); + let else_re = ELSE.get_or_init(|| Regex::new(r"^[ \t]*else\b[ \t]*").expect("go else")); + let code = self.code.as_bytes(); + let mut blocks = Vec::new(); + let mut depth = 0i64; + let mut i = at; + while i < code.len() { + match code[i] { + b'(' | b'[' => depth += 1, + b')' | b']' => depth -= 1, + b'}' => break, + b'{' => { + let end = self.close.get(&i).copied().unwrap_or(code.len()); + let rest = slice_at(&self.code, i + 1, 80); + let line_end = rest + .trim_start_matches([' ', '\t']) + .chars() + .next() + .is_none_or(|c| c == '\n'); + if depth != 0 || !line_end { + i = end + 1; + continue; + } + blocks.push((i, end)); + let Some(chained) = else_re.find(slice_at(&self.code, end + 1, 40)) else { + break; + }; + i = end + chained.end() + 1; + depth = 0; + continue; + } + _ => {} + } + i += 1; + } + blocks + } +} + +/// At most `len` bytes of `code` from `start`, cut back to a char boundary. +fn slice_at(code: &str, start: usize, len: usize) -> &str { + if start >= code.len() { + return ""; + } + let mut end = (start + len).min(code.len()); + while !code.is_char_boundary(end) { + end -= 1; + } + &code[start..end] +} + +/// The offset of the bracket closing the one at `at`, or the end of `code` +/// (upstream `goClosing`). +fn closing(code: &str, at: usize) -> usize { + let mut depth = 0i64; + for (i, &byte) in code.as_bytes().iter().enumerate().skip(at) { + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + depth -= 1; + if depth == 0 { + return i; + } + } + _ => {} + } + } + code.len() +} + +/// Where the statement declaring at `at` ends: its line's end, or a `;`, +/// outside brackets (upstream `goStatementEnd`). +fn statement_end(code: &str, at: usize) -> usize { + let mut depth = 0i64; + for (i, &byte) in code.as_bytes().iter().enumerate().skip(at) { + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + depth -= 1; + if depth < 0 { + return i; + } + } + b'\n' | b';' if depth == 0 => return i, + _ => {} + } + } + code.len() +} + +fn read_go_scopes(code: String) -> GoScopeIndex { + let bytes = code.as_bytes(); + let mut line_starts = vec![0]; + line_starts.extend( + bytes + .iter() + .enumerate() + .filter(|(_, b)| **b == b'\n') + .map(|(i, _)| i + 1), + ); + let mut events = Vec::new(); + let mut open = Vec::new(); + let mut close = HashMap::new(); + let mut stack: Vec = Vec::new(); + for (i, &byte) in bytes.iter().enumerate() { + match byte { + b'{' => stack.push(i), + b'}' => { + if let Some(at) = stack.pop() { + close.insert(at, i); + } + } + _ => continue, + } + events.push(i); + open.push(stack.last().copied()); + } + let mut decls: HashMap> = HashMap::new(); + let mut add = |name: &str, decl: GoDecl| { + if name != "_" { + decls.entry(name.to_string()).or_default().push(decl); + } + }; + short_variable_declarations(&code, &mut add); + var_and_const_specs(&code, &mut add); + function_parameters(&code, &mut add); + GoScopeIndex { + code, + line_starts, + events, + open, + close, + decls, + scopes: Mutex::new(HashMap::new()), + } +} + +/// `a, b := …`: the identifiers before each `:=`. +fn short_variable_declarations(code: &str, add: &mut impl FnMut(&str, GoDecl)) { + static HEADER: OnceLock = OnceLock::new(); + static CASE: OnceLock = OnceLock::new(); + static TYPE: OnceLock = OnceLock::new(); + static FROM: OnceLock = OnceLock::new(); + let header = HEADER.get_or_init(|| { + Regex::new(r"^\s*(?:\}\s*else\s+)?(?:if|for|switch)\b").expect("go header") + }); + let case = CASE.get_or_init(|| Regex::new(r"^\s*case\b").expect("go case")); + let type_re = TYPE.get_or_init(|| { + Regex::new(r"^[ \t]*&?[ \t]*([A-Za-z_]\w*\.[A-Za-z_]\w*)[ \t]*\{").expect("go literal type") + }); + let from_re = FROM.get_or_init(|| { + Regex::new(r"^[ \t]*&?[ \t]*([A-Za-z_]\w*)\.[A-Za-z_]\w*[ \t]*[({]").expect("go value from") + }); + let bytes = code.as_bytes(); + for (i, _) in code.match_indices(":=") { + let mut names: Vec<(&str, usize)> = Vec::new(); + let mut p = i; + loop { + while p > 0 && matches!(bytes[p - 1], b' ' | b'\t') { + p -= 1; + } + let mut s = p; + while s > 0 && is_ident_byte(bytes[s - 1]) { + s -= 1; + } + if s == p || bytes[s].is_ascii_digit() || (s > 0 && bytes[s - 1] == b'.') { + break; + } + names.push((&code[s..p], s)); + p = s; + while p > 0 && matches!(bytes[p - 1], b' ' | b'\t') { + p -= 1; + } + if p == 0 || bytes[p - 1] != b',' { + break; + } + p -= 1; + } + let Some(&(_, first)) = names.last() else { + continue; + }; + let line_start = code[..first].rfind('\n').map_or(0, |at| at + 1); + let lead = &code[line_start..first]; + let kind = if header.is_match(lead) { + DeclKind::Header + } else if case.is_match(lead) { + DeclKind::Case + } else { + DeclKind::Local + }; + // `x := &pkg.T{…}` writes the type it declares; `x := pkg.New(…)` + // the package its value comes from. + let rhs = slice_at(code, i + 2, 160); + let declared_type = (names.len() == 1) + .then(|| type_re.captures(rhs).map(|caps| caps[1].to_string())) + .flatten(); + let from = from_re.captures(rhs).map(|caps| caps[1].to_string()); + for (name, at) in names { + add( + name, + GoDecl { + at, + kind, + body: None, + declared_type: declared_type.clone(), + from: from.clone(), + }, + ); + } + } +} + +/// `var x T`, `var a, b = …`, `const (…)`: names before a type, an `=` or the +/// line's end — in a group, on lines outside the brackets of a value spanning +/// several (`http.MethodHead,` in a `[]string{…}` is no name). +fn var_and_const_specs(code: &str, add: &mut impl FnMut(&str, GoDecl)) { + static KEYWORD: OnceLock = OnceLock::new(); + let keyword = KEYWORD.get_or_init(|| Regex::new(r"\b(?:var|const)\b[ \t]*").expect("go var")); + let bytes = code.as_bytes(); + for found in keyword.find_iter(code) { + // `(? 0 + && (is_ident_byte(bytes[found.start() - 1]) || bytes[found.start() - 1] == b'.') + { + continue; + } + let start = found.end(); + if bytes.get(start) != Some(&b'(') { + add_spec(code, start, add); + continue; + } + let end = closing(code, start); + add_spec(code, start + 1, add); + let mut depth = 0i64; + for (i, &byte) in bytes.iter().enumerate().take(end).skip(start + 1) { + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b'\n' if depth == 0 => add_spec(code, i + 1, add), + _ => {} + } + } + } +} + +/// One spec's names at `at` (upstream `addSpec`): `[ \t]*name(, name)*` +/// followed by a line's end, `=` (not `==`), or a type. +fn add_spec(code: &str, at: usize, add: &mut impl FnMut(&str, GoDecl)) { + static TYPE: OnceLock = OnceLock::new(); + let type_re = TYPE.get_or_init(|| { + Regex::new(r"^[ \t]+([^\s=;][^=;\n]*?)[ \t]*(?:[=;\n]|$)").expect("go spec type") + }); + let bytes = code.as_bytes(); + let mut p = at; + while p < bytes.len() && matches!(bytes[p], b' ' | b'\t') { + p += 1; + } + let names_start = p; + // `name(?:[ \t]*,[ \t]*name)*` + let mut names: Vec<(&str, usize)> = Vec::new(); + loop { + let name_start = p; + if !bytes + .get(p) + .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_') + { + break; + } + while p < bytes.len() && is_ident_byte(bytes[p]) { + p += 1; + } + names.push((&code[name_start..p], name_start)); + let mut q = p; + while q < bytes.len() && matches!(bytes[q], b' ' | b'\t') { + q += 1; + } + if bytes.get(q) != Some(&b',') { + break; + } + q += 1; + while q < bytes.len() && matches!(bytes[q], b' ' | b'\t') { + q += 1; + } + if !bytes + .get(q) + .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_') + { + break; + } + p = q; + } + if names.is_empty() || names_start == p { + return; + } + let end = p; + // `(?=[ \t]*(?:\n|$|=(?!=))|[ \t]+(?:[*[A-Za-z_(]|<-))` + let after = &code[end..]; + let spaced = after.trim_start_matches([' ', '\t']); + let ends = spaced.is_empty() + || spaced.starts_with('\n') + || (spaced.starts_with('=') && !spaced.starts_with("==")); + let typed = after.len() > spaced.len() + && (spaced.starts_with(['*', '[', '(']) + || spaced.starts_with("<-") + || spaced + .bytes() + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')); + if !(ends || typed) { + return; + } + let declared_type = type_re + .captures(slice_at(code, end, 160)) + .map(|caps| caps[1].to_string()); + for (name, name_at) in names { + add( + name, + GoDecl { + at: name_at, + kind: DeclKind::Local, + body: None, + declared_type: declared_type.clone(), + from: None, + }, + ); + } +} + +/// Each `func`'s receiver, parameters and results, bound in its body. A +/// `[]func(…)` or `chan func(…)` is a type, whatever brace follows it. +fn function_parameters(code: &str, add: &mut impl FnMut(&str, GoDecl)) { + static FUNC: OnceLock = OnceLock::new(); + static FUNC_TYPE: OnceLock = OnceLock::new(); + let func = FUNC.get_or_init(|| Regex::new(r"\bfunc\b").expect("go func")); + let func_type = + FUNC_TYPE.get_or_init(|| Regex::new(r"(?:\]|\bchan)[ \t]*$").expect("go func type")); + let bytes = code.as_bytes(); + for found in func.find_iter(code) { + let at = found.start(); + if at > 0 && (is_ident_byte(bytes[at - 1]) || bytes[at - 1] == b'.') { + continue; + } + let mut lead_start = at.saturating_sub(8); + while !code.is_char_boundary(lead_start) { + lead_start -= 1; + } + if func_type.is_match(&code[lead_start..at]) { + continue; + } + let Some((groups, body)) = signature(code, at + 4) else { + continue; + }; + for (from, to) in groups { + for (name, name_at, declared_type) in param_names(code, from, to) { + add( + name, + GoDecl { + at: name_at, + kind: DeclKind::Param, + body: Some(body), + declared_type: Some(declared_type), + from: None, + }, + ); + } + } + } +} + +/// The parameter lists of the `func` whose keyword ends at `at` — receiver, +/// parameters, results — as the offsets inside their parentheses, and the +/// offset of its body's `{`; `None` for a function type, which has no body +/// (upstream `goSignature`). +fn signature(code: &str, at: usize) -> Option<(Vec<(usize, usize)>, usize)> { + let bytes = code.as_bytes(); + let mut groups: Vec<(usize, usize)> = Vec::new(); + let mut p = at; + let skip = |p: &mut usize| { + while *p < bytes.len() && matches!(bytes[*p], b' ' | b'\t') { + *p += 1; + } + }; + let group = |p: &mut usize, groups: &mut Vec<(usize, usize)>| -> bool { + if bytes.get(*p) != Some(&b'(') { + return false; + } + let end = closing(code, *p); + groups.push((*p + 1, end)); + *p = end + 1; + skip(p); + true + }; + // `func Name[T any](…)`, `func (r T) Name(…)`, or a literal / type + // `func(…)`. + let name = |p: &mut usize| -> bool { + let start = *p; + if !bytes + .get(start) + .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_') + { + return false; + } + let mut q = start; + while q < bytes.len() && is_ident_byte(bytes[q]) { + q += 1; + } + if is_type_keyword(&code[start..q]) { + return false; + } + while q < bytes.len() && matches!(bytes[q], b' ' | b'\t') { + q += 1; + } + if bytes.get(q) == Some(&b'[') { + q = closing(code, q) + 1; + } + while q < bytes.len() && matches!(bytes[q], b' ' | b'\t') { + q += 1; + } + if bytes.get(q) != Some(&b'(') { + return false; + } + *p = q; + true + }; + skip(&mut p); + if bytes.get(p) != Some(&b'(') { + if !name(&mut p) || !group(&mut p, &mut groups) { + return None; + } + } else { + group(&mut p, &mut groups); + if name(&mut p) && !group(&mut p, &mut groups) { + return None; + } + } + if bytes.get(p) == Some(&b'(') { + group(&mut p, &mut groups); + } + // A result type up to the body; a function type ends at a `,`, `)`, `}`, + // `=`, `;` or the line's end instead. + let mut depth = 0i64; + while p < bytes.len() { + match bytes[p] { + b'(' | b'[' => depth += 1, + b')' | b']' => { + depth -= 1; + if depth < 0 { + return None; + } + } + b'{' => { + if depth == 0 { + // `struct{…}` / `interface{…}` is the result type's own + // brace. + let mut lead_start = p.saturating_sub(12); + while !code.is_char_boundary(lead_start) { + lead_start -= 1; + } + let lead = code[lead_start..p].trim_end(); + if lead.ends_with("struct") || lead.ends_with("interface") { + p = closing(code, p); + } else { + return Some((groups, p)); + } + } + } + b'\n' | b',' | b';' | b'=' | b'}' if depth == 0 => return None, + _ => {} + } + p += 1; + } + None +} + +/// The names a Go parameter list between `from` and `to` declares, with the +/// type each is declared as: `(a, b int, c string)` declares a and b as `int` +/// and c as `string`; `(int, pkg.T)` and `(*T, ...U)` declare none (upstream +/// `goParamNames`). +fn param_names(code: &str, from: usize, to: usize) -> Vec<(&str, usize, String)> { + let bytes = code.as_bytes(); + let to = to.min(bytes.len()); + let mut parts: Vec<(usize, usize)> = Vec::new(); + let mut depth = 0i64; + let mut start = from; + for (i, &byte) in bytes.iter().enumerate().take(to).skip(from) { + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b',' if depth == 0 => { + parts.push((start, i)); + start = i + 1; + } + _ => {} + } + } + parts.push((start, to)); + let mut names: Vec<(&str, usize, String)> = Vec::new(); + let mut grouped: Vec<(&str, usize)> = Vec::new(); + for (s, e) in parts { + if s > e { + continue; + } + let text = &code[s..e]; + let lead = text.len() - text.trim_start().len(); + let rest = &text[lead..]; + let name_len = rest + .bytes() + .position(|b| !is_ident_byte(b)) + .unwrap_or(rest.len()); + if name_len == 0 + || !rest + .bytes() + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') + { + continue; + } + let name = &rest[..name_len]; + if is_type_keyword(name) { + continue; + } + let after = &rest[name_len..]; + // `pkg.T` is a type; a variadic `args ...T` is a name. + let after_trimmed = after.trim_start(); + if after_trimmed.starts_with('.') && !after_trimmed.starts_with("...") { + continue; + } + let at = s + lead; + if after.trim().is_empty() { + grouped.push((name, at)); + continue; + } + // `a, b int`: the names before a typed one share its type. + let declared_type = after.trim().to_string(); + for (grouped_name, grouped_at) in grouped.drain(..) { + names.push((grouped_name, grouped_at, declared_type.clone())); + } + names.push((name, at, declared_type)); + } + // A list with no typed name names no parameters: its lone identifiers + // are types. + names +} + +/// Go source with its comments and the contents of its strings and runes +/// blanked: offsets and newlines stay, so a match still maps to its line and +/// column (upstream `blankGoCode`). +fn blank_go_code(src: &str) -> String { + let bytes = src.as_bytes(); + let mut out = bytes.to_vec(); + let blank = |out: &mut Vec, from: usize, to: usize| { + for byte in &mut out[from..to] { + if *byte != b'\n' { + *byte = b' '; + } + } + }; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'/' if bytes.get(i + 1) == Some(&b'/') => { + let end = src[i..].find('\n').map_or(src.len(), |at| i + at); + blank(&mut out, i, end); + i = end; + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + let end = src[i + 2..] + .find("*/") + .map_or(src.len(), |at| i + 2 + at + 2); + blank(&mut out, i, end); + i = end; + } + b'`' => { + let end = src[i + 1..].find('`').map_or(src.len(), |at| i + 1 + at); + blank(&mut out, i + 1, end); + i = end + 1; + } + quote @ (b'"' | b'\'') => { + let mut j = i + 1; + while j < bytes.len() { + if bytes[j] == b'\\' { + j += 2; + continue; + } + if bytes[j] == quote || bytes[j] == b'\n' { + break; + } + j += 1; + } + let j = j.min(bytes.len()); + blank(&mut out, i + 1, j); + i = j + 1; + } + _ => i += 1, + } + } + String::from_utf8(out).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(index: &GoScopeIndex, needle: &str) -> (i64, i64) { + let offset = index.code.find(needle).expect("needle"); + let line = index.line_starts.partition_point(|&start| start <= offset); + let column = offset - index.line_starts[line - 1]; + (line as i64, column as i64) + } + + #[test] + fn a_name_is_the_local_or_parameter_whose_scope_holds_it() { + let source = "package auth\n\nimport \"github.com/golang-jwt/jwt/v5\"\n\nfunc TestJWTInfo(t *testing.T) {\n\tjwt, err := newTokenProviderJWT(\"key\")\n\t_ = err\n\tjwt.assign(\"abc\")\n}\n\nfunc TestJWTParse() {\n\tjwt.Parse(\"x\")\n}\n\nfunc (cache *store) Refresh() {\n\tcache.load()\n}\n\nfunc logError(logger logger.Interface, err error) {\n\tlogger.Error(err) // logger.Error here\n}\n"; + let index = GoScopeIndex::read(source); + let (line, column) = at(&index, "jwt.assign"); + assert!(index.local_decl("jwt", line, column).is_some()); + let (line, column) = at(&index, "jwt.Parse"); + assert!(index.local_decl("jwt", line, column).is_none()); + let (line, column) = at(&index, "cache.load"); + assert!(index.local_decl("cache", line, column).is_some()); + let (line, column) = at(&index, "logger.Error(err)"); + let decl = index.local_decl("logger", line, column).expect("parameter"); + assert_eq!(decl.declared_type.as_deref(), Some("logger.Interface")); + } + + #[test] + fn a_header_name_lives_in_its_blocks_and_a_case_name_in_its_clause() { + let source = "package p\n\nfunc f(ch chan int) {\n\tif cmp := pick(); cmp != nil {\n\t\tcmp.Compare(1, 2)\n\t} else {\n\t\tcmp.Compare(3, 4)\n\t}\n\tcmp.Diff(\"a\", \"b\")\n\tfor _, item := range items() {\n\t\titem.Close()\n\t}\n\titem.Open()\n\tselect {\n\tcase clock := <-ch:\n\t\tclock.Now()\n\tdefault:\n\t\tclock.Later()\n\t}\n\tlocal := clocktesting.NewFakeClock(0)\n\tlocal.Now()\n}\n"; + let index = GoScopeIndex::read(source); + let (line, column) = at(&index, "cmp.Compare(1, 2)"); + assert!(index.local_decl("cmp", line, column).is_some()); + let (line, column) = at(&index, "cmp.Compare(3, 4)"); + assert!(index.local_decl("cmp", line, column).is_some()); + let (line, column) = at(&index, "cmp.Diff"); + assert!(index.local_decl("cmp", line, column).is_none()); + let (line, column) = at(&index, "item.Close()"); + assert!(index.local_decl("item", line, column).is_some()); + let (line, column) = at(&index, "item.Open()"); + assert!(index.local_decl("item", line, column).is_none()); + let (line, column) = at(&index, "clock.Now()"); + assert!(index.local_decl("clock", line, column).is_some()); + let (line, column) = at(&index, "clock.Later()"); + assert!(index.local_decl("clock", line, column).is_none()); + let (line, column) = at(&index, "local.Now()"); + let decl = index.local_decl("local", line, column).expect("local"); + assert_eq!(decl.from.as_deref(), Some("clocktesting")); + } + + #[test] + fn comments_and_strings_are_blanked_in_place() { + let code = blank_go_code("a := \"x := 1\" // y := 2\nb := `z\n:= 3`\n"); + assert_eq!(code, "a := \" \" \nb := ` \n `\n"); + } + + #[test] + fn parameter_lists_name_what_they_declare() { + let code = "(a, b int, c string, args ...T)"; + let names: Vec<(String, String)> = param_names(code, 1, code.len() - 1) + .into_iter() + .map(|(name, _, ty)| (name.to_string(), ty)) + .collect(); + assert_eq!( + names, + [ + ("a".into(), "int".into()), + ("b".into(), "int".into()), + ("c".into(), "string".into()), + ("args".into(), "...T".into()), + ] + ); + let code = "(int, pkg.T)"; + assert!(param_names(code, 1, code.len() - 1).is_empty()); + } +} diff --git a/crates/codegraph-resolve/src/import_resolver.rs b/crates/codegraph-resolve/src/import_resolver.rs index d652dcae..48298133 100644 --- a/crates/codegraph-resolve/src/import_resolver.rs +++ b/crates/codegraph-resolve/src/import_resolver.rs @@ -12,9 +12,7 @@ use crate::name_matcher::{ }; use crate::path_aliases::apply_aliases; use crate::pathutil; -use crate::types::{ - GoModule, ImportMapping, ReExport, RefView, ResolutionContext, ResolvedBy, ResolvedRef, -}; +use crate::types::{ImportMapping, ReExport, RefView, ResolutionContext, ResolvedBy, ResolvedRef}; use crate::workspace_packages::resolve_workspace_import; use codegraph_core::types::{Language, Node, NodeKind}; use regex::Regex; @@ -369,6 +367,20 @@ fn is_external_import( import_path: &str, language: Language, context: &dyn ResolutionContext, +) -> bool { + is_external_import_with(import_path, language, context, true) +} + +/// [`is_external_import`], where `alias_prefixes: false` skips the test that +/// a JS specifier starting with a project path alias's prefix is local. Only a +/// caller that asks [`resolve_import_path`] whether the alias maps the import +/// to a file may skip it: a catch-all `"*"` alias has an empty prefix, which +/// every package starts with (upstream #2437). +fn is_external_import_with( + import_path: &str, + language: Language, + context: &dyn ResolutionContext, + alias_prefixes: bool, ) -> bool { if import_path.starts_with('.') { return false; @@ -407,7 +419,7 @@ fn is_external_import( return true; } // Project-defined alias prefix → local (import-resolver.ts:149-154). - if let Some(aliases) = context.get_project_aliases() { + if let Some(aliases) = context.get_project_aliases().filter(|_| alias_prefixes) { for pat in &aliases.patterns { if import_path.starts_with(&pat.prefix) { return false; @@ -446,12 +458,10 @@ fn is_external_import( if import_path.starts_with('.') { return false; } - for module in context.get_go_modules() { - if import_path == module.module_path - || import_path.starts_with(&format!("{}/", module.module_path)) - { - return false; - } + // A package of one of the project's modules, whose go.mod may be + // below the root or beside other modules (upstream #2322). + if crate::go_packages::go_import_package_dir(import_path, None, context).is_some() { + return false; } if import_path.contains("/internal/") { return false; @@ -502,6 +512,15 @@ fn resolve_relative_import( let base_path = pathutil::resolve(from_dir, import_path); let relative_path = pathutil::relative(project_root, &base_path); + // `require('..')` up to the project root is its `index.js` (upstream + // #2189): only the `/index…` candidates name a file there. + if relative_path.is_empty() || relative_path == "." { + return extension_resolution(language) + .iter() + .filter_map(|extension| extension.strip_prefix('/')) + .find(|candidate| context.file_exists(candidate)) + .map(str::to_string); + } resolve_source_candidate(&relative_path, language, context) } @@ -580,6 +599,68 @@ fn resolve_cpp_include_path( None } +/// A JS/TS module specifier written as a path (upstream `isJsPathSpecifier`, +/// #2263): relative (`./x`, `../x`, `.`, `..`), rooted, or an alias with a +/// folder in it (`@/lib/x`, `~/x`, `$lib/x`), rather than an imported +/// binding's name or a bare or scoped package (`react`, `@scope/pkg`). +fn is_js_path_specifier(name: &str) -> bool { + if name.starts_with("./") || name.starts_with("../") || name == "." || name == ".." { + return true; + } + if !name.contains('/') || name.chars().any(char::is_whitespace) { + return false; + } + // `@scope/pkg`: exactly one `/`, after an `@`-prefixed segment. + let scoped_package = name.strip_prefix('@').is_some_and(|rest| { + let mut parts = rest.split('/'); + let (Some(scope), Some(pkg), None) = (parts.next(), parts.next(), parts.next()) else { + return false; + }; + let word = |part: &str| { + !part.is_empty() + && part + .chars() + .all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-')) + }; + word(scope) && word(pkg) + }); + !scoped_package +} + +/// A JS/TS `imports` reference that names a module by path (upstream +/// `isJsPathImportRef`, #2263). It names a FILE, not a symbol, so it skips the +/// name-exists pre-filter. +pub fn is_js_path_import_ref(reference: &RefView) -> bool { + reference.reference_kind == codegraph_core::types::EdgeKind::Imports + && matches!( + reference.language, + Language::JavaScript + | Language::Jsx + | Language::TypeScript + | Language::Tsx + | Language::ArkTs + ) + && is_js_path_specifier(&reference.reference_name) +} + +/// A Lua / Luau `require` (upstream `isLuaRequireRef`, #2392): it names a +/// module file or a module from outside the project, never a symbol. +pub fn is_lua_require_ref(reference: &RefView) -> bool { + matches!(reference.language, Language::Lua | Language::Luau) + && reference.reference_kind == codegraph_core::types::EdgeKind::Imports +} + +/// A Dart `import`, `export` or `part` (upstream `isDartImportRef`, #2388): +/// its name is the URI, which names one file or none — `dart:async`, a +/// package from outside the project, a generated part nobody committed — and +/// never a symbol: the name-matcher took the URI's last segment for a file +/// name (`package:flutter/foundation.dart` went to riverpod's own +/// foundation.dart) or bound it to the file's own `import` node. +pub fn is_dart_import_ref(reference: &RefView) -> bool { + reference.language == Language::Dart + && reference.reference_kind == codegraph_core::types::EdgeKind::Imports +} + /// Is this reference a PHP include/require PATH (vs a namespace `use` symbol)? /// (`isPhpIncludePathRef`, `import-resolver.ts:541-547`). pub fn is_php_include_path_ref(reference: &RefView) -> bool { @@ -614,18 +695,29 @@ fn resolve_php_include_path( fn js_import_regex() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { + // `import type { X }` / `import type * as ns` is TypeScript's type-only + // form, not a default import named `type` (upstream #2134); the + // optional `type` group takes it, and `import type from './x'`, which + // leaves nothing else, still binds `type` (see `extract_js_imports`). + // A binding is any JS identifier, `$` included (upstream #2378): `\w` + // stops at the `$` of `import $store from './store'`. Regex::new( - r#"import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+(\w+))?\s*from\s*['"]([^'"]+)['"]"#, + r#"import\s+(?:(type)\s+)?(?:([A-Za-z_$][0-9A-Za-z_$]*)\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+([A-Za-z_$][0-9A-Za-z_$]*))?\s*from\s*['"]([^'"]+)['"]"#, ) .expect("valid JS import regex") }) } +/// A `require` binding: each declarator of a list too (`var express = +/// require('../'), request = require('supertest')`), and a member of the +/// module (`require('./utils').methods`, upstream #2189). fn js_require_regex() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { - Regex::new(r#"(?:const|let|var)\s+(?:(\w+)|\{([^}]+)\})\s*=\s*require\(['"]([^'"]+)['"]\)"#) - .expect("valid JS require regex") + Regex::new( + r#"(?:(?-u:\b)(?:const|let|var)\s+|,\s*)(?:([A-Za-z_$][0-9A-Za-z_$]*)|\{([^}]+)\})\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*([A-Za-z_$][0-9A-Za-z_$]*))?"#, + ) + .expect("valid JS require regex") }) } @@ -653,11 +745,25 @@ fn extract_js_imports(content: &str) -> Vec { let mut mappings = Vec::new(); for caps in js_import_regex().captures_iter(content) { - let default_import = caps.get(1).map(|m| m.as_str()); - let named_imports = caps.get(2).map(|m| m.as_str()); - let star = caps.get(3).is_some(); - let namespace_alias = caps.get(4).map(|m| m.as_str()); - let source = caps.get(5).map(|m| m.as_str()).unwrap_or_default(); + // A default binding is never followed by `{`: the `${` of a code + // generator's `` `import ${name} from '${src}'` `` is no binding + // (upstream #2378's `(?![\w${])`). + if caps + .get(2) + .is_some_and(|binding| content[binding.end()..].starts_with('{')) + { + continue; + } + let type_only = caps.get(1).is_some(); + let named_imports = caps.get(3).map(|m| m.as_str()); + let star = caps.get(4).is_some(); + let namespace_alias = caps.get(5).map(|m| m.as_str()); + let source = caps.get(6).map(|m| m.as_str()).unwrap_or_default(); + // `import type from './x'`: the `type` group took the binding's name. + let default_import = caps + .get(2) + .map(|m| m.as_str()) + .or((type_only && named_imports.is_none() && !star).then_some("type")); if let Some(default_import) = default_import { mappings.push(ImportMapping { @@ -671,13 +777,16 @@ fn extract_js_imports(content: &str) -> Vec { if let Some(named) = named_imports { for raw in named.split(',') { - let name = raw.trim(); + let name = strip_inline_type_modifier(raw.trim()); if let Some((orig, alias)) = parse_as_alias(name) { + // `{ default as X }` is the default import spelled as a + // named one (upstream #2412). + let is_default = orig == "default"; mappings.push(ImportMapping { local_name: alias, exported_name: orig, source: source.to_string(), - is_default: false, + is_default, is_namespace: false, }); } else if !name.is_empty() { @@ -709,13 +818,16 @@ fn extract_js_imports(content: &str) -> Vec { let default_name = caps.get(1).map(|m| m.as_str()); let destructured = caps.get(2).map(|m| m.as_str()); let source = caps.get(3).map(|m| m.as_str()).unwrap_or_default(); + let member = caps.get(4).map(|m| m.as_str()); if let Some(default_name) = default_name { + // `require('./utils').setCharset` is the module's `setCharset`, + // not its default (upstream #2189). mappings.push(ImportMapping { local_name: default_name.to_string(), - exported_name: "default".to_string(), + exported_name: member.unwrap_or("default").to_string(), source: source.to_string(), - is_default: true, + is_default: member.is_none(), is_namespace: false, }); } @@ -723,7 +835,7 @@ fn extract_js_imports(content: &str) -> Vec { if let Some(destructured) = destructured { for raw in destructured.split(',') { let name = raw.trim(); - if let Some((orig, alias)) = parse_colon_alias(name) { + if let Some((orig, alias)) = parse_js_colon_alias(name) { mappings.push(ImportMapping { local_name: alias, exported_name: orig, @@ -747,21 +859,31 @@ fn extract_js_imports(content: &str) -> Vec { mappings } -/// `extractPythonImports` (`import-resolver.ts:717-765`). +/// `extractPythonImports` (`import-resolver.ts:717-765`), over the code +/// alone: a docstring's `from x import y` binds nothing, and a parenthesized +/// list (`from x import (\n A,\n B,\n)`, upstream #2291) binds every +/// name in it, whatever its comments hold. fn extract_python_imports(content: &str) -> Vec { static FROM_RE: OnceLock = OnceLock::new(); static IMP_RE: OnceLock = OnceLock::new(); - let from_re = FROM_RE - .get_or_init(|| Regex::new(r"from\s+([\w.]+)\s+import\s+([^#\n]+)").expect("py from")); + let from_re = FROM_RE.get_or_init(|| { + Regex::new(r"from\s+([\w.]+)\s+import\s+(\([^)]*\)|[^#\n]+)").expect("py from") + }); let imp_re = IMP_RE.get_or_init(|| { Regex::new(r"(?m)^import\s+([\w.]+)(?:\s+as\s+(\w+))?").expect("py import") }); + let content = crate::strip_comments::strip_comments_for_regex( + content, + crate::strip_comments::CommentLang::Python, + ); let mut mappings = Vec::new(); - for caps in from_re.captures_iter(content) { + for caps in from_re.captures_iter(&content) { let source = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); - let imports = caps.get(2).map(|m| m.as_str()).unwrap_or_default(); + let imports = caps.get(2).map(|m| m.as_str()).unwrap_or_default().trim(); + let imports = imports.strip_prefix('(').unwrap_or(imports); + let imports = imports.strip_suffix(')').unwrap_or(imports); for raw in imports.split(',') { let name = raw.trim(); if let Some((orig, alias)) = parse_as_alias(name) { @@ -784,7 +906,7 @@ fn extract_python_imports(content: &str) -> Vec { } } - for caps in imp_re.captures_iter(content) { + for caps in imp_re.captures_iter(&content) { let source = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); let alias = caps.get(2).map(|m| m.as_str()); let local_name = alias @@ -802,54 +924,129 @@ fn extract_python_imports(content: &str) -> Vec { mappings } -/// `extractGoImports` (`import-resolver.ts:770-810`). +/// `extractGoImports` (upstream #2410). An import is bound to the name +/// written before its path, or else to the name of the package it imports, +/// which the path alone doesn't settle: `k8s.io/api/core/v1` is package `v1`, +/// while `go.yaml.in/yaml/v3` and `gopkg.in/yaml.v3` are `yaml` and +/// `github.com/mattn/go-sqlite3` is `sqlite3`. So an unaliased import keeps its +/// last path element and also takes the name goimports assumes for the path, +/// unless another of the file's imports is bound to that name or a second one +/// assumes it too. Only the import section is read, without its comments: a +/// word in one is no name (`"fmt" // for printing` named the next import +/// `printing`), and an import spelled in a comment or a later string is none. fn extract_go_imports(content: &str) -> Vec { static SINGLE_RE: OnceLock = OnceLock::new(); static BLOCK_RE: OnceLock = OnceLock::new(); static LINE_RE: OnceLock = OnceLock::new(); let single_re = SINGLE_RE.get_or_init(|| { - Regex::new(r#"import\s+(?:(\w+)\s+)?["']([^"']+)["']"#).expect("go single") + Regex::new(r#"\bimport\s+(?:([A-Za-z_]\w*)[ \t]*)?["']([^"']+)["']"#).expect("go single") }); let block_re = - BLOCK_RE.get_or_init(|| Regex::new(r"(?s)import\s*\(\s*([^)]+)\s*\)").expect("go block")); - let line_re = - LINE_RE.get_or_init(|| Regex::new(r#"(?:(\w+)\s+)?["']([^"']+)["']"#).expect("go line")); - - let mut mappings = Vec::new(); + BLOCK_RE.get_or_init(|| Regex::new(r"\bimport\s*\(\s*([^)]+)\s*\)").expect("go block")); + let line_re = LINE_RE.get_or_init(|| { + Regex::new(r#"(?:([A-Za-z_]\w*|\.)[ \t]*)?["']([^"']+)["']"#).expect("go line") + }); + let code = crate::strip_comments::strip_comments_for_regex( + &content[..go_imports_end(content)], + crate::strip_comments::CommentLang::Go, + ); - for caps in single_re.captures_iter(content) { - let alias = caps.get(1).map(|m| m.as_str()); - let source = caps.get(2).map(|m| m.as_str()).unwrap_or_default(); - let package_name = source.rsplit('/').next().unwrap_or(source); - mappings.push(ImportMapping { - local_name: alias.unwrap_or(package_name).to_string(), - exported_name: "*".to_string(), - source: source.to_string(), - is_default: false, - is_namespace: true, - }); + let mut specs: Vec<(Option<&str>, &str)> = Vec::new(); + for caps in single_re.captures_iter(&code) { + let name = caps.get(1).map(|m| m.as_str()); + specs.push((name, caps.get(2).map_or("", |m| m.as_str()))); } - - for caps in block_re.captures_iter(content) { - let block = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); + for caps in block_re.captures_iter(&code) { + let block = caps.get(1).map_or("", |m| m.as_str()); for line_caps in line_re.captures_iter(block) { - let alias = line_caps.get(1).map(|m| m.as_str()); - let source = line_caps.get(2).map(|m| m.as_str()).unwrap_or_default(); - let package_name = source.rsplit('/').next().unwrap_or(source); - mappings.push(ImportMapping { - local_name: alias.unwrap_or(package_name).to_string(), - exported_name: "*".to_string(), - source: source.to_string(), - is_default: false, - is_namespace: true, - }); + let name = line_caps.get(1).map(|m| m.as_str()); + specs.push((name, line_caps.get(2).map_or("", |m| m.as_str()))); } } + let mapping = |local_name: &str, source: &str| ImportMapping { + local_name: local_name.to_string(), + exported_name: "*".to_string(), + source: source.to_string(), + is_default: false, + is_namespace: true, + }; + // A dot import (`. "pkg"`) binds no name of its own; it stays listed under + // its last path element. + let mut mappings: Vec = specs + .iter() + .map(|&(name, source)| match name { + Some(name) if name != "." => mapping(name, source), + _ => mapping(source.rsplit('/').next().unwrap_or(source), source), + }) + .collect(); + let bound: BTreeSet = mappings.iter().map(|m| m.local_name.clone()).collect(); + let mut assumed: Vec<(String, Vec<&str>)> = Vec::new(); + for &(name, source) in &specs { + if name.is_some() { + continue; + } + let assumed_name = go_assumed_package_name(source); + if assumed_name.is_empty() || bound.contains(&assumed_name) { + continue; + } + match assumed.iter_mut().find(|(name, _)| *name == assumed_name) { + Some((_, sources)) => sources.push(source), + None => assumed.push((assumed_name, vec![source])), + } + } + // Two imports that assume one name can't both be that package. + for (local_name, sources) in &assumed { + if let [source] = sources.as_slice() { + mappings.push(mapping(local_name, source)); + } + } mappings } -/// `extractJavaImports` (`import-resolver.ts:826-853`). +/// Where a Go file's imports end: at its first other top-level declaration, a +/// line opening with `func`, `type`, `var` or `const` outside a block comment +/// (upstream `goImportsEnd`). +fn go_imports_end(content: &str) -> usize { + static DECL_RE: OnceLock = OnceLock::new(); + let decl_re = DECL_RE + .get_or_init(|| Regex::new(r"(?m)^(?:func|type|var|const)\b").expect("go declaration")); + decl_re + .find_iter(content) + .map(|found| found.start()) + .find(|&at| { + let open = content[..at].rfind("/*"); + let close = content[..at].rfind("*/"); + open.is_none() || close.is_some_and(|close| open < Some(close)) + }) + .unwrap_or(content.len()) +} + +/// The package name goimports assumes for an import path +/// (`ImportPathToAssumedName`, upstream `goAssumedPackageName`): its last +/// element that isn't a major version (`go.yaml.in/yaml/v3` → `yaml`), without +/// a `go-` prefix (`github.com/mattn/go-sqlite3` → `sqlite3`), cut where an +/// identifier can't go on (`gopkg.in/yaml.v3` → `yaml`). +fn go_assumed_package_name(import_path: &str) -> String { + let elements: Vec<&str> = import_path.split('/').collect(); + let mut base = elements.last().copied().unwrap_or_default(); + let is_major_version = |element: &str| { + element + .strip_prefix('v') + .is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())) + }; + if elements.len() > 1 && is_major_version(base) { + base = elements[elements.len() - 2]; + } + let base = base.strip_prefix("go-").unwrap_or(base); + base.chars() + .take_while(|c| c.is_alphabetic() || c.is_numeric() || *c == '_') + .collect() +} + +/// `extractJavaImports` (`import-resolver.ts:826-853`): Java's `import +/// [static] [.*];`, and Kotlin's `import [as Alias]` with no `;` +/// (upstream #2196). A renaming import binds its alias to the imported name. fn extract_java_imports(content: &str) -> Vec { static BLOCK_RE: OnceLock = OnceLock::new(); static LINE_RE: OnceLock = OnceLock::new(); @@ -857,7 +1054,8 @@ fn extract_java_imports(content: &str) -> Vec { let block_re = BLOCK_RE.get_or_init(|| Regex::new(r"(?s)/\*.*?\*/").expect("java block")); let line_re = LINE_RE.get_or_init(|| Regex::new(r"//[^\n]*").expect("java line")); let import_re = IMPORT_RE.get_or_init(|| { - Regex::new(r"(?m)^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;").expect("java import") + Regex::new(r"(?m)^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)(?:\s+as\s+(\w+))?\s*(?:;|$)") + .expect("java import") }); let no_block = block_re.replace_all(content, ""); @@ -868,13 +1066,14 @@ fn extract_java_imports(content: &str) -> Vec { if fqn.ends_with(".*") { continue; } - let local_name = fqn.rsplit('.').next().unwrap_or(fqn); + let exported_name = fqn.rsplit('.').next().unwrap_or(fqn); + let local_name = caps.get(3).map_or(exported_name, |alias| alias.as_str()); if local_name.is_empty() { continue; } mappings.push(ImportMapping { local_name: local_name.to_string(), - exported_name: local_name.to_string(), + exported_name: exported_name.to_string(), source: fqn.to_string(), is_default: false, is_namespace: false, @@ -936,16 +1135,45 @@ fn extract_cpp_imports(content: &str) -> Vec { mappings } +/// `{ util, type objectUtil }`: an inline `type` modifier is not part of the +/// binding's name (upstream #2134, `^type\s+(?=\w)`). +fn strip_inline_type_modifier(name: &str) -> &str { + let Some(rest) = name.strip_prefix("type") else { + return name; + }; + if !rest.starts_with(char::is_whitespace) { + return name; + } + let rest = rest.trim_start(); + if rest + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_' || byte == b'$') + { + rest + } else { + name + } +} + fn parse_as_alias(name: &str) -> Option<(String, String)> { static RE: OnceLock = OnceLock::new(); - let re = RE.get_or_init(|| Regex::new(r"(\w+)\s+as\s+(\w+)").expect("as-alias")); + let re = RE.get_or_init(|| { + Regex::new(r"([A-Za-z_$][0-9A-Za-z_$]*)\s+as\s+([A-Za-z_$][0-9A-Za-z_$]*)") + .expect("as-alias") + }); re.captures(name) .map(|c| (c[1].to_string(), c[2].to_string())) } -fn parse_colon_alias(name: &str) -> Option<(String, String)> { +/// `key: local` in a destructured `require` pattern, with JS identifiers +/// (`$` included, upstream's `[A-Za-z_$][\w$]*`). +fn parse_js_colon_alias(name: &str) -> Option<(String, String)> { static RE: OnceLock = OnceLock::new(); - let re = RE.get_or_init(|| Regex::new(r"(\w+)\s*:\s*(\w+)").expect("colon-alias")); + let re = RE.get_or_init(|| { + Regex::new(r"([A-Za-z_$][0-9A-Za-z_$]*)\s*:\s*([A-Za-z_$][0-9A-Za-z_$]*)") + .expect("js colon alias") + }); re.captures(name) .map(|c| (c[1].to_string(), c[2].to_string())) } @@ -1118,8 +1346,8 @@ fn split_top_level_commas(inner: &str) -> Vec { /// ref, a `type_alias` for a supertype). /// /// Two oracles only, each one its language cannot be wrong about: -/// - the JS specifier family, via the existing [`is_external_import`], which -/// already understands tsconfig path aliases and workspace packages; and +/// - the JS specifier family, via [`is_out_of_repo_js_import`], which +/// understands tsconfig path aliases and workspace packages; and /// - Rust `use`, restricted to [`RUST_OUT_OF_REPO_ROOTS`], with the escape that a /// path walking to a real file is local. /// @@ -1144,7 +1372,14 @@ pub fn is_bound_to_out_of_repo_module( .get_import_mappings(&reference.file_path, reference.language) .into_iter() .filter(|m| m.local_name == head) - .any(|m| is_external_import(&m.source, reference.language, context)); + .any(|m| { + is_out_of_repo_js_import( + &m.source, + &reference.file_path, + reference.language, + context, + ) + }); } if reference.language == Language::Rust { @@ -1160,6 +1395,42 @@ pub fn is_bound_to_out_of_repo_module( false } +/// Does a JS-family import of `source` from `from_file` name a module outside +/// the repository? A path alias makes it the project's only when it maps the +/// import to a file the index holds (upstream #2437). Matching the alias's +/// prefix is not enough: a catch-all `"*": ["./typings/*"]` matches every +/// package. Nor is a path that exists on disk: an alias can land in +/// `node_modules`, or on a directory. +fn is_out_of_repo_js_import( + source: &str, + from_file: &str, + language: Language, + context: &dyn ResolutionContext, +) -> bool { + is_external_import_with(source, language, context, false) + && !resolve_import_path(source, from_file, language, context) + .is_some_and(|resolved| context.is_indexed_file(&resolved)) +} + +/// Does a JS-family import of `source`, written in `from_file`, name a module +/// outside the repository (upstream #2120 `isOutOfRepoImport`, #2128)? A +/// package that is not a relative path, a path alias onto an indexed file or +/// a workspace member, and that the file's package.json chain or Deno import +/// map declares, or a Node built-in, a runtime scheme or a framework's +/// virtual module ([`crate::package_manifests`]): an alias the resolver does +/// not follow (`$lib/…`, `~/…`) and the repository's own package stay the +/// project's. Every other language answers `false`. +pub fn is_out_of_repo_import( + source: &str, + from_file: &str, + language: Language, + context: &dyn ResolutionContext, +) -> bool { + is_js_specifier_language(language) + && is_out_of_repo_js_import(source, from_file, language, context) + && crate::package_manifests::is_declared_outside_package(source, from_file, context) +} + /// Does a Rust `use` path name a crate outside the repository? /// /// True only for a [`RUST_OUT_OF_REPO_ROOTS`] root, and even then not when the @@ -1186,6 +1457,13 @@ fn rust_use_path_is_out_of_repo( true } +/// A JavaScript identifier without escapes: an `ID_Start` letter, `$` or +/// `_`, then `ID_Continue`, `$`, ZWNJ or ZWJ. Upstream's `[A-Za-z_$][\w$]*` +/// (#2378, #2384) is its ASCII subset; the `\w+` the local alias forms read +/// before took the Unicode letters but not `$`, so `export default items$;` +/// bound nothing. +const JS_IDENTIFIER: &str = r"[\p{ID_Start}_$][\p{ID_Continue}$\x{200C}\x{200D}]*"; + /// Extract JS/TS cross-file re-exports and explicit local export aliases /// (`extractReExports`, `import-resolver.ts:989-1042`). pub fn extract_re_exports(content: &str, language: Language) -> Vec { @@ -1203,29 +1481,42 @@ pub fn extract_re_exports(content: &str, language: Language) -> Vec { static LOCAL_CONST_RE: OnceLock = OnceLock::new(); static LOCAL_NAMED_RE: OnceLock = OnceLock::new(); static LOCAL_DEFAULT_RE: OnceLock = OnceLock::new(); + // `export * from '…'`; `export * as ns from '…'` exports `ns` alone + // (upstream #2192). let wildcard_re = WILDCARD_RE.get_or_init(|| { - Regex::new(r#"export\s*\*(?:\s+as\s+\w+)?\s*from\s*['"]([^'"]+)['"]"#) - .expect("reexport wildcard") + Regex::new( + r#"export\s*\*(?:\s+as\s+([A-Za-z_$][0-9A-Za-z_$]*))?\s*from\s*['"]([^'"]+)['"]"#, + ) + .expect("reexport wildcard") }); let named_re = NAMED_RE.get_or_init(|| { Regex::new(r#"export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#).expect("reexport named") }); let local_const_re = LOCAL_CONST_RE.get_or_init(|| { - Regex::new(r"(?m)\bexport[ \t]+const[ \t]+(\w+)[ \t]*=[ \t]*(\w+)[ \t]*(?:;[ \t]*)?$") - .expect("local const export alias") + Regex::new(&format!( + r"(?m)\bexport[ \t]+const[ \t]+({JS_IDENTIFIER})[ \t]*=[ \t]*({JS_IDENTIFIER})[ \t]*(?:;[ \t]*)?$" + )) + .expect("local const export alias") }); let local_named_re = LOCAL_NAMED_RE.get_or_init(|| { Regex::new(r"(?m)\bexport[ \t]*\{([^}]+)\}[ \t]*;?[ \t]*$") .expect("local named export alias") }); let local_default_re = LOCAL_DEFAULT_RE.get_or_init(|| { - Regex::new(r"(?m)\bexport[ \t]+default[ \t]+(\w+)[ \t]*;?[ \t]*$") - .expect("local default export alias") + Regex::new(&format!( + r"(?m)\bexport[ \t]+default[ \t]+({JS_IDENTIFIER})[ \t]*;?[ \t]*$" + )) + .expect("local default export alias") }); for caps in wildcard_re.captures_iter(&cleaned) { - out.push(ReExport::Wildcard { - source: caps[1].to_string(), + let source = caps[2].to_string(); + out.push(match caps.get(1) { + Some(namespace) => ReExport::Namespace { + exported_name: namespace.as_str().to_string(), + source, + }, + None => ReExport::Wildcard { source }, }); } @@ -1284,11 +1575,60 @@ pub fn extract_re_exports(content: &str, language: Language) -> Vec { }); } + // CommonJS (upstream #2189): `exports.setCharset = function setCharset(…)`, + // `module.exports.x = impl`, and `module.exports = { parse, format: + // formatDate }`. + static CJS_MEMBER_RE: OnceLock = OnceLock::new(); + static CJS_OBJECT_RE: OnceLock = OnceLock::new(); + static CJS_ENTRY_RE: OnceLock = OnceLock::new(); + let cjs_member_re = CJS_MEMBER_RE.get_or_init(|| { + Regex::new( + r"(?m)^[ \t]*(?:module\.)?exports\.([A-Za-z_$][0-9A-Za-z_$]*)\s*=\s*(?:(?:async\s+)?function\s*\*?\s*([A-Za-z_$][0-9A-Za-z_$]*)\s*\(|([A-Za-z_$][0-9A-Za-z_$]*)\s*;?[ \t]*$)", + ) + .expect("commonjs member export") + }); + let cjs_object_re = CJS_OBJECT_RE.get_or_init(|| { + Regex::new(r"(?m)^[ \t]*module\.exports\s*=\s*\{([^{}]*)\}") + .expect("commonjs object export") + }); + let cjs_entry_re = CJS_ENTRY_RE.get_or_init(|| { + Regex::new(r"^\s*([A-Za-z_$][0-9A-Za-z_$]*)\s*(?::\s*([A-Za-z_$][0-9A-Za-z_$]*)\s*)?$") + .expect("commonjs object entry") + }); + for caps in cjs_member_re.captures_iter(content) { + let local = caps.get(2).or_else(|| caps.get(3)); + if let Some(local) = local { + out.push(ReExport::CommonJsAlias { + exported_name: caps[1].to_string(), + original_name: local.as_str().to_string(), + }); + } + } + for caps in cjs_object_re.captures_iter(content) { + for raw in caps[1].split(',') { + if let Some(entry) = cjs_entry_re.captures(raw) { + let exported = entry[1].to_string(); + let local = entry + .get(2) + .map_or_else(|| exported.clone(), |m| m.as_str().to_string()); + out.push(ReExport::CommonJsAlias { + exported_name: exported, + original_name: local, + }); + } + } + } + out } +/// A JS identifier: `[A-Za-z_$][0-9A-Za-z_$]*` (upstream #2378). fn is_word(s: &str) -> bool { - !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_') + let mut chars = s.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') } /// Resolve a JVM (Java/Kotlin) FQN import via the qualifiedName index @@ -1371,6 +1711,136 @@ fn drop_last_segment(path: &str) -> Vec<&str> { } } +/// The qualified name (`App\Fields::FirstName`) of the class a PHP class +/// name written with a namespace in it (`Field\FirstName`, `\Ns\X`) names at +/// `reference`, read as PHP reads it: a leading `\` makes it absolute; +/// otherwise a first segment a `use` imports is replaced by what it imports, +/// and any other name is relative to the namespace the reference is in. +pub(crate) fn php_qualified_class_qn( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> String { + let fqn = if let Some(rooted) = name.strip_prefix('\\') { + rooted.to_string() + } else { + let head = name.split('\\').next().unwrap_or(name); + match context + .get_import_mappings(&reference.file_path, reference.language) + .into_iter() + .find(|import| import.local_name == head) + { + Some(import) => format!( + "{}{}", + import.source.trim_start_matches('\\'), + &name[head.len()..] + ), + // `namespace App;` applies until the next namespace statement. + None => match context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|node| { + node.kind == NodeKind::Namespace && node.start_line <= reference.line + }) + .max_by(|a, b| { + (a.start_line, a.start_column, &b.id).cmp(&( + b.start_line, + b.start_column, + &a.id, + )) + }) { + Some(namespace) => format!("{}\\{name}", namespace.qualified_name), + None => name.to_string(), + }, + } + }; + match fqn.rsplit_once('\\') { + Some((namespace, last)) => format!("{namespace}::{last}"), + None => fqn, + } +} + +/// A PHP class written with a namespace in it (upstream +/// `resolvePhpQualifiedClassRef`, #2256 / #2286). `use App\Fields as Field;` +/// aliases a namespace, so `new Field\FirstName()`, `extends Field\Base` and +/// `Field\FirstName::make()` all name `App\Fields\FirstName`. PHP reads a +/// qualified name one way: a leading `\` makes it absolute; otherwise a first +/// segment a `use` imports is replaced by what it imports, and any other name +/// is relative to the current namespace. `None` when the ref is not a +/// qualified class name (or names a method the class inherits); `Some(None)` +/// when it is but no single project class has that name, so it lives outside +/// the project and the name fallbacks must not guess. KEEP-RUST: a static +/// call is recorded as written (`Field\FirstName.make`), the other class +/// name refs by their last segment, whose written name is read back from the +/// source ([`crate::php_scope::php_written_class_name`]). +pub(crate) fn resolve_php_qualified_class_ref( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + if reference.language != Language::Php { + return None; + } + let (name, member) = if reference.reference_kind == codegraph_core::types::EdgeKind::Calls { + // A static call on the class: `Field\FirstName::make()` is written + // `Field\FirstName.make`. + static CALL: OnceLock = OnceLock::new(); + let call = CALL + .get_or_init(|| { + Regex::new(r"^(.*\\[^\\.:]+)(?:\.|::)([0-9A-Za-z_]+)$").expect("php qualified call") + }) + .captures(&reference.reference_name)?; + (call[1].to_string(), Some(call[2].to_string())) + } else { + ( + crate::php_scope::php_written_class_name(reference, context)?, + None, + ) + }; + if !name.contains('\\') { + return None; + } + let qualified_name = php_qualified_class_qn(&name, reference, context); + let classes: Vec> = context + .get_nodes_by_qualified_name_shared(&qualified_name) + .into_iter() + .filter(|node| node.language == Language::Php && is_static_member_container(node.kind)) + .collect(); + // A type mention can name something other than a class (a namespaced + // constant or function), so it keeps the ordinary strategies. + let [owner] = classes.as_slice() else { + return (reference.reference_kind != codegraph_core::types::EdgeKind::References) + .then_some(None); + }; + let Some(member) = member else { + return Some(Some(ResolvedRef { + original: reference.clone(), + target_node_id: owner.id.clone(), + confidence: 0.95, + resolved_by: ResolvedBy::Import, + })); + }; + let methods: Vec> = context + .get_nodes_by_qualified_name_shared(&format!("{}::{member}", owner.qualified_name)) + .into_iter() + .filter(|node| { + node.language == Language::Php + && node.kind == NodeKind::Method + && node.file_path == owner.file_path + }) + .collect(); + // A method the class inherits is left to the strategies that walk + // supertypes. + let [method] = methods.as_slice() else { + return None; + }; + Some(Some(ResolvedRef { + original: reference.clone(), + target_node_id: method.id.clone(), + confidence: 0.95, + resolved_by: ResolvedBy::Import, + })) +} + /// Resolve a PHP scoped call through the exact class namespace named by its /// local `use` binding. /// @@ -1472,6 +1942,53 @@ pub fn resolve_via_import( reference: &RefView, context: &dyn ResolutionContext, ) -> Option { + // #2263 — a JS/TS module path (`import './polyfills'`, the module of + // `import x from '../lib/a'`) names a FILE, found the way the runtime finds + // it: extensions, `index` files, path aliases. + if is_js_path_import_ref(reference) + && let Some(file) = resolve_import_path( + &reference.reference_name, + &reference.file_path, + reference.language, + context, + ) + && file != reference.file_path + && let Some(file_node) = context + .get_nodes_by_name_shared(pathutil::basename(&file)) + .into_iter() + .find(|n| n.kind == NodeKind::File && n.file_path == file) + { + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: file_node.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::Import, + }); + } + // A Dart `import` / `export` URI names a library file, and a `part` URI + // one of the library's own files: `package:app/x.dart` is app's + // lib/x.dart, any other path is from the file the directive is in + // (upstream #2388, #2391). + if is_dart_import_ref(reference) { + let file = crate::dart_libraries::dart_directive_file( + &reference.file_path, + &reference.reference_name, + context, + )?; + if file == reference.file_path { + return None; + } + let file_node = context + .get_nodes_in_file_shared(&file) + .into_iter() + .find(|n| n.kind == NodeKind::File)?; + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: file_node.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::Import, + }); + } // C/C++ #include → file→file edge (import-resolver.ts:1132-1164). if matches!(reference.language, Language::C | Language::Cpp) && reference.reference_kind == codegraph_core::types::EdgeKind::Imports @@ -1612,6 +2129,17 @@ pub fn resolve_via_import( } } + // Kotlin's `import app.model.Outer.Inner as Made`: `Made.create()` is the + // aliased class's member, found by its FQN; nothing else binds the alias + // (upstream #2196). + if reference.language == Language::Kotlin + && reference.reference_kind != codegraph_core::types::EdgeKind::Imports + { + if let Some(aliased) = resolve_jvm_alias(reference, &imports, context) { + return Some(aliased); + } + } + // Generic: reference name matches an import (import-resolver.ts:1272-1306). for imp in &imports { if imp.local_name == reference.reference_name @@ -1649,23 +2177,11 @@ pub fn resolve_via_import( None }; + // Python symbols are never marked exported; a named import + // names the module's own top-level definition (#1820), or + // one it re-exports (#2247). let python_symbol = |name: &str| { - // Python symbols are never marked exported; a named import - // names the module's own top-level definition (#1820). - context - .get_nodes_in_file_shared(&resolved_path) - .into_iter() - .find(|node| { - node.name == name - && !node.qualified_name.contains("::") - && matches!( - node.kind, - NodeKind::Class - | NodeKind::Function - | NodeKind::Variable - | NodeKind::Constant - ) - }) + python_module_symbol(&resolved_path, name, context) .map(|node| node.as_ref().clone()) }; let wanted_python_name = @@ -1865,6 +2381,49 @@ fn resolve_module_import_to_file( /// Resolve a Java/Kotlin reference whose receiver is an imported FQN's simple /// name (`resolveJavaImportedReference`, `import-resolver.ts:1671-1735`). +/// `Alias` or `Alias.member` through a renaming Kotlin import, by the imported +/// FQN (upstream #2196 `resolveJvmAlias`): the package is some prefix of the +/// FQN, the rest the type path (`app.model::Outer::Inner`). +fn resolve_jvm_alias( + reference: &RefView, + imports: &[ImportMapping], + context: &dyn ResolutionContext, +) -> Option { + let (root, member) = match reference.reference_name.split_once('.') { + Some((root, member)) => (root, Some(member)), + None => (reference.reference_name.as_str(), None), + }; + let imp = imports.iter().find(|mapping| { + mapping.local_name == root && mapping.local_name != mapping.exported_name + })?; + let parts: Vec<&str> = imp.source.split('.').collect(); + for cut in (1..parts.len()).rev() { + let qualified = format!("{}::{}", parts[..cut].join("."), parts[cut..].join("::")); + let Some(target) = context + .get_nodes_by_qualified_name_shared(&qualified) + .into_iter() + .next() + else { + continue; + }; + let target_node = match member { + None => target, + Some(member) if member.contains('.') => return None, + Some(member) => context + .get_nodes_by_qualified_name_shared(&format!("{}::{member}", target.qualified_name)) + .into_iter() + .next()?, + }; + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: target_node.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::Import, + }); + } + None +} + fn resolve_java_imported_reference( reference: &RefView, imports: &[ImportMapping], @@ -1937,6 +2496,162 @@ fn resolve_java_imported_reference( None } +/// What `module.exports = …` (also `exports = module.exports = …`) default +/// exports (upstream #2189 `commonJsDefaultExport`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CommonJsDefault { + /// The module it forwards: `module.exports = require('./lib/express')`. + Forward(String), + /// The name it binds: `createApplication`, `function name(`, `class Name`. + Binding(String), +} + +fn commonjs_default_export( + file_path: &str, + context: &dyn ResolutionContext, +) -> Option { + if !is_js_family_file(file_path) { + return None; + } + commonjs_default_of(&context.read_file_shared(file_path)?) +} + +/// [`commonjs_default_export`] read from a JS-family file's text. +pub(crate) fn commonjs_default_of(source: &str) -> Option { + static COMMONJS_DEFAULT_EXPORT: OnceLock = OnceLock::new(); + if !source.contains("module.exports") { + return None; + } + let pattern = COMMONJS_DEFAULT_EXPORT.get_or_init(|| { + Regex::new( + r#"(?m)^[ \t]*(?:exports\s*=\s*)?module\.exports\s*=\s*(?:exports\s*=\s*)?(?:require\(\s*['"]([^'"]+)['"]\s*\)\s*;?[ \t]*$|(?:async\s+)?function\s*\*?\s*([A-Za-z_$][0-9A-Za-z_$]*)\s*\(|class\s+([A-Za-z_$][0-9A-Za-z_$]*)|([A-Za-z_$][0-9A-Za-z_$]*)\s*;?[ \t]*$)"#, + ) + .expect("commonjs default export") + }); + let captures = pattern.captures(source)?; + if let Some(forward) = captures.get(1) { + return Some(CommonJsDefault::Forward(forward.as_str().to_string())); + } + captures + .get(2) + .or_else(|| captures.get(3)) + .or_else(|| captures.get(4)) + .map(|name| CommonJsDefault::Binding(name.as_str().to_string())) +} + +/// `[cm]?[jt]sx?`: a JS-family file (upstream `JS_FAMILY_FILE`). +pub(crate) fn is_js_family_file(file_path: &str) -> bool { + let Some((_, extension)) = file_path.rsplit_once('.') else { + return false; + }; + let extension = extension.strip_prefix(['c', 'm']).unwrap_or(extension); + matches!(extension, "js" | "jsx" | "ts" | "tsx") +} + +/// Languages with ES import semantics (upstream `ESM_IMPORT_LANGUAGES`). +fn is_esm_import_language(language: Language) -> bool { + matches!( + language, + Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx + | Language::ArkTs + | Language::Svelte + | Language::Vue + | Language::Astro + ) +} + +/// The node a CommonJS export alias (`exports.x = impl`) names for +/// `exported_name`: the file's first node of that local name (upstream +/// `exportedByName`'s clause aliases). +fn commonjs_alias_target( + exported_name: &str, + re_exports: &[ReExport], + nodes_in_file: &[Arc], +) -> Option { + let local = re_exports.iter().find_map(|rex| match rex { + ReExport::CommonJsAlias { + exported_name: exported, + original_name, + } if exported == exported_name => Some(original_name.as_str()), + _ => None, + })?; + nodes_in_file + .iter() + .find(|n| n.name == local && n.kind != NodeKind::File) + .map(|n| n.as_ref().clone()) +} + +/// Whether a JS-family file's text holds an `export default` statement +/// (upstream #2433 `ESM_DEFAULT_EXPORT_RE`). +pub(crate) fn has_esm_default_statement(source: &str) -> bool { + static ESM_DEFAULT_EXPORT: OnceLock = OnceLock::new(); + source.contains("default") + && ESM_DEFAULT_EXPORT + .get_or_init(|| { + Regex::new(r"(?m)^[ \t]*export\s+default(?-u:\b)").expect("export default") + }) + .is_match(source) +} + +/// The declaration an ES module default-exports, when it has an ESM default +/// export at all (upstream #2433 `hasEsmDefaultExport` / `esmDefaultExport`): +/// the component a single-file component file is, else what an `export +/// default NAME` statement names, else the first exported function or class +/// of a file with an `export default` statement. A CommonJS module has none, +/// though its `exports.x = function` declarations are exported. +fn esm_default_export( + file_path: &str, + nodes_in_file: &[Arc], + re_exports: &[ReExport], + context: &dyn ResolutionContext, +) -> Option { + if let Some(component) = nodes_in_file + .iter() + .find(|n| n.is_exported && n.kind == NodeKind::Component) + { + return Some(component.as_ref().clone()); + } + if !is_js_family_file(file_path) { + return None; + } + if !has_esm_default_statement(&context.read_file_shared(file_path)?) { + return None; + } + let bound = re_exports.iter().find_map(|rex| match rex { + ReExport::LocalAlias { + exported_name, + original_name, + } if exported_name == "default" => Some(original_name.as_str()), + _ => None, + }); + bound + .and_then(|name| { + nodes_in_file + .iter() + .filter(|n| { + n.name == name + && matches!( + n.kind, + NodeKind::Function + | NodeKind::Class + | NodeKind::Component + | NodeKind::Constant + | NodeKind::Variable + ) + }) + .min_by_key(|n| (n.start_line, n.start_column)) + }) + .or_else(|| { + nodes_in_file + .iter() + .find(|n| n.is_exported && matches!(n.kind, NodeKind::Function | NodeKind::Class)) + }) + .map(|n| n.as_ref().clone()) +} + /// Recursive depth cap for re-export chains (`REEXPORT_MAX_DEPTH`, /// `import-resolver.ts:1798`). const REEXPORT_MAX_DEPTH: u32 = 8; @@ -2046,6 +2761,42 @@ fn find_exported_symbol( { return Some(direct.as_ref().clone()); } + // CommonJS: `module.exports = createApplication`, or `= + // require('./lib/express')` (upstream #2189). + match commonjs_default_export(file_path, context) { + Some(CommonJsDefault::Forward(source)) => { + if let Some(next) = resolve_import_path(&source, file_path, language, context) { + return find_exported_symbol( + &next, + want, + language, + context, + visited, + depth + 1, + ); + } + } + Some(CommonJsDefault::Binding(name)) => { + if let Some(bound) = nodes_in_file + .iter() + .filter(|n| { + n.name == name + && matches!( + n.kind, + NodeKind::Function + | NodeKind::Class + | NodeKind::Component + | NodeKind::Constant + | NodeKind::Variable + ) + }) + .min_by_key(|n| (n.start_line, n.start_column)) + { + return Some(bound.as_ref().clone()); + } + } + None => {} + } } else if want.is_namespace { if let Some(member) = &want.member_name { if let Some(direct) = nodes_in_file @@ -2054,12 +2805,32 @@ fn find_exported_symbol( { return Some(direct.as_ref().clone()); } + if let Some(aliased) = commonjs_alias_target(member, &re_exports, &nodes_in_file) { + return Some(aliased); + } } } else if let Some(direct) = nodes_in_file .iter() .find(|n| n.name == want.exported_name && n.is_exported) { return Some(direct.as_ref().clone()); + } else if let Some(aliased) = + commonjs_alias_target(&want.exported_name, &re_exports, &nodes_in_file) + { + return Some(aliased); + } else if depth == 0 + && want.exported_name == "default" + && is_esm_import_language(language) + && let Some(esm_default) = + esm_default_export(file_path, &nodes_in_file, &re_exports, context) + { + // `require('./x').default` and `const { default: X } = require('./x')` + // read the `default` property of module.exports. A CommonJS module + // sets it by name (`exports.default = fn`, found just above); a module + // written as an ES module sets it to its default export, which is no + // named export (upstream #2433). Only in the module asked for: + // `export * from` never forwards a default. + return Some(esm_default); } // Cross-file re-export hits (import-resolver.ts:1855-1893). @@ -2067,8 +2838,14 @@ fn find_exported_symbol( return None; } + // For `ns.member` through `import * as ns`, the member is the name wanted + // (upstream #2192). let target_name = if want.is_default { "default" + } else if want.is_namespace + && let Some(member) = want.member_name.as_deref() + { + member } else { &want.exported_name }; @@ -2100,10 +2877,42 @@ fn find_exported_symbol( } } } - ReExport::Wildcard { .. } | ReExport::LocalAlias { .. } => {} + ReExport::Wildcard { .. } + | ReExport::Namespace { .. } + | ReExport::LocalAlias { .. } + | ReExport::CommonJsAlias { .. } => {} } } + // `z.core.util.fn` through `export * as core from './core'`: the member + // continues in that module (upstream #2192). + if want.is_namespace + && let Some(member) = want.member_name.as_deref() + && let Some((head, rest)) = member.split_once('.') + && let Some(source) = re_exports.iter().find_map(|rex| match rex { + ReExport::Namespace { + exported_name, + source, + } if exported_name == head => Some(source), + _ => None, + }) + { + let next = resolve_import_path(source, file_path, language, context)?; + return find_exported_symbol( + &next, + &Want { + is_default: want.is_default, + is_namespace: want.is_namespace, + exported_name: rest.split('.').next().unwrap_or(rest).to_string(), + member_name: Some(rest.to_string()), + }, + language, + context, + visited, + depth + 1, + ); + } + for rex in &re_exports { match rex { ReExport::Wildcard { source } => { @@ -2115,7 +2924,10 @@ fn find_exported_symbol( } } } - ReExport::Named { .. } | ReExport::LocalAlias { .. } => {} + ReExport::Named { .. } + | ReExport::Namespace { .. } + | ReExport::LocalAlias { .. } + | ReExport::CommonJsAlias { .. } => {} } } @@ -2164,10 +2976,16 @@ fn resolve_imported_instance_member( let decl_slice = lines[start..end].join("\n"); let escaped = regex_escape(&value.name); - for pattern in local_receiver_type_patterns(value.language, &escaped) { - let Some(captured) = pattern.captures(&decl_slice).and_then(|c| c.get(1)) else { + for (pattern, check) in local_receiver_type_patterns(value.language, &escaped) { + let Some(captures) = pattern.captures(&decl_slice) else { + continue; + }; + let (Some(whole), Some(captured)) = (captures.get(0), captures.get(1)) else { continue; }; + if !check.passes(&decl_slice, whole, captured) { + continue; + } let Some(type_name) = normalize_inferred_type_name(captured.as_str()) else { continue; }; @@ -2241,84 +3059,69 @@ fn resolve_static_member( Some(candidates.swap_remove(0).as_ref().clone()) } -/// The module whose `module_path` covers `import_path`, longest match first. -fn owning_go_module<'m>(import_path: &str, modules: &'m [GoModule]) -> Option<&'m GoModule> { - modules - .iter() - .filter(|m| { - import_path == m.module_path || import_path.starts_with(&format!("{}/", m.module_path)) - }) - .max_by_key(|m| m.module_path.len()) -} - -/// The project-relative directory an in-project Go import resolves to: the -/// owning module's own directory, plus the import's path below `module_path`. -fn go_package_dir(import_path: &str, module: &GoModule) -> String { - let below = import_path - .strip_prefix(&module.module_path) - .unwrap_or_default() - .trim_start_matches('/'); - match (module.dir.as_str(), below) { - ("", rest) => rest.to_string(), - (dir, "") => dir.to_string(), - (dir, rest) => format!("{dir}/{rest}"), - } -} - /// Go cross-package call `pkga.FuncX` → the exported member in the package /// directory the import maps to. Ports `resolveGoCrossPackageReference` -/// (import-resolver.ts, issue #388). +/// (import-resolver.ts, issue #388), with #2322's package directories: a +/// bare name the source writes through a package (`store.Manager` in a field +/// or a signature, which the index keeps as `Manager`) is that package's +/// too, and a link inside a longer chain (`s.db.Get`, kept as `db.Get`) is +/// no package's. fn resolve_go_cross_package_reference( reference: &RefView, imports: &[ImportMapping], context: &dyn ResolutionContext, ) -> Option { - let modules = context.get_go_modules(); - if modules.is_empty() { - return None; - } - let dot_idx = reference.reference_name.find('.')?; - if dot_idx == 0 { + let Some(dot_idx) = reference.reference_name.find('.') else { + let import = crate::go_packages::go_ref_qualifier(reference, context)?; + return find_go_package_member(&reference.reference_name, &import, reference, context); + }; + if dot_idx == 0 || crate::go_packages::is_go_chain_link(reference, context) { return None; } + // A parameter or local named like the import holds the call there: + // `store := newStore()`, then `store.Get(k)` is no call into package + // store (upstream #2448). + crate::go_packages::go_ref_qualifier(reference, context)?; let receiver = &reference.reference_name[..dot_idx]; let member = &reference.reference_name[dot_idx + 1..]; if member.is_empty() { return None; } + imports + .iter() + .filter(|import| import.local_name == receiver) + .find_map(|import| find_go_package_member(member, import, reference, context)) +} - for imp in imports { - if imp.local_name != receiver { - continue; - } - // Only in-project imports map to a known directory. With several modules - // the LONGEST matching module path wins, so a module nested inside - // another's path resolves to its own directory rather than its parent's. - let Some(module) = owning_go_module(&imp.source, &modules) else { - continue; - }; - let pkg_dir = go_package_dir(&imp.source, module); - - for node in context.get_nodes_by_name_shared(member) { - if node.language != Language::Go || !node.is_exported { - continue; - } - let fp = node.file_path.replace('\\', "/"); - let file_dir = match fp.rfind('/') { - Some(i) => &fp[..i], - None => "", - }; - if file_dir == pkg_dir { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: node.id.clone(), - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }); - } - } - } - None +/// The exported `member` of the project package `import` names, in the +/// package's own directory (`pkga.FuncX` never lands on a `FuncX` of +/// `pkga/subpkg/`; upstream `findGoPackageMember`). `None` when the import +/// is no project package. +fn find_go_package_member( + member: &str, + import: &ImportMapping, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let pkg_dir = crate::go_packages::go_import_package_dir( + &import.source, + Some(&reference.file_path), + context, + )?; + context + .get_nodes_by_name_shared(member) + .into_iter() + .find(|node| { + node.language == Language::Go + && node.is_exported + && crate::go_packages::package_dir_of(&node.file_path) == pkg_dir + }) + .map(|node| ResolvedRef { + original: reference.clone(), + target_node_id: node.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::Import, + }) } #[derive(Default)] @@ -2417,28 +3220,20 @@ fn lookup_python_module_member( }; } - let mut targets = context - .get_nodes_in_file_shared(resolved_path) - .into_iter() - .filter(|n| { - n.name == member - && matches!( - n.kind, - NodeKind::Function | NodeKind::Class | NodeKind::Variable | NodeKind::Constant - ) - }); - let Some(target) = targets.next() else { - return PythonModuleMemberLookup { - claimed: true, - resolved: None, - }; + // The module's own top-level definition, which must be unique, or one + // it re-exports (a package's `__init__.py`, upstream #2248). + let own = python_top_level_symbols(resolved_path, member, context); + let target = match own.as_slice() { + [target] => Some(Arc::clone(target)), + [] => python_module_symbol(resolved_path, member, context), + _ => None, }; - if targets.next().is_some() { + let Some(target) = target else { return PythonModuleMemberLookup { claimed: true, resolved: None, }; - } + }; PythonModuleMemberLookup { claimed: true, @@ -2558,27 +3353,242 @@ fn resolve_python_from_import_member( fn resolve_python_absolute_module( reference: &RefView, context: &dyn ResolutionContext, -) -> Option { - if reference.reference_kind != codegraph_core::types::EdgeKind::Imports { - return None; +) -> Option { + if reference.reference_kind != codegraph_core::types::EdgeKind::Imports { + return None; + } + if !reference.reference_name.contains('.') { + return None; + } + let hit_id = find_python_module_file(&reference.reference_name, context, &reference.file_path) + .and_then(|path| { + context + .get_nodes_in_file_shared(&path) + .into_iter() + .find(|n| n.kind == NodeKind::File) + .map(|n| n.id.clone()) + })?; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: hit_id, + confidence: 0.9, + resolved_by: ResolvedBy::Import, + }) +} + +/// How many modules deep a Python re-export walk goes (upstream #2247). +const PYTHON_REEXPORT_DEPTH: usize = 3; + +/// The top-level classes, functions and values named `name` in the Python +/// module `file`, in source order. +fn python_top_level_symbols( + file: &str, + name: &str, + context: &dyn ResolutionContext, +) -> Vec> { + let mut symbols: Vec> = context + .get_nodes_in_file_shared(file) + .into_iter() + .filter(|node| { + node.name == name + && !node.qualified_name.contains("::") + && matches!( + node.kind, + NodeKind::Class | NodeKind::Function | NodeKind::Variable | NodeKind::Constant + ) + }) + .collect(); + symbols.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + symbols +} + +/// The definition the Python module `file` offers as `name` (upstream #2247 +/// `pythonModuleSymbol`): its own top-level class, function or value, else +/// the one it re-exports by name (`from .tokens import Token`) or through a +/// star import (`from .users import *`), three modules deep. +/// +/// Where upstream takes the first hit, this fails closed: two imports +/// rebinding the name (`try: from .fast import Parser` / `except +/// ImportError: from .slow import Parser`), or star-imported modules +/// offering different definitions, leave it unresolved. Each `(module, name, +/// depth)` answer is memoised for the pass; the walk is a function of the +/// files alone, so the order in which references ask cannot change it. +pub(crate) fn python_module_symbol( + file: &str, + name: &str, + context: &dyn ResolutionContext, +) -> Option> { + python_module_symbol_at(file, name, context, 0) +} + +fn python_module_symbol_at( + file: &str, + name: &str, + context: &dyn ResolutionContext, + depth: usize, +) -> Option> { + let compute = || python_module_symbol_uncached(file, name, context, depth); + let id = match context.source_facts(file) { + Some(facts) => facts.python_module_symbol(name, depth, compute), + None => compute(), + }?; + context.get_node_by_id_shared(&id) +} + +fn python_module_symbol_uncached( + file: &str, + name: &str, + context: &dyn ResolutionContext, + depth: usize, +) -> Option { + if let Some(own) = python_top_level_symbols(file, name, context).first() { + return Some(own.id.clone()); + } + if depth >= PYTHON_REEXPORT_DEPTH { + return None; + } + let follow = |source: &str, exported: &str| { + let target = python_import_target(source, file, context)?; + python_module_symbol_at(&target, exported, context, depth + 1) + }; + let named: Vec = context + .get_import_mappings(file, Language::Python) + .into_iter() + .filter(|imp| !imp.is_namespace && imp.local_name == name) + .collect(); + match named.as_slice() { + [] => {} + [imp] => { + if let Some(node) = follow(&imp.source, &imp.exported_name) { + return Some(node.id.clone()); + } + } + _ => return None, + } + // A star import is not among the import mappings: read it here. + let mut found: Option> = None; + for source in python_star_imports(file, context) { + let Some(node) = follow(&source, name) else { + continue; + }; + if found.as_ref().is_some_and(|seen| seen.id != node.id) { + return None; + } + found = Some(node); + } + found.map(|node| node.id.clone()) +} + +/// The file a Python import of `source` in `file` names, other than `file`. +fn python_import_target( + source: &str, + file: &str, + context: &dyn ResolutionContext, +) -> Option { + resolve_import_path(source, file, Language::Python, context) + .or_else(|| find_python_module_file(source, context, file)) + .filter(|target| target != file) +} + +/// The modules Python `code` star-imports (`from .users import *`), in +/// source order. +fn python_star_modules(code: &str) -> Vec { + static STAR: OnceLock = OnceLock::new(); + let star = STAR.get_or_init(|| { + Regex::new(r"(?m)^\s*from\s+([\w.]+)\s+import\s+\*").expect("python star import") + }); + star.captures_iter(code) + .filter_map(|captures| captures.get(1)) + .map(|module| module.as_str().to_string()) + .collect() +} + +/// The modules `file` star-imports, read from its code (comments and +/// docstrings blanked). +fn python_star_imports(file: &str, context: &dyn ResolutionContext) -> Vec { + context + .source_facts(file) + .map(|facts| python_star_modules(facts.python_comment_free().0)) + .unwrap_or_default() +} + +/// The names a Python import statement of `file` can re-export to the files +/// that import `file`, as [`python_module_symbol`] reads them: what a `from +/// … import a, b as c` binds, and for `from m import *` every name module +/// `m` offers, its own top-level definitions and what it re-exports in turn, +/// as deep as the walk looks. Sync touches these when the statement comes or +/// goes, since an importer's name then moves without any node of that name +/// changing. +pub fn python_reexported_names( + file: &str, + statement: &str, + context: &dyn ResolutionContext, +) -> BTreeSet { + let mut names: BTreeSet = extract_import_mappings(statement, Language::Python) + .into_iter() + .filter(|mapping| !mapping.is_namespace) + .map(|mapping| mapping.local_name) + .collect(); + let code = crate::strip_comments::strip_comments_for_regex( + statement, + crate::strip_comments::CommentLang::Python, + ); + let mut seen = std::collections::HashMap::new(); + for module in python_star_modules(&code) { + if let Some(target) = python_import_target(&module, file, context) { + python_offered_names(&target, 1, context, &mut names, &mut seen); + } + } + names +} + +/// Every name the walk can find in the Python module `file` when it arrives +/// there `depth` modules deep. +fn python_offered_names( + file: &str, + depth: usize, + context: &dyn ResolutionContext, + names: &mut BTreeSet, + seen: &mut std::collections::HashMap, +) { + // A module already read at this depth or shallower offered all of this. + if seen.get(file).is_some_and(|&at| at <= depth) { + return; } - if !reference.reference_name.contains('.') { - return None; + seen.insert(file.to_string(), depth); + names.extend( + context + .get_nodes_in_file_shared(file) + .into_iter() + .filter(|node| { + !node.qualified_name.contains("::") + && matches!( + node.kind, + NodeKind::Class + | NodeKind::Function + | NodeKind::Variable + | NodeKind::Constant + ) + }) + .map(|node| node.name.clone()), + ); + if depth >= PYTHON_REEXPORT_DEPTH { + return; + } + names.extend( + context + .get_import_mappings(file, Language::Python) + .into_iter() + .filter(|mapping| !mapping.is_namespace) + .map(|mapping| mapping.local_name), + ); + for module in python_star_imports(file, context) { + if let Some(target) = python_import_target(&module, file, context) { + python_offered_names(&target, depth + 1, context, names, seen); + } } - let hit_id = find_python_module_file(&reference.reference_name, context, &reference.file_path) - .and_then(|path| { - context - .get_nodes_in_file_shared(&path) - .into_iter() - .find(|n| n.kind == NodeKind::File) - .map(|n| n.id.clone()) - })?; - Some(ResolvedRef { - original: reference.clone(), - target_node_id: hit_id, - confidence: 0.9, - resolved_by: ResolvedBy::Import, - }) } /// Find a Python module file by its absolute dotted path (`a.b.c` → @@ -2771,7 +3781,7 @@ fn resolve_rust_module_file( /// Lua / Luau `require("a.b.c")` dotted module path → module file. Ports /// `resolveLuaRequire`. -fn resolve_lua_require( +pub(crate) fn resolve_lua_require( reference: &RefView, context: &dyn ResolutionContext, ) -> Option { @@ -2790,16 +3800,14 @@ fn resolve_lua_require( format!("{base}/init.lua"), format!("{base}/init.luau"), ]; - let files = context.get_all_files_shared(); let shared = |a: &str, b: &str| -> usize { a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count() }; for suffix in &suffixes { - let mut matches: Vec = files - .iter() - .filter(|f| *f == suffix || f.ends_with(&format!("/{suffix}"))) - .cloned() - .collect(); + // The files the suffix names, read once per pass (W5-09). + let mut matches: Vec = crate::lua_scope::lua_require_matches(suffix, context) + .as_ref() + .clone(); if matches.is_empty() { continue; } @@ -2828,7 +3836,7 @@ fn resolve_lua_require( mod tests { use super::*; use crate::path_aliases::{AliasMap, AliasPattern}; - use crate::types::{ImportMapping, ReExport, RefView, ResolutionContext}; + use crate::types::{GoModule, ImportMapping, ReExport, RefView, ResolutionContext}; use crate::workspace_packages::WorkspacePackages; use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; use std::collections::{BTreeMap, BTreeSet}; @@ -2848,6 +3856,8 @@ mod tests { workspace_packages: Option, go_module: Option, cpp_include_dirs: Vec, + /// Files on disk that the index does not hold (a `node_modules` file). + disk_only_files: BTreeSet, } impl ResolutionContext for TestContext { @@ -2880,7 +3890,7 @@ mod tests { .collect() } fn file_exists(&self, file_path: &str) -> bool { - self.existing_files.contains(file_path) + self.existing_files.contains(file_path) || self.disk_only_files.contains(file_path) } fn read_file(&self, file_path: &str) -> Option { self.file_contents.get(file_path).cloned() @@ -3197,6 +4207,95 @@ mod tests { } } + fn alias(prefix: &str, has_wildcard: bool, replacement: &str) -> AliasPattern { + AliasPattern { + prefix: prefix.to_string(), + suffix: String::new(), + has_wildcard, + replacements: vec![replacement.to_string()], + } + } + + /// Upstream #2437: a catch-all `"*"` alias has an empty prefix, which every + /// package specifier starts with, so matching it proves nothing. A binding + /// is local only when an alias maps it to an indexed file. + #[test] + fn catch_all_alias_never_makes_a_package_binding_local() { + let mut ctx = TestContext { + project_root: "/proj".to_string(), + project_aliases: Some(AliasMap { + base_url: "/proj".to_string(), + // In the loader's specificity order: longest prefix first. + patterns: vec![ + alias("@lib/", true, "lib/*"), + alias("", true, "./typings/*"), + ], + }), + ..Default::default() + }; + ctx.existing_files.insert("lib/thing.ts".to_string()); + ctx.import_mappings.insert( + "src/App.tsx".to_string(), + vec![ + mapping("Typography", "Typography", "@mui/material"), + mapping("Thing", "Thing", "@lib/thing"), + ], + ); + let package = reference( + "Typography", + EdgeKind::Imports, + "src/App.tsx", + Language::Tsx, + ); + assert!( + is_bound_to_out_of_repo_module(&package, &ctx), + "a catch-all alias must not make `@mui/material` local" + ); + let aliased = reference("Thing", EdgeKind::Imports, "src/App.tsx", Language::Tsx); + assert!( + !is_bound_to_out_of_repo_module(&aliased, &ctx), + "an alias that maps the import to an indexed file keeps it local" + ); + // Resolving an import still honors the catch-all's empty prefix. + assert!(!is_external_import("@mui/material", Language::Tsx, &ctx)); + } + + /// Upstream #2437: an alias that lands on a file outside the index, such as + /// one under `node_modules`, does not make the binding the project's. + #[test] + fn alias_onto_a_file_outside_the_index_is_out_of_repo() { + let mut ctx = TestContext { + project_root: "/proj".to_string(), + project_aliases: Some(AliasMap { + base_url: "/proj".to_string(), + patterns: vec![alias("lit/format", false, "./node_modules/lit/format.js")], + }), + ..Default::default() + }; + ctx.disk_only_files + .insert("node_modules/lit/format.js".to_string()); + ctx.import_mappings.insert( + "src/card.ts".to_string(), + vec![mapping("format", "format", "lit/format")], + ); + let r = reference( + "format", + EdgeKind::Imports, + "src/card.ts", + Language::TypeScript, + ); + assert!( + is_bound_to_out_of_repo_module(&r, &ctx), + "an alias into node_modules is out of the repository" + ); + + // The same alias onto a file the index holds is the project's. + ctx.disk_only_files.clear(); + ctx.existing_files + .insert("node_modules/lit/format.js".to_string()); + assert!(!is_bound_to_out_of_repo_module(&r, &ctx)); + } + #[test] fn extension_resolution_covers_all_language_arms() { assert!(extension_resolution(Language::TypeScript).contains(&".ts")); @@ -3241,10 +4340,14 @@ mod tests { #[test] fn parse_colon_alias_extracts_orig_and_alias() { assert_eq!( - parse_colon_alias("foo: bar"), + parse_js_colon_alias("foo: bar"), Some(("foo".to_string(), "bar".to_string())) ); - assert_eq!(parse_colon_alias("foo"), None); + assert_eq!(parse_js_colon_alias("foo"), None); + assert_eq!( + parse_js_colon_alias("$el: $root"), + Some(("$el".to_string(), "$root".to_string())) + ); } #[test] @@ -3278,6 +4381,224 @@ import * as ns from './baz'; ); } + /// `local Vec { + extract_import_mappings(source, Language::TypeScript) + .into_iter() + .map(|m| { + format!( + "{}<{}{}", + m.local_name, + m.exported_name, + if m.is_namespace { " ns" } else { "" } + ) + }) + .collect() + } + + /// Upstream #2134: a type-only import binds its names, never `type`. + #[test] + fn a_type_only_import_binds_its_names_never_type() { + assert_eq!( + js_bindings("import type { enumUtil } from './helpers/enumUtil.js';\n"), + ["enumUtil Vec { + extract_js_imports(content) + .into_iter() + .map(|m| { + format!( + "{}<{}{}{}", + m.local_name, + m.exported_name, + if m.is_default { " default" } else { "" }, + if m.is_namespace { " ns" } else { "" } + ) + }) + .collect() + } + + #[test] + fn import_default_as_is_the_default_import() { + assert_eq!( + mapping_strings( + "import {\n default as AppRoot,\n ErrorBoundary as AppRootErrorBoundary,\n} from './routes/app/root';\n" + ), + [ + "AppRoot { + Some(format!("{exported_name}<{original_name}")) + } + _ => None, + }) + .collect::>(), + ["a$ = extract_re_exports(content, Language::JavaScript) + .into_iter() + .filter_map(|rex| match rex { + ReExport::CommonJsAlias { + exported_name, + original_name, + } => Some((exported_name, original_name)), + _ => None, + }) + .collect(); + assert_eq!( + aliases, + [ + ("setCharset".to_string(), "setCharset".to_string()), + ("alias".to_string(), "impl".to_string()), + ("default".to_string(), "formatDate".to_string()), + ("parse".to_string(), "parse".to_string()), + ("format".to_string(), "formatDate".to_string()), + ] + ); + } + + #[test] + fn js_family_files_carry_js_or_ts_extensions() { + for file in [ + "a.js", "a.cjs", "a.mjs", "a.jsx", "a.ts", "a.mts", "a.cts", "a.tsx", + ] { + assert!(is_js_family_file(file), "{file}"); + } + for file in ["a.vue", "a.svelte", "a.json", "a", "a.py"] { + assert!(!is_js_family_file(file), "{file}"); + } + } + #[test] fn extract_js_imports_require_forms() { let content = r#" @@ -3322,6 +4643,119 @@ const { x, y: z } = require('./other'); ); } + /// Upstream #2291's case: a parenthesized from-import binds every name + /// it lists, a comment in it holds none, and a docstring imports nothing. + #[test] + fn extract_python_imports_reads_parenthesized_lists_over_code_only() { + let content = "\nfrom store.base import (\n Base,\n helper as h, # a comment, with a comma and a call()\n)\n\"\"\"\nfrom docstring import NotAnImport\n\"\"\"\nfrom store.rows import (Row, Col)\n"; + let mappings = extract_import_mappings(content, Language::Python); + let found: Vec<(&str, &str, &str)> = mappings + .iter() + .map(|m| { + ( + m.local_name.as_str(), + m.exported_name.as_str(), + m.source.as_str(), + ) + }) + .collect(); + assert_eq!( + found, + [ + ("Base", "Base", "store.base"), + ("h", "helper", "store.base"), + ("Row", "Row", "store.rows"), + ("Col", "Col", "store.rows"), + ] + ); + } + + /// `name <- path` of every Go import mapping of `source`, sorted. + fn go_import_names(source: &str) -> Vec { + let mut names: Vec = extract_import_mappings(source, Language::Go) + .iter() + .map(|m| format!("{} <- {}", m.local_name, m.source)) + .collect(); + names.sort(); + names + } + + /// Upstream #2410: an unaliased versioned or `go-` import is bound to its + /// package's name as well as its last element. + #[test] + fn a_go_import_takes_the_name_goimports_assumes() { + assert_eq!( + go_import_names( + "package p\nimport (\n\t\"go.yaml.in/yaml/v3\"\n\t\"github.com/mattn/go-sqlite3\"\n\t\"k8s.io/api/core/v1\"\n\t\"github.com/gin-gonic/gin\"\n\t\"gopkg.in/natefinch/lumberjack.v2\"\n)\n" + ), + [ + "core <- k8s.io/api/core/v1", + "gin <- github.com/gin-gonic/gin", + "go-sqlite3 <- github.com/mattn/go-sqlite3", + "lumberjack <- gopkg.in/natefinch/lumberjack.v2", + "lumberjack.v2 <- gopkg.in/natefinch/lumberjack.v2", + "sqlite3 <- github.com/mattn/go-sqlite3", + "v1 <- k8s.io/api/core/v1", + "v3 <- go.yaml.in/yaml/v3", + "yaml <- go.yaml.in/yaml/v3", + ] + ); + assert_eq!( + go_import_names("package p\n\nimport \"gopkg.in/yaml.v3\"\n"), + ["yaml <- gopkg.in/yaml.v3", "yaml.v3 <- gopkg.in/yaml.v3"] + ); + } + + /// Upstream #2410: no assumed name another import holds, or two assume. + #[test] + fn a_go_import_takes_no_assumed_name_another_holds_or_two_assume() { + assert_eq!( + go_import_names( + "package p\nimport (\n\t\"k8s.io/api/core/v1\"\n\t\"k8s.io/kubernetes/pkg/apis/core\"\n\tyaml \"sigs.k8s.io/yaml\"\n\t\"go.yaml.in/yaml/v3\"\n\t\"github.com/a/semver/v3\"\n\t\"github.com/b/go-semver\"\n)\n" + ), + [ + "core <- k8s.io/kubernetes/pkg/apis/core", + "go-semver <- github.com/b/go-semver", + "v1 <- k8s.io/api/core/v1", + "v3 <- github.com/a/semver/v3", + "v3 <- go.yaml.in/yaml/v3", + "yaml <- sigs.k8s.io/yaml", + ] + ); + } + + /// Upstream #2410: a written name is kept, a dot import assumes none, and + /// a comment names nothing. + #[test] + fn a_go_import_keeps_a_written_name_and_reads_none_from_a_comment() { + assert_eq!( + go_import_names( + "package p\nimport (\n\t\"fmt\" // for printing\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t_ \"embed\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t// yaml decoding\n\t\"gopkg.in/yaml.v3\"\n\t/* the store */ \"example.com/app/store\"\n)\nfunc run() {}\n" + ), + [ + "_ <- embed", + "fmt <- fmt", + "metav1 <- k8s.io/apimachinery/pkg/apis/meta/v1", + "store <- example.com/app/store", + "v2 <- github.com/onsi/ginkgo/v2", + "yaml <- gopkg.in/yaml.v3", + "yaml.v3 <- gopkg.in/yaml.v3", + ] + ); + } + + /// Upstream #2410: only the import declarations, wherever a comment or a + /// later string spells one. + #[test] + fn a_go_file_reads_only_its_import_declarations() { + assert_eq!( + go_import_names( + "/*\nPackage p is a code generator.\nfunc Example() {}\n*/\npackage p\n// import \"example.com/commented\"\nimport (\n\t\"text/template\" // (for the body below)\n\t\"strings\"\n)\nfunc run() string {\n\treturn strings.TrimSpace(`\nimport \"example.com/generated\"\n`)\n}\n" + ), + ["strings <- strings", "template <- text/template"] + ); + } + #[test] fn extract_go_imports_single_and_block_with_alias() { let single = r#"import "fmt""#; @@ -3356,6 +4790,21 @@ const { x, y: z } = require('./other'); ); } + /// Upstream #2196: Kotlin imports carry no `;`, and `as` renames one. + #[test] + fn extract_kotlin_imports_without_semicolons_and_with_aliases() { + let content = "package app\n\nimport java.lang.reflect.Field\nimport app.model.Outer.Inner as Made // the builder\nimport okio.*\n/* import gone.Thing */\n"; + let m = extract_import_mappings(content, Language::Kotlin); + assert!(m.iter().any(|x| x.local_name == "Field" + && x.exported_name == "Field" + && x.source == "java.lang.reflect.Field")); + assert!(m.iter().any(|x| x.local_name == "Made" + && x.exported_name == "Inner" + && x.source == "app.model.Outer.Inner")); + assert!(!m.iter().any(|x| x.source.ends_with(".*"))); + assert!(!m.iter().any(|x| x.local_name == "Thing")); + } + #[test] fn extract_php_imports_use_and_alias() { let content = "use App\\Models\\User;\nuse App\\Service as Svc;\n"; @@ -3416,8 +4865,13 @@ export { } from './empty'; out.iter() .any(|r| matches!(r, ReExport::Wildcard { source } if source == "./all")) ); + // `export * as ns` exports the namespace alone (upstream #2192). + assert!(out.iter().any(|r| matches!( + r, + ReExport::Namespace { exported_name, source } if exported_name == "ns" && source == "./ns" + ))); assert!( - out.iter() + !out.iter() .any(|r| matches!(r, ReExport::Wildcard { source } if source == "./ns")) ); assert!(out.iter().any(|r| matches!( @@ -3480,6 +4934,49 @@ export const memberAlias = namespace.target; ))); } + #[test] + fn extract_re_exports_reads_dollar_and_unicode_identifiers_in_local_aliases() { + let content = "const $fmt = () => 1;\nexport default $fmt;\nfunction make$() {}\nexport const $make = make$;\nexport const café = naïve;\n"; + let out = extract_re_exports(content, Language::TypeScript); + let aliases: Vec<(&str, &str)> = out + .iter() + .filter_map(|r| match r { + ReExport::LocalAlias { + exported_name, + original_name, + } => Some((exported_name.as_str(), original_name.as_str())), + _ => None, + }) + .collect(); + assert_eq!( + aliases, + [("$make", "make$"), ("café", "naïve"), ("default", "$fmt")] + ); + let trailing = extract_re_exports( + "const items$ = {};\nexport default items$;\n", + Language::JavaScript, + ); + assert_eq!( + trailing, + [ReExport::LocalAlias { + exported_name: "default".to_string(), + original_name: "items$".to_string(), + }] + ); + // A default that is no identifier binds nothing new: `42` and a call. + let none = extract_re_exports( + "export default 42;\nexport default make$();\n", + Language::JavaScript, + ); + assert!( + none.iter().all(|r| !matches!( + r, + ReExport::LocalAlias { original_name, .. } if original_name.starts_with("make") + )), + "{none:?}" + ); + } + #[test] fn extract_re_exports_stays_empty_for_non_ecmascript_languages() { let content = r#" @@ -4539,49 +6036,6 @@ export default defaultTarget; assert_eq!(resolved.target_node_id, "function:FuncX"); } - fn go_mod(module_path: &str, dir: &str) -> crate::types::GoModule { - crate::types::GoModule { - module_path: module_path.to_string(), - dir: dir.to_string(), - } - } - - #[test] - fn owning_go_module_prefers_the_longest_matching_module_path() { - let modules = vec![go_mod("ex/a", "a"), go_mod("ex/a/sub", "a/sub")]; - assert_eq!( - owning_go_module("ex/a/sub/pkg", &modules).map(|m| m.dir.as_str()), - Some("a/sub"), - "the nested module must win over its prefix" - ); - assert_eq!( - owning_go_module("ex/a/other", &modules).map(|m| m.dir.as_str()), - Some("a") - ); - assert!(owning_go_module("github.com/x/y", &modules).is_none()); - } - - #[test] - fn owning_go_module_rejects_a_shared_path_prefix_that_is_not_a_segment() { - let modules = vec![go_mod("ex/a", "a")]; - assert!( - owning_go_module("ex/abc", &modules).is_none(), - "`ex/abc` is a different module, not a package inside `ex/a`" - ); - } - - #[test] - fn go_package_dir_joins_module_dir_with_the_path_below_it() { - assert_eq!(go_package_dir("ex/a", &go_mod("ex/a", "a")), "a"); - assert_eq!(go_package_dir("ex/a/pkg", &go_mod("ex/a", "a")), "a/pkg"); - assert_eq!(go_package_dir("ex/m/pkg", &go_mod("ex/m", "")), "pkg"); - assert_eq!( - go_package_dir("ex/m", &go_mod("ex/m", "")), - "", - "a root module's own package lives in the project root" - ); - } - #[test] fn resolve_python_module_member_resolves() { let mut existing = BTreeSet::new(); diff --git a/crates/codegraph-resolve/src/java_scope.rs b/crates/codegraph-resolve/src/java_scope.rs new file mode 100644 index 00000000..edce41a9 --- /dev/null +++ b/crates/codegraph-resolve/src/java_scope.rs @@ -0,0 +1,952 @@ +//! Java names by scope (upstream #2126, #2195, #2236, in the shape at +//! `b635dd46`): a bare call reaches a method in scope, an import from outside +//! the project owns its name, and a bare type name is a type in reach by +//! package, import or nesting. +//! +//! Supertypes are read from the declarations' heads, as upstream reads them: +//! a first resolution pass has no `extends` edges yet. + +use std::cell::OnceCell; +use std::collections::{HashSet, VecDeque}; +use std::sync::{Arc, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::source_facts::SourceFacts; +use crate::strip_comments::{CommentLang, strip_comments_for_regex}; +use crate::types::{RefView, ResolutionContext}; + +/// How many type names a bare call's scope walk visits (upstream #2126). +const SCOPE_TYPE_LIMIT: usize = 40; + +/// How many levels above a type its ancestors are read (upstream #2236 +/// `javaAncestorNames`, `depth < 4`). +const ANCESTOR_DEPTH: usize = 4; + +/// The kinds a Java type declaration is indexed as. Upstream's +/// `JAVA_TYPE_KINDS` and `JAVA_TYPE_KINDS_VISIBLE` also list `struct`, +/// `trait`, `record` and `annotation`, which no Java node has. +fn is_java_type_kind(kind: NodeKind) -> bool { + matches!(kind, NodeKind::Class | NodeKind::Interface | NodeKind::Enum) +} + +/// `^[A-Za-z_$][\w$]*$` (ASCII), the bare names these rules judge. +fn is_java_identifier(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +/// A Java call by a bare name: `verify(mock)`, `helper()`, and `this.x()` and +/// `super.x()`, which the extractor records as `x`. +pub(crate) fn is_java_bare_call(reference: &RefView) -> bool { + reference.language == Language::Java + && reference.reference_kind == EdgeKind::Calls + && is_java_identifier(&reference.reference_name) +} + +/// What a Java file declares about the names it sees: its package and +/// imports (upstream `javaFileScope`, read without comments and strings) and +/// its static imports (upstream `javaStaticImportsOf`, read from the raw +/// text). +#[derive(Debug, Default)] +pub(crate) struct JavaFileScope { + /// `package a.b;`, or empty. + package: String, + /// Single-type and single static imports as written: `a.b.C`, `a.B.m`. + single: HashSet, + /// On-demand imports, static ones included, without the `.*`. + demand: HashSet, + /// The owners of `import static a.B.*;`: `B`. + static_owners: HashSet, + /// `B.m` for each `import static a.B.m;`. + static_members: HashSet, +} + +impl JavaFileScope { + pub(crate) fn read(source: &str) -> Self { + static PACKAGE: OnceLock = OnceLock::new(); + static IMPORT: OnceLock = OnceLock::new(); + static STATIC_IMPORT: OnceLock = OnceLock::new(); + let package = PACKAGE.get_or_init(|| { + Regex::new(r"(?m)^\s*package\s+([A-Za-z0-9_.]+)\s*;").expect("java package") + }); + let import = IMPORT.get_or_init(|| { + Regex::new(r"(?m)^\s*import\s+(?:static\s+)?([A-Za-z0-9_.]+?)(\.\*)?\s*;") + .expect("java import") + }); + let static_import = STATIC_IMPORT.get_or_init(|| { + Regex::new( + r"(?m)^\s*import\s+static\s+([A-Za-z0-9_.$]+)\s*\.\s*(\*|[A-Za-z0-9_$]+)\s*;", + ) + .expect("java static import") + }); + let text = strip_comments_for_regex(source, CommentLang::Java); + let mut scope = Self { + package: package + .captures(&text) + .map(|caps| caps[1].to_string()) + .unwrap_or_default(), + ..Self::default() + }; + for caps in import.captures_iter(&text) { + let path = caps[1].to_string(); + if caps.get(2).is_some() { + scope.demand.insert(path); + } else { + scope.single.insert(path); + } + } + for caps in static_import.captures_iter(source) { + let owner = caps[1].rsplit('.').next().unwrap_or_default(); + if &caps[2] == "*" { + scope.static_owners.insert(owner.to_string()); + } else { + scope.static_members.insert(format!("{owner}.{}", &caps[2])); + } + } + scope + } +} + +/// `file`'s [`JavaFileScope`], read once per resolution pass; empty when the +/// file is unreadable. +fn file_scope(file: &str, context: &dyn ResolutionContext) -> Arc { + context.source_facts(file).map_or_else( + || Arc::new(JavaFileScope::default()), + |facts| facts.java_file_scope(), + ) +} + +/// The supertype names a Java type declaration's head writes, by the two +/// readers upstream uses. +#[derive(Debug, Default)] +pub(crate) struct JavaHead { + /// Upstream `javaSupertypesOf` (#2126): the six lines from the + /// declaration joined, the `extends` / `implements` clause up to the + /// first `{` after it, type arguments dropped, each dotted name by its + /// last segment. + supertypes: Vec, + /// The direct names of upstream `javaAncestorNames` (#2236): the lines up + /// to the one that opens the body (at most seven), the text after the + /// first `extends` / `implements` before the `{`, type arguments dropped, + /// each name followed by a `,`, by `implements` or by the end. + ancestors: Vec, +} + +impl JavaHead { + pub(crate) fn read(facts: &SourceFacts, start_line: i64) -> Self { + static CLAUSE: OnceLock = OnceLock::new(); + static LIST: OnceLock = OnceLock::new(); + static NAME: OnceLock = OnceLock::new(); + let clause = CLAUSE.get_or_init(|| { + Regex::new(r"(?-u:\b)(?:extends|implements)(?-u:\b)([^{]*)\{").expect("java clause") + }); + let list = LIST.get_or_init(|| { + Regex::new(r"(?-u:\b)(?:extends|implements)(?-u:\b)([^{]*)").expect("java list") + }); + let name = NAME.get_or_init(|| { + Regex::new(r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*") + .expect("java head name") + }); + let count = facts.line_count(); + let start = usize::try_from(start_line).unwrap_or(1).saturating_sub(1); + + let end = start.saturating_add(6).min(count); + let head = if start < end { + facts.join_lines(start, end, " ") + } else { + String::new() + }; + let written = clause + .captures(&head) + .map(|caps| caps[1].to_string()) + .unwrap_or_default(); + let supertypes = name + .find_iter(&without_type_arguments(&written)) + .filter_map(|m| m.as_str().rsplit('.').next().map(str::to_string)) + .filter(|simple| simple != "extends" && simple != "implements") + .collect(); + + let mut header = String::new(); + let mut at = start; + while at < start.saturating_add(7).min(count) && !header.contains('{') { + header.push_str(facts.line(at)); + header.push(' '); + at += 1; + } + let before_body = header.split('{').next().unwrap_or_default(); + let listed = list + .captures(before_body) + .map(|caps| without_type_arguments(&caps[1])) + .unwrap_or_default(); + let ancestors = listed_names(&listed) + .into_iter() + .filter(|simple| simple != "extends" && simple != "implements") + .collect(); + Self { + supertypes, + ancestors, + } + } +} + +/// `text` without its type arguments, two levels deep +/// (`/<[^<>]*(?:<[^<>]*>[^<>]*)*>/g`). +fn without_type_arguments(text: &str) -> String { + static ARGS: OnceLock = OnceLock::new(); + ARGS.get_or_init(|| Regex::new(r"<[^<>]*(?:<[^<>]*>[^<>]*)*>").expect("java type args")) + .replace_all(text, "") + .into_owned() +} + +/// Each `([A-Za-z_]\w*)\s*(?=,|$|\bimplements\b|\s*$)` of a supertype list: +/// a name followed, after any whitespace, by a `,`, by `implements` or by the +/// end. Upstream's lookahead is checked by hand (the regex crate has none); +/// a word run whose follower fails fails from every start inside it, so a +/// run is judged once, from its first letter or `_`. +fn listed_names(list: &str) -> Vec { + let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_'; + let bytes = list.as_bytes(); + let mut names = Vec::new(); + let mut at = 0usize; + while at < bytes.len() { + if !is_word(bytes[at]) { + at += 1; + continue; + } + let run = at; + let mut end = at; + while end < bytes.len() && is_word(bytes[end]) { + end += 1; + } + let Some(first) = bytes[run..end] + .iter() + .position(|b| b.is_ascii_alphabetic() || *b == b'_') + else { + at = end; + continue; + }; + let rest = &list[end..]; + let after = end + (rest.len() - rest.trim_start().len()); + let follower = &list[after..]; + let followed = follower.is_empty() + || follower.starts_with(',') + || follower + .strip_prefix("implements") + .is_some_and(|tail| tail.bytes().next().is_none_or(|b| !is_word(b))); + if followed { + names.push(list[run + first..end].to_string()); + at = after.max(end); + } else { + at = end; + } + } + names +} + +/// Key prefix of the Java type head rows (W4-JVM-01): one +/// `project_metadata` row per Java file, so that a sync can tell which +/// heads a change moved once the old text is gone. +pub const HEAD_ROW_KEY_PREFIX: &str = "java_type_heads:"; + +/// The `project_metadata` key of `file_path`'s head row. +pub fn head_row_key(file_path: &str) -> String { + format!("{HEAD_ROW_KEY_PREFIX}{file_path}") +} + +/// One Java type declaration's head, as resolution reads it. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct HeadEntry { + pub name: String, + pub qualified_name: String, + /// What the scope walk of a bare call reads (upstream #2126). + pub supertypes: Vec, + /// What a type name's ancestor walk reads (upstream #2236). + pub ancestors: Vec, +} + +impl HeadEntry { + /// Every name the head writes, by either reader. + pub fn names(&self) -> impl Iterator { + self.supertypes + .iter() + .chain(&self.ancestors) + .map(String::as_str) + } +} + +/// A Java file's head row: each type declaration whose head names +/// anything, in the order of the declarations in the file (the order the +/// scope walk of a bare call reads same-named declarations in, which its +/// limit makes part of what it reaches). A declaration whose head names +/// nothing contributes no name to any walk. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeadRow { + pub entries: Vec, +} + +impl HeadRow { + /// The row of a file whose text is `source` and whose nodes are + /// `nodes`, read by the functions resolution reads heads with. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let facts = SourceFacts::new(Arc::from(source)); + let mut declarations: Vec<&Node> = nodes + .iter() + .filter(|node| node.language == Language::Java && is_java_type_kind(node.kind)) + .collect(); + declarations.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + let entries: Vec = declarations + .into_iter() + .filter_map(|node| { + let head = JavaHead::read(&facts, node.start_line); + (!head.supertypes.is_empty() || !head.ancestors.is_empty()).then(|| HeadEntry { + name: node.name.clone(), + qualified_name: node.qualified_name.clone(), + supertypes: head.supertypes, + ancestors: head.ancestors, + }) + }) + .collect(); + Self { entries } + } + + /// No entry: no row records it. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("java head row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +/// `declaration`'s [`JavaHead`], read once per resolution pass. +fn head_of(declaration: &Node, context: &dyn ResolutionContext) -> Arc { + context + .source_facts(&declaration.file_path) + .map_or_else(Arc::default, |facts| { + facts.java_head(&declaration.id, |facts| { + JavaHead::read(facts, declaration.start_line) + }) + }) +} + +/// The names every Java type declaration called `type_name` extends or +/// implements (upstream `javaSupertypesOf`). The declarations are read in +/// path order, so the walk's limit cuts the same names on every index +/// (upstream reads them in index order; KEEP-RUST). +fn supertypes_of(type_name: &str, context: &dyn ResolutionContext) -> Vec { + let mut declarations: Vec> = context + .get_nodes_by_name_shared(type_name) + .into_iter() + .filter(|node| node.language == Language::Java && is_java_type_kind(node.kind)) + .collect(); + declarations.sort_by(|a, b| { + (&a.file_path, a.start_line, a.start_column, &a.id).cmp(&( + &b.file_path, + b.start_line, + b.start_column, + &b.id, + )) + }); + declarations + .iter() + .flat_map(|declaration| head_of(declaration, context).supertypes.clone()) + .collect() +} + +/// What a bare Java call can reach (upstream #2126 `isJavaMethodInScope`): +/// a method of a class around it, of one of that class's supertypes (an +/// anonymous class's being what it instantiates), or one its file imports +/// statically. halo's tests' Mockito `verify(…)` bound 1,038 calls to an +/// `EmailVerificationService.verify`. +pub(crate) struct JavaCallScope { + file: Arc, + /// The types around the call and, breadth first, the names their + /// declarations' heads extend or implement, at most + /// [`SCOPE_TYPE_LIMIT`]. + types: HashSet, +} + +impl JavaCallScope { + pub(crate) fn of(reference: &RefView, context: &dyn ResolutionContext) -> Self { + let mut queue: VecDeque = context + .get_nodes_in_file_shared(&reference.file_path) + .iter() + .filter(|node| { + is_java_type_kind(node.kind) + && node.start_line <= reference.line + && node.end_line >= reference.line + }) + .map(|node| node.name.clone()) + .collect(); + let mut types = HashSet::new(); + while types.len() < SCOPE_TYPE_LIMIT { + let Some(name) = queue.pop_front() else { + break; + }; + if types.contains(&name) { + continue; + } + queue.extend(supertypes_of(&name, context)); + // An anonymous class derives from what it instantiates, as the + // type-name rule reads it: gson's `new LinkedTreeMapIterator<…>() + // { … nextNode() … }` calls its base's `nextNode`. Upstream reads + // only the declaration's head, which names nothing (KEEP-RUST). + if let Some(instantiated) = anonymous_supertype(&name, &name) { + queue.push_back(instantiated.to_string()); + } + types.insert(name); + } + Self { + file: file_scope(&reference.file_path, context), + types, + } + } + + /// Whether `method` is a method of a type in reach, or one the file + /// imports statically. A method without an owner is always in reach. + pub(crate) fn reaches(&self, method: &Node, reference: &RefView) -> bool { + let Some((parent, _)) = method.qualified_name.rsplit_once("::") else { + return true; + }; + let owner = parent.rsplit("::").next().unwrap_or(parent); + self.file.static_owners.contains(owner) + || self + .file + .static_members + .contains(&format!("{owner}.{}", reference.reference_name)) + || self.types.contains(owner) + } +} + +/// Whether `name` is a package a Java, Kotlin or Scala file of the project +/// declares (upstream `JVM_PACKAGES`). +fn is_project_jvm_package(name: &str, context: &dyn ResolutionContext) -> bool { + context + .get_nodes_by_qualified_name_shared(name) + .iter() + .any(|node| { + node.kind == NodeKind::Namespace + && matches!( + node.language, + Language::Java | Language::Kotlin | Language::Scala + ) + }) +} + +/// Whether a Java or Kotlin reference's first segment is bound by a +/// single-type or static import from a package the project does not declare +/// (upstream #2195 `isJavaOutsideImport`, Kotlin since #2196): after `import +/// java.lang.reflect.Field;` the file's `Field` is the JDK's, and after +/// `import static org.junit.Assert.assertEquals;` its `assertEquals` is +/// JUnit's. An import of the project's own class, a nested one included, is +/// under a project package. A declaration the file makes itself comes first, +/// in its own namespace: a callable for a call, a type otherwise (#2196). +/// An `imports` reference is the import resolver's. +pub(crate) fn binds_an_outside_import( + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if !matches!(reference.language, Language::Java | Language::Kotlin) + || reference.reference_kind == EdgeKind::Imports + { + return false; + } + let head = reference + .reference_name + .split('.') + .next() + .unwrap_or_default(); + let mappings = context.get_import_mappings(&reference.file_path, reference.language); + let Some(binding) = mappings.iter().find(|mapping| mapping.local_name == head) else { + return false; + }; + // Exposed's `toLocalDateTime(value)` inside the column type that defines + // it, beside `import kotlinx.datetime.toLocalDateTime`; but gson's `new + // URI(…)` is `java.net.URI` beside `TypeAdapters`' field `URI`. + let call = reference.reference_kind == EdgeKind::Calls && head == reference.reference_name; + if context + .get_nodes_in_file_shared(&reference.file_path) + .iter() + .any(|node| { + node.name == head + && if call { + matches!(node.kind, NodeKind::Method | NodeKind::Function) + } else { + matches!( + node.kind, + NodeKind::Class + | NodeKind::Interface + | NodeKind::Enum + | NodeKind::Struct + | NodeKind::Trait + | NodeKind::TypeAlias + ) + } + }) + { + return false; + } + let parts: Vec<&str> = binding.source.split('.').collect(); + !(1..parts.len()).any(|end| is_project_jvm_package(&parts[..end].join("."), context)) +} + +/// The first node of `file` (by line) whose qualified name is +/// `qualified_name` and that `keep` accepts. +fn first_in_file( + qualified_name: &str, + file: &str, + keep: impl Fn(&Node) -> bool, + context: &dyn ResolutionContext, +) -> Option> { + context + .get_nodes_by_qualified_name_shared(qualified_name) + .into_iter() + .filter(|node| node.file_path == file && keep(node)) + .min_by_key(|node| node.start_line) +} + +/// The type an anonymous class instantiates: `NodeFilter` of +/// ``, read from its name or the last segment of its +/// qualified name. +fn anonymous_supertype<'n>(name: &'n str, qualified_name: &'n str) -> Option<&'n str> { + static AT_END: OnceLock = OnceLock::new(); + static ANYWHERE: OnceLock = OnceLock::new(); + let at_end = AT_END.get_or_init(|| { + Regex::new(r"<([A-Za-z_$][A-Za-z0-9_$]*)\$anon@[0-9]+>$").expect("java anon name") + }); + let anywhere = ANYWHERE.get_or_init(|| { + Regex::new(r"<([A-Za-z_$][A-Za-z0-9_$]*)\$anon@[0-9]+>").expect("java anon segment") + }); + at_end + .captures(name) + .and_then(|caps| caps.get(1)) + .or_else(|| { + let last = qualified_name.rsplit("::").next().unwrap_or_default(); + anywhere.captures(last).and_then(|caps| caps.get(1)) + }) + .map(|found| found.as_str()) +} + +/// The names the Java types of qualified name `qualified_name` extend or +/// implement, and theirs up to [`ANCESTOR_DEPTH`] levels above, through +/// every Java type of each name (upstream `javaAncestorNames`). Each type is +/// read once, at the nearest level it is reached; upstream memoises each +/// type at the depth it was first asked for, which makes a name's reach +/// depend on resolution order (KEEP-RUST). +fn ancestor_names(qualified_name: &str, context: &dyn ResolutionContext) -> HashSet { + let mut names = HashSet::new(); + let mut read = HashSet::new(); + let mut level = vec![qualified_name.to_string()]; + for depth in 0..=ANCESTOR_DEPTH { + let mut next = Vec::new(); + for qualified in level { + if !read.insert(qualified.clone()) { + continue; + } + let direct: Vec = context + .get_nodes_by_qualified_name_shared(&qualified) + .iter() + .filter(|node| node.language == Language::Java && is_java_type_kind(node.kind)) + .flat_map(|node| head_of(node, context).ancestors.clone()) + .collect(); + if depth < ANCESTOR_DEPTH { + for base in &direct { + next.extend( + context + .get_nodes_by_name_shared(base) + .into_iter() + .filter(|node| { + node.language == Language::Java + && is_java_type_kind(node.kind) + && node.qualified_name != qualified + }) + .map(|node| node.qualified_name.clone()), + ); + } + } + names.extend(direct); + } + if next.is_empty() { + break; + } + level = next; + } + names +} + +/// How the reference's own line writes its name. +#[derive(Debug, Default)] +struct Written { + /// `outer.new Inner()`. + through_new: bool, + /// Each qualifier written before the name, `RequestFactory` of + /// `RequestFactory.Builder`, a type annotation allowed between them + /// (jsoup's `Range.@Nullable Spans`). + qualifiers: Vec, +} + +/// A bare Java name's reference site, read once for every candidate it is +/// judged against (upstream #2236 `isJavaTypeVisible`). +pub(crate) struct JavaTypeSite<'r> { + reference: &'r RefView, + judged: bool, + here: OnceCell>, + line: OnceCell, + written: OnceCell, + /// `(qualified name, name)` of each Java type around the reference. + enclosing: OnceCell>, + /// What the types around the reference extend or implement, and what + /// an anonymous class among them instantiates. + around: OnceCell>, +} + +impl<'r> JavaTypeSite<'r> { + pub(crate) fn new(reference: &'r RefView) -> Self { + Self { + reference, + judged: reference.language == Language::Java + && is_java_identifier(&reference.reference_name), + here: OnceCell::new(), + line: OnceCell::new(), + written: OnceCell::new(), + enclosing: OnceCell::new(), + around: OnceCell::new(), + } + } + + /// Whether the bare name can mean `candidate`. A top-level type is in + /// reach from its own package and through a single-type or on-demand + /// import; a nested type from inside its owner (or a type deriving from + /// it), through an import of it or of its owner's members, or written + /// through its owner. A constructor is in reach where its type is, and an + /// enum constant inside its enum, in a `case` label, through a static + /// import or written through its enum. Anything else, and any name in + /// another language, is not judged here. + pub(crate) fn visible(&self, candidate: &Node, context: &dyn ResolutionContext) -> bool { + if !self.judged || candidate.language != Language::Java { + return true; + } + match candidate.kind { + NodeKind::Method => self.constructor_visible(candidate, context), + NodeKind::EnumMember => self.enum_constant_visible(candidate, context), + kind if is_java_type_kind(kind) => self.type_visible(candidate, context), + _ => true, + } + } + + /// lombok's `@Builder` and okhttp's `new OkHttpClient.Builder()` are no + /// project `Builder`'s constructor. A call is judged as a method. + fn constructor_visible(&self, candidate: &Node, context: &dyn ResolutionContext) -> bool { + let segments: Vec<&str> = candidate.qualified_name.split("::").collect(); + if segments.len() < 2 + || segments[segments.len() - 2] != candidate.name + || self.reference.reference_kind == EdgeKind::Calls + { + return true; + } + let owner_qualified = segments[..segments.len() - 1].join("::"); + first_in_file( + &owner_qualified, + &candidate.file_path, + |node| is_java_type_kind(node.kind), + context, + ) + .is_none_or(|owner| self.visible(&owner, context)) + } + + /// Never `java.lang.Character`'s `Character.MIN_SUPPLEMENTARY_CODE_POINT` + /// for jsoup's `TokenType.Character`. + fn enum_constant_visible(&self, candidate: &Node, context: &dyn ResolutionContext) -> bool { + if candidate.file_path == self.reference.file_path { + return true; + } + let segments: Vec<&str> = candidate.qualified_name.split("::").collect(); + let enum_name = segments + .len() + .checked_sub(2) + .map_or("", |index| segments[index]); + let line = self.line(context); + let name = regex::escape(&self.reference.reference_name); + let in_case = Regex::new(&format!( + r"(?-u:\b)case(?-u:\b)[^:;]*(?-u:\b){name}(?-u:\b)" + )) + .is_ok_and(|label| label.is_match(line)); + let through_enum = Regex::new(&format!( + r"(?-u:\b){}\s*\.\s*{name}(?-u:\b)", + regex::escape(enum_name) + )) + .is_ok_and(|written| written.is_match(line)); + if in_case || through_enum { + return true; + } + let package = file_scope(&candidate.file_path, context); + let enum_path = std::iter::once(package.package.as_str()) + .chain( + segments + .get(1..segments.len().saturating_sub(1)) + .unwrap_or_default() + .iter() + .copied(), + ) + .filter(|part| !part.is_empty()) + .collect::>() + .join("."); + let here = self.here(context); + here.single + .contains(&format!("{enum_path}.{}", self.reference.reference_name)) + || here.demand.contains(&enum_path) + } + + /// halo's `Context`, retrofit's `Builder`, jsoup's `Attribute` (meant: + /// `Evaluator.Attribute`) reached a same-named type nothing imported. + fn type_visible(&self, candidate: &Node, context: &dyn ResolutionContext) -> bool { + let segments: Vec<&str> = candidate.qualified_name.split("::").collect(); + let candidate_scope = file_scope(&candidate.file_path, context); + let package = candidate_scope.package.as_str(); + // The qualified name leads with the package when there is one. + let type_path = if !package.is_empty() && segments.first() == Some(&package) { + &segments[1..] + } else { + &segments[..] + }; + let path = std::iter::once(package) + .chain(type_path.iter().copied()) + .filter(|part| !part.is_empty()) + .collect::>() + .join("."); + let written = self.written(context); + if written.through_new { + return true; + } + // Nested only inside a type the file declares; a class local to a + // method is the lexical rule's to judge. + let owner_qualified = segments[..segments.len().saturating_sub(1)].join("::"); + let owner = (segments.len() > 1) + .then(|| { + first_in_file( + &owner_qualified, + &candidate.file_path, + |node| !matches!(node.kind, NodeKind::Namespace | NodeKind::File), + context, + ) + }) + .flatten(); + if owner + .as_ref() + .is_some_and(|owner| !is_java_type_kind(owner.kind)) + { + return true; + } + let owner_name = owner.as_ref().map_or("", |owner| owner.name.as_str()); + let nested = owner.is_some(); + let owner_suffix = format!(".{owner_name}"); + if written.qualifiers.iter().any(|qualifier| { + if nested { + qualifier == owner_name || qualifier.ends_with(&owner_suffix) + } else { + qualifier == package + } + }) { + return true; + } + let here = self.here(context); + if !nested { + if candidate.file_path == self.reference.file_path || package == here.package { + return true; + } + return here.single.contains(&path) || here.demand.contains(package); + } + // Nested: inside its owner, a subtype of it, or imported. + let owner_prefix = format!("{owner_qualified}::"); + if self.enclosing(context).iter().any(|(qualified, _)| { + *qualified == owner_qualified || qualified.starts_with(&owner_prefix) + }) { + return true; + } + if self.around(context).contains(owner_name) { + return true; + } + let owner_path = path.rsplit_once('.').map_or("", |(owner, _)| owner); + here.single.contains(&path) || here.demand.contains(owner_path) + } + + fn here(&self, context: &dyn ResolutionContext) -> &JavaFileScope { + self.here + .get_or_init(|| file_scope(&self.reference.file_path, context)) + } + + /// The reference's own line, or empty. + fn line(&self, context: &dyn ResolutionContext) -> &str { + self.line.get_or_init(|| { + context + .source_facts(&self.reference.file_path) + .and_then(|facts| { + usize::try_from(self.reference.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .filter(|index| *index < facts.line_count()) + .map(|index| facts.line(index).to_string()) + }) + .unwrap_or_default() + }) + } + + fn written(&self, context: &dyn ResolutionContext) -> &Written { + self.written.get_or_init(|| { + let line = self.line(context); + // Both shapes write a `.` before the name. + if !line.contains('.') { + return Written::default(); + } + let name = regex::escape(&self.reference.reference_name); + let through_new = Regex::new(&format!(r"\.\s*new\s+{name}(?-u:\b)")) + .is_ok_and(|shape| shape.is_match(line)); + let qualifiers = Regex::new(&format!( + r"([A-Za-z_$][A-Za-z0-9_$.]*)\s*\.\s*(?:@[A-Za-z0-9_.]+(?:\([^)]*\))?\s+)*{name}(?-u:\b)" + )) + .map(|shape| { + shape + .captures_iter(line) + .map(|caps| caps[1].to_string()) + .collect() + }) + .unwrap_or_default(); + Written { + through_new, + qualifiers, + } + }) + } + + fn enclosing(&self, context: &dyn ResolutionContext) -> &[(String, String)] { + self.enclosing.get_or_init(|| { + context + .get_nodes_in_file_shared(&self.reference.file_path) + .iter() + .filter(|node| { + is_java_type_kind(node.kind) + && node.start_line <= self.reference.line + && node.end_line >= self.reference.line + }) + .map(|node| (node.qualified_name.clone(), node.name.clone())) + .collect() + }) + } + + /// An anonymous class (`new NodeFilter() { … }`, named + /// ``) derives from what it instantiates. + fn around(&self, context: &dyn ResolutionContext) -> &HashSet { + self.around.get_or_init(|| { + let mut names = HashSet::new(); + for (qualified, name) in self.enclosing(context) { + let Some(instantiated) = anonymous_supertype(name, qualified) else { + names.extend(ancestor_names(qualified, context)); + continue; + }; + names.insert(instantiated.to_string()); + for declaration in context.get_nodes_by_name_shared(instantiated) { + if declaration.language == Language::Java && is_java_type_kind(declaration.kind) + { + names.extend(ancestor_names(&declaration.qualified_name, context)); + } + } + } + names + }) + } +} + +/// Whether a bare Java name at `reference` can mean `candidate` (upstream +/// #2236 `isJavaTypeVisible`); [`JavaTypeSite::visible`] for one candidate. +pub(crate) fn is_java_type_visible( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + JavaTypeSite::new(reference).visible(candidate, context) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn listed_names_follow_upstream_lookahead() { + assert_eq!( + listed_names(" Base implements Foo, Bar "), + ["Base", "Foo", "Bar"] + ); + assert_eq!(listed_names(" a.b.Base "), ["Base"]); + // A name followed by anything else is no supertype. + assert_eq!(listed_names(" B permits C, D "), ["C", "D"]); + assert_eq!(listed_names(" Foo$Bar "), ["Bar"]); + assert_eq!(listed_names(" 9ab "), ["ab"]); + assert!(listed_names(" Comparable> ").is_empty()); + assert_eq!(listed_names(" X implementsY"), ["implementsY"]); + } + + #[test] + fn type_arguments_go_two_levels_deep() { + assert_eq!(without_type_arguments("Map> x"), "Map x"); + assert_eq!(without_type_arguments("A>>"), "A"); + } + + #[test] + fn file_scope_reads_package_imports_and_static_imports() { + let scope = JavaFileScope::read( + "// package nope;\npackage app.web;\n\nimport java.util.List;\nimport static org.junit.Assert.*;\nimport static org.mockito.Mockito.verify;\nimport app.model.*;\n/* import gone.Thing; */\n", + ); + assert_eq!(scope.package, "app.web"); + assert!(scope.single.contains("java.util.List")); + assert!(scope.single.contains("org.mockito.Mockito.verify")); + assert!(scope.demand.contains("org.junit.Assert")); + assert!(scope.demand.contains("app.model")); + assert!(!scope.single.contains("gone.Thing")); + assert!(scope.static_owners.contains("Assert")); + assert!(scope.static_members.contains("Mockito.verify")); + } + + #[test] + fn heads_are_read_by_both_readers() { + let facts = SourceFacts::new(Arc::from( + "public class Child\n extends Base>\n implements a.b.Runner, Closeable {\n}\n", + )); + let head = JavaHead::read(&facts, 1); + // `javaSupertypesOf` reads the first clause: `extends Number>` here. + assert_eq!(head.supertypes, ["Number", "Base", "Runner", "Closeable"]); + assert_eq!(head.ancestors, ["Base", "Runner", "Closeable"]); + let plain = SourceFacts::new(Arc::from("class Alone {\n}\n")); + let head = JavaHead::read(&plain, 1); + assert!(head.supertypes.is_empty() && head.ancestors.is_empty()); + } + + #[test] + fn anonymous_classes_name_what_they_instantiate() { + assert_eq!( + anonymous_supertype("", "a::B::f::"), + Some("NodeFilter") + ); + assert_eq!(anonymous_supertype("Plain", "a::Plain"), None); + } + + #[test] + fn identifiers_are_ascii_with_dollar() { + assert!(is_java_identifier("$x_1")); + assert!(!is_java_identifier("1x")); + assert!(!is_java_identifier("a.b")); + assert!(!is_java_identifier("")); + } +} diff --git a/crates/codegraph-resolve/src/jsx_tags.rs b/crates/codegraph-resolve/src/jsx_tags.rs new file mode 100644 index 00000000..913fc84f --- /dev/null +++ b/crates/codegraph-resolve/src/jsx_tags.rs @@ -0,0 +1,98 @@ +//! A JSX tag naming a local of the component it is written in (upstream +//! #2442). +//! +//! `(Widget, index) => ` and outline's `const Content = variant === +//! "dropdown" ? DropdownMenu.SubContent : ContextMenu.SubContent; … ` render the value the component binds, never a same-named component of +//! another file. A tag whose name its component binds itself, a parameter or +//! a plain `const` / `let` / `var` by the tag's line, renders only a +//! component, function or class the parent declares inside itself (`const +//! Row = ({ row }) => ; … `), else nothing. A destructured name +//! still links by name, as a destructured call does, and a tag above the +//! local keeps the outer one. +//! +//! Upstream reads tags off the parent's text with a pattern, so it also had +//! to skip a name written in a type argument or parameter list +//! (`useState()`, ``, `(…) +//! =>`); the walker emits a tag's reference from the syntax tree, where those +//! are no tags. + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; + +use crate::name_matcher::js_function_local_scope; +use crate::types::{RefView, ResolutionContext, ResolvedRef}; + +/// `result` for a JSX tag's reference, unless its component binds the name +/// itself: then a component the parent declares inside itself, or nothing. +pub(crate) fn gate_jsx_local_tag( + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let result = result?; + let name = reference.reference_name.as_str(); + if reference.reference_kind != EdgeKind::References + || !matches!(reference.language, Language::Tsx | Language::Jsx) + || !is_tag_at(reference, context) + || js_function_local_scope(name, reference, context).is_none() + { + return Some(result); + } + let Some(parent) = context.get_node_by_id_shared(&reference.from_node_id) else { + return Some(result); + }; + let inside = |node: &Node| { + node.id != parent.id + && node.file_path == parent.file_path + && node.start_line >= parent.start_line + && node.start_line <= parent.end_line + }; + if context + .get_node_by_id_shared(&result.target_node_id) + .is_some_and(|target| inside(&target)) + { + return Some(result); + } + let inner = context + .get_nodes_by_name_shared(name) + .into_iter() + .find(|node| { + matches!( + node.kind, + NodeKind::Component | NodeKind::Function | NodeKind::Class + ) && inside(node) + })?; + Some(ResolvedRef { + target_node_id: inner.id.clone(), + ..result + }) +} + +/// Whether the reference is a JSX tag's name: written right after `<`, a +/// plain name (not ` bool { + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let Some(text) = usize::try_from(reference.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .and_then(|index| facts.raw_line(index)) + else { + return false; + }; + let Ok(column) = usize::try_from(reference.column) else { + return false; + }; + let (Some(before), Some(rest)) = (text.get(..column), text.get(column..)) else { + return false; + }; + before.ends_with('<') + && rest + .strip_prefix(reference.reference_name.as_str()) + .is_some_and(|after| { + !after.starts_with(|c: char| { + c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '.') + }) + }) +} diff --git a/crates/codegraph-resolve/src/kotlin_scope.rs b/crates/codegraph-resolve/src/kotlin_scope.rs new file mode 100644 index 00000000..aaeb2b2c --- /dev/null +++ b/crates/codegraph-resolve/src/kotlin_scope.rs @@ -0,0 +1,1766 @@ +//! Kotlin names by scope (upstream #2146, #2172, #2168, #2206, #2232, in the +//! shape at `b635dd46`): a top-level declaration is visible from its package, +//! an import or a star import; a bare call reaches a member of a type in reach +//! (the classes, anonymous objects, extension receivers and lambdas around +//! it, what they inherit, a type the project's function types take as a +//! receiver, an imported object); a standard-library name on an untyped chain +//! link needs a receiver named after its owner; and a Gradle script's bare +//! call never reaches a member of a project class. +//! +//! Heads and receivers are read from source, as upstream reads them: a first +//! resolution pass has no `extends` edges yet. + +use std::cell::OnceCell; +use std::collections::{BTreeSet, HashSet, VecDeque}; +use std::sync::{Arc, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::strip_comments::{CommentLang, blank_string_contents, strip_comments_for_regex}; +use crate::types::{RefView, ResolutionContext}; + +/// Packages every Kotlin file imports without writing it. +const KOTLIN_DEFAULT_IMPORTS: &[&str] = &[ + "kotlin", + "kotlin.annotation", + "kotlin.collections", + "kotlin.comparisons", + "kotlin.io", + "kotlin.ranges", + "kotlin.sequences", + "kotlin.text", + "kotlin.jvm", + "java.lang", + "kotlin.js", +]; + +/// Kotlin's scope functions and standard collection / string / conversion +/// methods (upstream #2168 `KOTLIN_STD_METHODS`): on an untyped chain link +/// (`builder.apply { … }`, `list.map { … }`) they are the standard +/// library's, never a project type's same-named member. Names project types +/// commonly carry (`get`, `write`, `close`, `add`) are left out. +const KOTLIN_STD_METHODS: &[&str] = &[ + "apply", + "also", + "let", + "run", + "takeIf", + "takeUnless", + "toString", + "equals", + "hashCode", + "map", + "mapNotNull", + "mapIndexed", + "filter", + "filterNot", + "filterIsInstance", + "forEach", + "forEachIndexed", + "first", + "firstOrNull", + "last", + "lastOrNull", + "single", + "singleOrNull", + "isEmpty", + "isNotEmpty", + "isNullOrEmpty", + "isNullOrBlank", + "isBlank", + "isNotBlank", + "orEmpty", + "joinToString", + "toList", + "toMutableList", + "toSet", + "toMutableSet", + "toMap", + "toTypedArray", + "any", + "all", + "none", + "count", + "sumOf", + "maxOf", + "minOf", + "maxOrNull", + "minOrNull", + "sortedBy", + "sortedByDescending", + "sorted", + "sortedWith", + "reversed", + "drop", + "dropLast", + "take", + "takeLast", + "zip", + "flatMap", + "flatten", + "distinct", + "groupBy", + "associate", + "associateBy", + "associateWith", + "partition", + "contains", + "containsKey", + "getOrElse", + "getOrNull", + "getOrPut", + "getOrDefault", + "trim", + "trimEnd", + "trimStart", + "split", + "substring", + "startsWith", + "endsWith", + "replace", + "lowercase", + "uppercase", + "toInt", + "toLong", + "toDouble", + "toFloat", + "toIntOrNull", + "toLongOrNull", + "encodeToByteArray", + "decodeToString", + "copyOf", + "copyOfRange", + "indexOf", + "lastIndexOf", + "withIndex", + "asSequence", + "asList", + "ifEmpty", + "ifBlank", + "padStart", + "padEnd", + "repeat", + "lines", + "toCharArray", + "coerceAtLeast", + "coerceAtMost", + "coerceIn", +]; + +/// Words before a `{` that open a block, not a lambda argument. +const KOTLIN_BLOCK_WORDS: &[&str] = &[ + "if", + "else", + "for", + "while", + "do", + "when", + "try", + "catch", + "finally", + "init", + "get", + "set", + "constructor", + "fun", + "class", + "object", + "interface", + "return", + "by", + "lazy", + "apply", + "run", + "also", + "let", + "with", + "use", +]; + +/// What the Android framework and AndroidX classes a Kotlin class commonly +/// extends inherit (upstream `KOTLIN_PLATFORM_SUPERS`), so an extension on +/// `ComponentCallbacks` or `ComponentActivity` is in reach of an +/// `AppCompatActivity` subclass. +fn platform_supers(name: &str) -> &'static [&'static str] { + match name { + "AppCompatActivity" => &["FragmentActivity"], + "FragmentActivity" => &["ComponentActivity"], + "ComponentActivity" => &[ + "Activity", + "LifecycleOwner", + "ViewModelStoreOwner", + "SavedStateRegistryOwner", + ], + "Activity" => &["ContextThemeWrapper", "ComponentCallbacks2"], + "ContextThemeWrapper" => &["ContextWrapper"], + "ContextWrapper" => &["Context"], + "Application" | "Service" => &["ContextWrapper", "ComponentCallbacks2"], + "ComponentCallbacks2" => &["ComponentCallbacks"], + "Fragment" => &[ + "ComponentCallbacks", + "LifecycleOwner", + "ViewModelStoreOwner", + "SavedStateRegistryOwner", + ], + "DialogFragment" => &["Fragment"], + "AppCompatDialogFragment" => &["DialogFragment"], + "BottomSheetDialogFragment" => &["AppCompatDialogFragment"], + "AndroidViewModel" => &["ViewModel"], + _ => &[], + } +} + +/// How many type names a bare call's hierarchy walk visits. +const HIERARCHY_LIMIT: usize = 60; + +/// How many receiver types the project-wide set grows to through +/// supertypes. +const RECEIVER_TYPES_LIMIT: usize = 5000; + +/// Upstream `MEMBER_CLASS_KINDS` (no node has `record`). +pub(crate) fn is_member_class_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Class | NodeKind::Struct | NodeKind::Interface | NodeKind::Enum + ) +} + +/// Upstream `isMethodOwnerKind`. +pub(crate) fn is_method_owner_kind(node: &Node) -> bool { + matches!( + node.kind, + NodeKind::Class | NodeKind::Struct | NodeKind::Union | NodeKind::Interface + ) || (node.language == Language::Scala && node.kind == NodeKind::Module) +} + +/// `^[A-Za-z_$][\w$]*$` (ASCII). +fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +fn is_word_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +/// A Kotlin call by a bare name (upstream `kotlinCall`). +pub(crate) fn is_kotlin_call(reference: &RefView) -> bool { + reference.language == Language::Kotlin + && reference.reference_kind == EdgeKind::Calls + && is_identifier(&reference.reference_name) +} + +/// A Kotlin file's `package` and the names and packages its `import`s bring +/// in (upstream `kotlinFileScope`): block comments blanked and backticks +/// dropped first. +#[derive(Debug, Default)] +pub(crate) struct KotlinFileScope { + pub(crate) package: String, + imports: HashSet, + stars: HashSet, +} + +impl KotlinFileScope { + pub(crate) fn read(source: &str) -> Self { + static BLOCK: OnceLock = OnceLock::new(); + static PACKAGE: OnceLock = OnceLock::new(); + static IMPORT: OnceLock = OnceLock::new(); + let block = BLOCK.get_or_init(|| Regex::new(r"(?s)/\*.*?\*/").expect("kotlin block")); + let package = PACKAGE.get_or_init(|| { + Regex::new(r"(?m)^\s*package\s+([A-Za-z0-9_.]+)").expect("kotlin package") + }); + let import = IMPORT.get_or_init(|| { + Regex::new( + r"(?m)^\s*import\s+([A-Za-z0-9_.]+?)(\.\*)?(?:\s+as\s+[A-Za-z0-9_]+)?\s*;?\s*(?://.*)?$", + ) + .expect("kotlin import") + }); + let text = block.replace_all(source, " ").replace('`', ""); + let mut scope = Self { + package: package + .captures(&text) + .map(|caps| caps[1].to_string()) + .unwrap_or_default(), + ..Self::default() + }; + for caps in import.captures_iter(&text) { + let path = caps[1].to_string(); + if caps.get(2).is_some() { + scope.stars.insert(path); + } else { + scope.imports.insert(path); + } + } + scope + } +} + +/// `file`'s [`KotlinFileScope`], read once per resolution pass; empty when +/// the file is unreadable. +pub(crate) fn file_scope(file: &str, context: &dyn ResolutionContext) -> Arc { + context.source_facts(file).map_or_else( + || Arc::new(KotlinFileScope::default()), + |facts| facts.kotlin_file_scope(), + ) +} + +/// Upstream `KOTLIN_ENCLOSING_KINDS`. +fn is_enclosing_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Class + | NodeKind::Interface + | NodeKind::Enum + | NodeKind::Struct + | NodeKind::Trait + | NodeKind::Protocol + | NodeKind::Module + | NodeKind::Namespace + | NodeKind::Function + | NodeKind::Method + ) +} + +/// Whether a top-level Kotlin declaration (a function, an extension function +/// indexed under its receiver type, `JdbcTransaction::assertEquals`, a +/// property) can be named from the file a call is written in (upstream +/// #2146 `isKotlinTopLevelVisible`): its own package, an `import` of it, a +/// star import of its package, or a package every file imports. A member of a +/// class is not judged here; a lambda's receiver can put any type's members +/// in scope. +pub(crate) fn is_kotlin_top_level_visible( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if candidate.language != Language::Kotlin || candidate.file_path == reference.file_path { + return true; + } + let enclosed = context + .get_nodes_in_file_shared(&candidate.file_path) + .iter() + .any(|other| { + other.id != candidate.id + && is_enclosing_kind(other.kind) + && other.start_line <= candidate.start_line + && other.end_line >= candidate.end_line + && (other.start_line < candidate.start_line || other.end_line > candidate.end_line) + }); + if enclosed { + return true; + } + let candidate_scope = file_scope(&candidate.file_path, context); + let package = candidate_scope.package.as_str(); + let here = file_scope(&reference.file_path, context); + let imported = if package.is_empty() { + candidate.name.clone() + } else { + format!("{package}.{}", candidate.name) + }; + package == here.package + || here.stars.contains(package) + || here.imports.contains(&imported) + || KOTLIN_DEFAULT_IMPORTS.contains(&package) +} + +/// Segment `line - 1` of the reference's file split at `\n` (a `\r` before +/// it kept), as upstream reads a call's line. +fn raw_line(reference: &RefView, context: &dyn ResolutionContext) -> Option { + let facts = context.source_facts(&reference.file_path)?; + let index = usize::try_from(reference.line).ok()?.checked_sub(1)?; + facts.raw_line(index).map(str::to_string) +} + +/// Where the reference's name starts on `line`: at its column, or else its +/// first occurrence (not after a word character or `$`) followed, past any +/// whitespace, by one of `followers`. +fn name_start( + reference: &RefView, + line: &str, + followers: &[char], + at_column_needs_boundary: bool, +) -> Option { + let name = reference.reference_name.as_str(); + let column = usize::try_from(reference.column).unwrap_or(0); + if line + .get(column..) + .is_some_and(|rest| rest.starts_with(name)) + && (!at_column_needs_boundary + || !line[..column] + .chars() + .next_back() + .is_some_and(|c| is_word_char(c) || c == '$')) + { + return Some(column); + } + line.match_indices(name).map(|(at, _)| at).find(|&at| { + !line[..at] + .chars() + .next_back() + .is_some_and(|c| is_word_char(c) || c == '$') + && line[at + name.len()..].trim_start().starts_with(followers) + }) +} + +/// Whether a bare Kotlin name is written with no receiver at its call, not +/// as a later link of a chain (`….name(`; upstream #2172 +/// `isReceiverLessKotlinCall`). +pub(crate) fn is_receiver_less_kotlin_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let Some(line) = raw_line(reference, context) else { + return false; + }; + name_start(reference, &line, &['(', '{', '<'], true) + .is_some_and(|start| !line[..start].trim_end().ends_with(['.', ':'])) +} + +/// Whether a bare `require(…)` / `check(…)` / `assert(…)` is Kotlin's +/// precondition (upstream `isKotlinPreconditionCall`): its condition is a +/// comparison or boolean expression, or a lazy message follows it. +fn is_kotlin_precondition_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { + static CONDITION: OnceLock = OnceLock::new(); + if !matches!( + reference.reference_name.as_str(), + "require" | "check" | "assert" + ) { + return false; + } + let Some(line) = raw_line(reference, context) else { + return false; + }; + let name = reference.reference_name.as_str(); + // `(?=!]=|&&|\|\||(?:^|[\s(])!|\s[<>]\s|(?-u:\b)is(?-u:\b)|(?-u:\b)in(?-u:\b)|(?-u:\b)true(?-u:\b)|(?-u:\b)false(?-u:\b)", + ) + .expect("kotlin precondition") + }) + .is_match(&args) + || rest.trim_start().starts_with('{') +} + +/// The project-wide Kotlin facts a bare call reads, computed once per +/// resolution pass. +#[derive(Debug, Default)] +pub struct KotlinTables { + receiver_types: OnceLock>, +} + +impl KotlinTables { + pub fn new() -> Self { + Self::default() + } + + /// Every type a Kotlin function type in the project takes as its + /// receiver, every type from outside the project it writes extensions on, + /// and what those inherit (upstream #2172 `kotlinReceiverTypes`). + fn receiver_types(&self, context: &dyn ResolutionContext) -> &HashSet { + self.receiver_types + .get_or_init(|| receiver_types_of(context)) + } +} + +/// What one Kotlin file's text contributes to the project's receiver types: +/// the type before each `.(` (`Module` in `Module.() -> Unit`), and each +/// segment of an extension function's receiver. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ReceiverMentions { + pub function_types: Vec, + pub extensions: Vec, +} + +impl ReceiverMentions { + pub fn read(source: &str) -> Self { + static FUNCTION_TYPE: OnceLock = OnceLock::new(); + static EXTENSION: OnceLock = OnceLock::new(); + let function_type = FUNCTION_TYPE.get_or_init(|| { + Regex::new( + r"(?-u:\b)([A-Z][A-Za-z0-9_]*)(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\s*\.\s*\(", + ) + .expect("kotlin function type") + }); + let extension = EXTENSION.get_or_init(|| { + Regex::new( + r"(?-u:\b)fun\s+(?:<[^>]*>\s*)?([A-Z][A-Za-z0-9_]*(?:\.[A-Z][A-Za-z0-9_]*)*)(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\??\.[A-Za-z_`][A-Za-z0-9_`]*\s*\(", + ) + .expect("kotlin extension") + }); + let text = strip_comments_for_regex(source, CommentLang::Java); + let mut mentions = Self::default(); + for caps in function_type.captures_iter(&text) { + push_unique(&mut mentions.function_types, &caps[1]); + } + for caps in extension.captures_iter(&text) { + for part in caps[1].split('.') { + push_unique(&mut mentions.extensions, part); + } + } + mentions + } +} + +fn push_unique(list: &mut Vec, name: &str) { + if !list.iter().any(|known| known == name) { + list.push(name.to_string()); + } +} + +/// Whether `path` is a Kotlin source or script (`\.kts?$`). +pub fn is_kotlin_path(path: &str) -> bool { + path.ends_with(".kt") || path.ends_with(".kts") +} + +/// Upstream `kotlinReceiverTypes`, over the Kotlin files in path order. +fn receiver_types_of(context: &dyn ResolutionContext) -> HashSet { + let mut files: Vec = context + .get_all_files_shared() + .iter() + .filter(|file| is_kotlin_path(file)) + .cloned() + .collect(); + files.sort(); + let mentions: Vec = files + .iter() + .filter_map(|file| context.read_file_shared(file)) + .map(|source| ReceiverMentions::read(&source)) + .collect(); + receiver_types_from( + &mentions, + |name| is_declared_class(name, context), + |name| kotlin_declared_supertypes(name, context), + ) +} + +/// Whether a Kotlin or Java class, struct, interface or enum of `name` +/// exists: an extension on such a type is no outside type's. +fn is_declared_class(name: &str, context: &dyn ResolutionContext) -> bool { + context.get_nodes_by_name_shared(name).iter().any(|node| { + is_member_class_kind(node.kind) + && matches!(node.language, Language::Kotlin | Language::Java) + }) +} + +/// The receiver types `mentions` (each Kotlin file's, in path order) make: +/// every function type's receiver, then every extension receiver no +/// `declared` class names, grown by `supertypes`. +pub fn receiver_types_from( + mentions: &[ReceiverMentions], + declared: impl Fn(&str) -> bool, + supertypes: impl Fn(&str) -> Vec, +) -> HashSet { + let mut types: Vec = Vec::new(); + let mut outside: Vec = Vec::new(); + for file in mentions { + for name in &file.function_types { + push_unique(&mut types, name); + } + for name in &file.extensions { + push_unique(&mut outside, name); + } + } + for name in outside { + if !declared(&name) { + push_unique(&mut types, &name); + } + } + expand_receiver_types(types, supertypes) +} + +/// `types` grown, breadth first, by what their Kotlin declarations' heads +/// extend, up to [`RECEIVER_TYPES_LIMIT`] names. +pub fn expand_receiver_types( + types: Vec, + supertypes: impl Fn(&str) -> Vec, +) -> HashSet { + let mut set: HashSet = types.iter().cloned().collect(); + let mut queue: VecDeque = types.into(); + while set.len() < RECEIVER_TYPES_LIMIT { + let Some(name) = queue.pop_front() else { + break; + }; + for supertype in supertypes(&name) { + if set.insert(supertype.clone()) { + queue.push_back(supertype); + } + } + } + set +} + +/// The supertypes every Kotlin member class named `name` declares in its +/// head (upstream `classHeadSupertypes`), the declarations in path order. +fn kotlin_declared_supertypes(name: &str, context: &dyn ResolutionContext) -> Vec { + let mut declarations: Vec> = context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| node.language == Language::Kotlin && is_member_class_kind(node.kind)) + .collect(); + declarations.sort_by(|a, b| { + (&a.file_path, a.start_line, a.start_column, &a.id).cmp(&( + &b.file_path, + b.start_line, + b.start_column, + &b.id, + )) + }); + declarations + .iter() + .flat_map(|declaration| crate::class_heads::class_head_supertypes(declaration, context)) + .collect() +} + +/// What a `{` of a Kotlin file opens, read from the text before it +/// (upstream `kotlinBraceFrames`): a `with(x)` / `.apply` / `.run` block, a +/// type or extension head's names, or a lambda passed to the named call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BraceHead { + Scoped, + Names(Vec), + Lambda(String), +} + +/// A brace pair of a Kotlin file whose head names something, by line range, +/// in the order the pairs close. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScannedBrace { + start: i64, + end: i64, + head: BraceHead, +} + +/// Each brace pair of `source` whose head names something (upstream +/// `kotlinBraceFrames`, before the lambda receivers are read): comments and +/// strings blanked, the head the text since the last `{`, `}` or `;` (at +/// most 600 characters). +pub(crate) fn scan_braces(source: &str) -> Vec { + static SCOPED: OnceLock = OnceLock::new(); + static CALL: OnceLock = OnceLock::new(); + let scoped_re = SCOPED.get_or_init(|| { + Regex::new(r"(?:(?-u:\b)with\s*\([^{}]*\)|\.\s*(?:apply|run)(?:\s*<[^<>]*>)?)\s*$") + .expect("kotlin scoped block") + }); + let call_re = CALL.get_or_init(|| { + Regex::new( + r"(?:^|[^A-Za-z0-9_$])([a-z_][A-Za-z0-9_]*)\s*(?:<[^<>{}]*>)?\s*(?:\([^(){}]*\))?\s*$", + ) + .expect("kotlin lambda call") + }); + let text = blank_string_contents(&strip_comments_for_regex(source, CommentLang::Java)); + let mut braces = Vec::new(); + let mut stack: Vec<(i64, Option)> = Vec::new(); + let mut line = 1i64; + let mut pending: VecDeque = VecDeque::new(); + for c in text.chars() { + if c == '\n' { + line += 1; + } + match c { + '{' => { + let head: String = pending.iter().collect(); + let opened = if scoped_re.is_match(&head) { + Some(BraceHead::Scoped) + } else { + match head_names(&head) { + Some(names) if !names.is_empty() => Some(BraceHead::Names(names)), + // `single { get() }`: a lambda runs on the receiver + // its function's parameter type names (upstream + // #2206). + _ => call_re + .captures(&head) + .and_then(|caps| caps.get(1)) + .map(|found| found.as_str()) + .filter(|call| !KOTLIN_BLOCK_WORDS.contains(call)) + .map(|call| BraceHead::Lambda(call.to_string())), + } + }; + stack.push((line, opened)); + pending.clear(); + } + '}' => { + if let Some((start, Some(head))) = stack.pop() { + braces.push(ScannedBrace { + start, + end: line, + head, + }); + } + pending.clear(); + } + ';' => pending.clear(), + _ => { + if pending.len() >= 600 { + pending.drain(..300); + } + pending.push_back(c); + } + } + } + braces +} + +/// A type body of a Kotlin file by line range (upstream +/// `kotlinBraceFrames`): each `class` / `object` / `interface` body with its +/// name and supertypes, an anonymous `object : Base(…)` with its base, an +/// extension function's body with its receiver, a lambda with the receiver +/// its function's parameter type names, or `*` for a `with(x)` / `.apply` / +/// `.run` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BraceFrame { + start: i64, + end: i64, + names: Vec, +} + +/// Upstream `kotlinBraceFrames`. +pub(crate) fn brace_frames(source: &str, context: &dyn ResolutionContext) -> Vec { + scan_braces(source) + .into_iter() + .filter_map(|brace| { + let names = match brace.head { + BraceHead::Scoped => vec!["*".to_string()], + BraceHead::Names(names) => names, + BraceHead::Lambda(call) => vec![lambda_receiver(&call, context)?], + }; + Some(BraceFrame { + start: brace.start, + end: brace.end, + names, + }) + }) + .collect() +} + +/// The type names a Kotlin block head introduces (upstream +/// `kotlinHeadNames`): a type declaration's name and supertypes, or an +/// extension function's receiver; `None` for any other head. +fn head_names(head: &str) -> Option> { + static DECLARATION: OnceLock = OnceLock::new(); + static SUPERS: OnceLock = OnceLock::new(); + static WHERE: OnceLock = OnceLock::new(); + static RECEIVER: OnceLock = OnceLock::new(); + let mut depth = 0usize; + let mut flat = String::new(); + for c in head.chars() { + match c { + '<' | '(' => depth += 1, + '>' | ')' => depth = depth.saturating_sub(1), + _ if depth == 0 => flat.push(c), + _ => {} + } + } + let declaration = DECLARATION.get_or_init(|| { + Regex::new( + r"(?-u:\b)(?:class|interface|object)(?-u:\b)(?:\s+([A-Za-z_][A-Za-z0-9_]*))?([^=]*)$", + ) + .expect("kotlin declaration head") + }); + if let Some(caps) = declaration.captures(&flat) { + let rest = caps.get(2).map_or("", |found| found.as_str()); + let supers = SUPERS + .get_or_init(|| Regex::new(r"(?s):\s*(.*)$").expect("kotlin supers")) + .captures(rest) + .and_then(|caps| caps.get(1)) + .map_or("", |found| found.as_str()); + let supers = WHERE + .get_or_init(|| Regex::new(r"(?s)(?-u:\b)where(?-u:\b).*$").expect("kotlin where")) + .replace(supers, ""); + let mut names: Vec = caps + .get(1) + .map(|name| name.as_str().to_string()) + .into_iter() + .collect(); + names.extend(listed_capitalized_names(&supers)); + return Some(names); + } + RECEIVER + .get_or_init(|| { + Regex::new(r"(?-u:\b)fun\s+(?:<[^>]*>\s*)?([A-Z][A-Za-z0-9_]*)(?:<[^>]*>)?\??\.[A-Za-z_][A-Za-z0-9_]*\s*\(") + .expect("kotlin extension head") + }) + .captures(head) + .map(|caps| vec![caps[1].to_string()]) +} + +/// Each `([A-Z]\w*)\s*(?=,|$|\bby\b)` of `text`, the lookahead checked by +/// hand: a capitalized word, from its first capital, followed past any +/// whitespace by a `,`, the end, or the word `by`. +fn listed_capitalized_names(text: &str) -> Vec { + let bytes = text.as_bytes(); + let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_'; + let mut names = Vec::new(); + let mut at = 0usize; + while at < bytes.len() { + if !is_word(bytes[at]) { + at += 1; + continue; + } + let run = at; + let mut end = at; + while end < bytes.len() && is_word(bytes[end]) { + end += 1; + } + let Some(first) = bytes[run..end].iter().position(u8::is_ascii_uppercase) else { + at = end; + continue; + }; + let rest = &text[end..]; + let after = end + (rest.len() - rest.trim_start().len()); + let follower = &text[after..]; + let followed = follower.is_empty() + || follower.starts_with(',') + || (after > end + && follower + .strip_prefix("by") + .is_some_and(|tail| tail.bytes().next().is_none_or(|b| !is_word(b)))); + if followed { + names.push(text[run + first..end].to_string()); + at = after.max(end); + } else { + at = end; + } + } + names +} + +/// The type a Kotlin function or method declaration writes for its last +/// parameter, as the lambda-receiver lookup reads it (upstream +/// `kotlinLambdaReceiver`): its head's twelve lines, the parameter list after +/// its name, `noinline` / `crossinline` dropped, a default value dropped. +pub(crate) fn lambda_parameter_type( + facts: &crate::source_facts::SourceFacts, + function: &Node, +) -> Option { + static TYPE: OnceLock = OnceLock::new(); + static MODIFIER: OnceLock = OnceLock::new(); + let type_re = TYPE.get_or_init(|| { + Regex::new(r"(?s):\s*(.+?)(?:\s*=\s*[^=>].*)?$").expect("kotlin parameter type") + }); + let modifier = MODIFIER.get_or_init(|| { + Regex::new(r"^\s*(?:noinline|crossinline)\s+").expect("kotlin parameter modifier") + }); + let open_re = Regex::new(&format!(r"(?-u:\b){}\s*\(", regex::escape(&function.name))).ok()?; + let start = usize::try_from(function.start_line) + .unwrap_or(1) + .saturating_sub(1); + let end = start.saturating_add(12).min(facts.line_count()); + if start >= end { + return None; + } + let head = facts.join_lines(start, end, " "); + let open = open_re.find(&head)?; + let paren = head[open.start()..].find('(').map(|at| open.start() + at)?; + let mut depth = 0usize; + let mut close = None; + for (offset, c) in head[paren..].char_indices() { + if c == '(' { + depth += 1; + } else if c == ')' { + depth = depth.saturating_sub(1); + if depth == 0 { + close = Some(paren + offset); + break; + } + } + } + let parameters = crate::cpp_namespaces::split_cpp_top_level(&head[paren + 1..close?]); + let last = modifier.replace(parameters.last()?, ""); + type_re + .captures(&last) + .and_then(|caps| caps.get(1)) + .map(|written| written.as_str().to_string()) +} + +/// The right side of a Kotlin typealias declaration's first line +/// (`Scope.(Int) -> T` of `typealias Definition = Scope.(Int) -> T`). +pub(crate) fn alias_rhs( + facts: &crate::source_facts::SourceFacts, + declaration: &Node, +) -> Option { + static RHS: OnceLock = OnceLock::new(); + let index = usize::try_from(declaration.start_line) + .ok() + .and_then(|line| line.checked_sub(1)) + .filter(|index| *index < facts.line_count())?; + RHS.get_or_init(|| Regex::new(r"=\s*(.+)$").expect("kotlin alias rhs")) + .captures(facts.line(index)) + .map(|caps| caps[1].to_string()) +} + +/// The receiver a lambda passed to the project's `name` runs with (upstream +/// #2206 `kotlinLambdaReceiver`): the type before `.(` in its last +/// parameter's function type, directly or through a typealias. `None` +/// unless every Kotlin function or method of that name agrees. +fn lambda_receiver(name: &str, context: &dyn ResolutionContext) -> Option { + let written: Vec = context + .get_nodes_by_name_shared(name) + .iter() + .filter(|function| { + function.language == Language::Kotlin + && matches!(function.kind, NodeKind::Function | NodeKind::Method) + }) + .filter_map(|function| { + lambda_parameter_type(&*context.source_facts(&function.file_path)?, function) + }) + .collect(); + lambda_receiver_from(written.iter().map(String::as_str), &|alias| { + alias_rhs_named(alias, context) + }) +} + +/// The first right side, in path order, of a Kotlin typealias named +/// `alias` (upstream reads them in index order; KEEP-RUST). +fn alias_rhs_named(alias: &str, context: &dyn ResolutionContext) -> Option { + let mut declarations: Vec> = context + .get_nodes_by_name_shared(alias) + .into_iter() + .filter(|node| node.language == Language::Kotlin && node.kind == NodeKind::TypeAlias) + .collect(); + declarations.sort_by(|a, b| (&a.file_path, a.start_line).cmp(&(&b.file_path, b.start_line))); + declarations.iter().find_map(|declaration| { + alias_rhs(&*context.source_facts(&declaration.file_path)?, declaration) + }) +} + +/// The one receiver the parameter types `written` name, through +/// `alias_rhs` (a typealias name's right side), or `None` when they name +/// none or disagree. +pub fn lambda_receiver_from<'w>( + written: impl IntoIterator, + alias_rhs: &dyn Fn(&str) -> Option, +) -> Option { + let found: BTreeSet = written + .into_iter() + .filter_map(|written| receiver_of(written, 0, alias_rhs)) + .collect(); + if found.len() == 1 { + found.into_iter().next() + } else { + None + } +} + +/// The receiver a Kotlin function type names (`Scope` of `Scope.(Int) -> +/// T`), through up to two typealiases (upstream `receiverOf`). +fn receiver_of( + written: &str, + depth: usize, + alias_rhs: &dyn Fn(&str) -> Option, +) -> Option { + static DIRECT: OnceLock = OnceLock::new(); + static ALIAS: OnceLock = OnceLock::new(); + let direct = DIRECT.get_or_init(|| { + Regex::new( + r"^\s*(?:suspend\s+)?([A-Z][A-Za-z0-9_]*)(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)?\s*\.\s*\(", + ) + .expect("kotlin direct receiver") + }); + if let Some(caps) = direct.captures(written) { + return Some(caps[1].to_string()); + } + let alias = ALIAS + .get_or_init(|| Regex::new(r"^\s*([A-Z][A-Za-z0-9_]*)(?-u:\b)").expect("kotlin alias")) + .captures(written)? + .get(1)? + .as_str() + .to_string(); + if depth > 2 { + return None; + } + receiver_of(&alias_rhs(&alias)?, depth + 1, alias_rhs) +} + +/// The Kotlin types a bare call is written inside and what they inherit +/// (upstream #2172 `kotlinHierarchyAt`): the classes and objects around it, +/// the receiver of the extension function it is in, a Gradle script's +/// `Project` or `Settings`, and the brace frames around it; through +/// platform ancestry and the heads of their Kotlin declarations, at most +/// [`HIERARCHY_LIMIT`] names. +fn hierarchy_at(reference: &RefView, context: &dyn ResolutionContext) -> HashSet { + static OWNER_SPLIT: OnceLock = OnceLock::new(); + let owner_split = OWNER_SPLIT.get_or_init(|| Regex::new(r"::|\.").expect("owner split")); + let mut queue: VecDeque = VecDeque::new(); + for node in context + .get_nodes_in_file_shared(&reference.file_path) + .iter() + { + if node.start_line > reference.line || node.end_line < reference.line { + continue; + } + if is_member_class_kind(node.kind) { + queue.push_back(node.name.clone()); + } else if matches!(node.kind, NodeKind::Method | NodeKind::Function) + && let Some((owner, _)) = node.qualified_name.rsplit_once("::") + { + // `fun Foo.bar() { baz() }`: Foo is the implicit receiver. + if let Some(last) = owner_split.split(owner).last() { + queue.push_back(last.to_string()); + } + } + } + // A Gradle build script runs on the Project, a settings script on + // Settings. + if reference.file_path.ends_with(".gradle.kts") { + let settings = reference.file_path == "settings.gradle.kts" + || reference.file_path.ends_with("/settings.gradle.kts"); + queue.push_back(if settings { "Settings" } else { "Project" }.to_string()); + } + if let Some(facts) = context.source_facts(&reference.file_path) { + for frame in facts + .kotlin_brace_frames(|source| brace_frames(source, context)) + .iter() + { + if frame.start <= reference.line && frame.end >= reference.line { + queue.extend(frame.names.iter().cloned()); + } + } + } + let mut names = HashSet::new(); + while names.len() < HIERARCHY_LIMIT { + let Some(name) = queue.pop_front() else { + break; + }; + if names.contains(&name) { + continue; + } + queue.extend(platform_supers(&name).iter().map(|s| (*s).to_string())); + queue.extend(kotlin_declared_supertypes(&name, context)); + names.insert(name); + } + names +} + +/// A method's owner as a bare call reads it: the last segment of its +/// qualified name's parent, the type around a companion object for one of its +/// members; `None` without a parent. +fn member_owner(qualified_name: &str) -> Option<(String, bool)> { + static SPLIT: OnceLock = OnceLock::new(); + let split = SPLIT.get_or_init(|| Regex::new(r"::|\.").expect("owner split")); + let (parent, _) = qualified_name.rsplit_once("::")?; + if parent.is_empty() { + return None; + } + let mut path: Vec<&str> = split.split(parent).collect(); + let mut owner = path.pop()?.to_string(); + let companion = owner == "Companion" && !path.is_empty(); + if companion { + owner = path.pop()?.to_string(); + } + Some((owner, companion)) +} + +/// A bare Kotlin call's site, read once for every candidate. +pub(crate) struct KotlinSite<'r> { + reference: &'r RefView, + hierarchy: OnceCell>, + precondition: OnceCell, +} + +impl<'r> KotlinSite<'r> { + pub(crate) fn new(reference: &'r RefView) -> Self { + Self { + reference, + hierarchy: OnceCell::new(), + precondition: OnceCell::new(), + } + } + + fn hierarchy(&self, context: &dyn ResolutionContext) -> &HashSet { + self.hierarchy + .get_or_init(|| hierarchy_at(self.reference, context)) + } + + /// Whether the bare call can reach `candidate` (upstream #2172 + /// `isKotlinMemberReachable`, with #2232's Gradle script rule): a member + /// of a type around the call or of what it inherits, of a type the + /// project's function types take as a receiver, or of an object the file + /// imports. koin's `error("…")`, Kotlin's, went to a Logger's `error`. + pub(crate) fn reaches(&self, candidate: &Node, context: &dyn ResolutionContext) -> bool { + let reference = self.reference; + // A Gradle script's bare calls run on the build tool's own types: + // never a member of a class the project declares (nowinandroid's + // `Graph.plugins()`); build logic's extensions on Gradle's types stay. + if reference.file_path.ends_with(".kts") { + if !matches!( + candidate.kind, + NodeKind::Method | NodeKind::Field | NodeKind::Property + ) { + return true; + } + let Some((owner, _)) = member_owner(&candidate.qualified_name) else { + return false; + }; + return !context + .get_nodes_by_name_shared(&owner) + .iter() + .any(|node| is_method_owner_kind(node) || node.kind == NodeKind::Module); + } + // A Java class's method, too: Kotlin calls it bare only from a + // subclass or through a static import. + if candidate.kind != NodeKind::Method + || !matches!(candidate.language, Language::Kotlin | Language::Java) + { + return true; + } + // `require(n >= 0) { … }`, `check(!closed)`: Kotlin's preconditions. + if *self + .precondition + .get_or_init(|| is_kotlin_precondition_call(reference, context)) + { + return false; + } + let Some((owner, companion)) = member_owner(&candidate.qualified_name) else { + return true; + }; + // A (Java) constructor is the type's, called by its name. + if candidate.name == owner { + return true; + } + let hierarchy = self.hierarchy(context); + if hierarchy.contains("*") + || hierarchy.contains(&owner) + || context + .kotlin_tables() + .receiver_types(context) + .contains(&owner) + { + return true; + } + // `import okio.TestUtil.deepCopy` / `import okio.TestUtil.*` names an + // object's members. + let package = file_scope(&candidate.file_path, context).package.clone(); + let mut object_path = if package.is_empty() { + owner + } else { + format!("{package}.{owner}") + }; + if companion { + object_path.push_str(".Companion"); + } + let here = file_scope(&reference.file_path, context); + here.imports + .contains(&format!("{object_path}.{}", candidate.name)) + || here.stars.contains(&object_path) + } + + /// Of the members a bare call can reach, those the code around it + /// reaches (its class, an extension's receiver, the lambda it is in) + /// before those only the project-wide receiver set reaches (upstream + /// #2206 `lexicalKotlinMembers`): koin's `get()` in `Scope.new(…)` is + /// Scope's, not Koin's. + pub(crate) fn lexical( + &self, + candidates: Vec>, + context: &dyn ResolutionContext, + ) -> Vec> { + if candidates.len() < 2 { + return candidates; + } + let hierarchy = self.hierarchy(context); + if hierarchy.contains("*") { + return candidates; + } + let lexical: Vec> = candidates + .iter() + .filter(|node| { + node.kind == NodeKind::Method + && member_owner(&node.qualified_name) + .is_some_and(|(owner, _)| hierarchy.contains(&owner)) + }) + .cloned() + .collect(); + if lexical.is_empty() { + candidates + } else { + lexical + } + } +} + +/// `text`'s receiver as upstream's `rustGoReceiverName` reads it: the +/// expression before a trailing `.`, from the right, skipping balanced +/// brackets; empty when it cannot tell. +pub(crate) fn receiver_name(text: &str) -> String { + static TRAILING_DOT: OnceLock = OnceLock::new(); + static LITERAL: OnceLock = OnceLock::new(); + let trailing = TRAILING_DOT.get_or_init(|| Regex::new(r"\s*\.\s*$").expect("trailing dot")); + let literal = LITERAL + .get_or_init(|| Regex::new(r"^\(\s*&?\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\{").expect("literal")); + let src: Vec = trailing.replace(text, "").chars().collect(); + let mut out: Vec = Vec::new(); + let mut i = src.len() as isize - 1; + while i >= 0 { + let c = src[i as usize]; + if matches!(c, ')' | ']' | '}') { + let open = match c { + ')' => '(', + ']' => '[', + _ => '{', + }; + let mut depth = 0usize; + let mut j = i; + while j >= 0 { + let at = src[j as usize]; + if at == c { + depth += 1; + } else if at == open { + depth -= 1; + if depth == 0 { + break; + } + } + j -= 1; + } + if j < 0 { + return String::new(); + } + // `(JSON{data})`: a parenthesized composite literal names its + // type. + if c == ')' && out.is_empty() { + let slice: String = src[j as usize..=i as usize].iter().collect(); + if let Some(caps) = literal.captures(&slice) { + return caps[1].to_string(); + } + } + i = j - 1; + } else if is_word_char(c) || matches!(c, '$' | '.' | ':' | '!') { + if c != '!' { + out.insert(0, c); + } + i -= 1; + } else { + break; + } + } + let joined: String = out.into_iter().collect(); + joined + .trim_start_matches(['.', ':']) + .trim_end_matches(['.', ':']) + .to_string() +} + +/// The receiver a Kotlin chain link `….name(` / `?.name {` is written on, or +/// `None` for a call with none (upstream #2168 `kotlinChainReceiver`, with +/// #2174's infix branch). +fn chain_receiver(reference: &RefView, context: &dyn ResolutionContext) -> Option { + static SAFE_CALL: OnceLock = OnceLock::new(); + static BANG: OnceLock = OnceLock::new(); + let line = raw_line(reference, context)?; + let Some(start) = name_start(reference, &line, &['(', '{'], false) else { + // An infix call on an expression, `alias(libs.x) apply false`, whose + // receiver the extractor could not name. + return infix_start(&line, &reference.reference_name) + .map(|at| receiver_name(line[..at].trim_end())); + }; + let before = &line[..start]; + if !before.trim_end().ends_with('.') { + return None; + } + let before = SAFE_CALL + .get_or_init(|| Regex::new(r"\?\s*\.\s*$").expect("safe call")) + .replace(before, ""); + let before = BANG + .get_or_init(|| Regex::new(r"!!\s*$").expect("not-null")) + .replace(&before, ""); + Some(receiver_name(&before)) +} + +/// Where `name` is written as an infix function on `line` (upstream #2174, +/// `(?<=[\w)\]"'}]\s+)name\s+(?=[^\s=])`): the first occurrence between +/// blanks, after a word character, a closing bracket or a quote, and before +/// something other than `=`. +fn infix_start(line: &str, name: &str) -> Option { + if name.is_empty() { + return None; + } + line.match_indices(name).map(|(at, _)| at).find(|&at| { + let before = line[..at].trim_end(); + let after = &line[at + name.len()..]; + before.len() < at + && before + .chars() + .next_back() + .is_some_and(|c| is_word_char(c) || matches!(c, ')' | ']' | '"' | '\'' | '}')) + && after.trim_start().len() < after.len() + && after.trim_start().chars().next().is_some_and(|c| c != '=') + }) +} + +/// Kotlin's bitwise infix functions and the number types that declare them +/// (upstream #2174 `KOTLIN_BITWISE_INFIX`, `KOTLIN_NUMBER_TYPES`). +const KOTLIN_BITWISE_INFIX: [&str; 7] = ["and", "or", "xor", "shl", "shr", "ushr", "inv"]; +const KOTLIN_NUMBER_TYPES: [&str; 9] = [ + "Byte", "Short", "Int", "Long", "UByte", "UShort", "UInt", "ULong", "Char", +]; + +/// Whether `candidate` is a project's bitwise extension on a number type, +/// okio's `infix fun Byte.and(mask: Int)`, for a Kotlin reference (upstream +/// #2174 `isKotlinNumberBitwise`). Without the operand's type it cannot be +/// told from the standard library's own `Int.and` / `Long.shr` members every +/// other `x and 0xff` / `h shr 8` calls, so it is never a safe edge. +pub(crate) fn is_kotlin_number_bitwise(candidate: &Node, reference: &RefView) -> bool { + static OWNER_SPLIT: OnceLock = OnceLock::new(); + if reference.language != Language::Kotlin + || !KOTLIN_BITWISE_INFIX.contains(&candidate.name.as_str()) + { + return false; + } + let owner_split = OWNER_SPLIT.get_or_init(|| Regex::new(r"::|\.").expect("owner split")); + candidate + .qualified_name + .rsplit_once("::") + .filter(|(owner, _)| !owner.is_empty()) + .and_then(|(owner, _)| owner_split.split(owner).last()) + .is_some_and(|owner| KOTLIN_NUMBER_TYPES.contains(&owner)) +} + +/// The receiver of a Kotlin `calls` reference named like a standard-library +/// method and written as a chain link, or `None` (upstream `kotlinStdChain`). +pub(crate) fn std_chain_receiver( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + (reference.language == Language::Kotlin + && reference.reference_kind == EdgeKind::Calls + && KOTLIN_STD_METHODS.contains(&reference.reference_name.as_str())) + .then(|| chain_receiver(reference, context)) + .flatten() +} + +/// Whether a standard-named Kotlin chain link can mean `candidate`: only +/// through a receiver named after its owner (upstream +/// `isKotlinStdChainTarget`). +pub(crate) fn is_std_chain_target(candidate: &Node, receiver: &str) -> bool { + if !matches!(candidate.kind, NodeKind::Method | NodeKind::Function) { + return true; + } + receiver == "this" || (!receiver.is_empty() && shares_receiver_word(receiver, candidate)) +} + +/// Whether `name` is one of [`KOTLIN_STD_METHODS`]. +pub(crate) fn is_kotlin_std_method(name: &str) -> bool { + KOTLIN_STD_METHODS.contains(&name) +} + +/// Upstream `sharesReceiverWord`: the receiver's last link names the owner +/// (letters and digits only, a trailing number dropped, any case), or one of +/// its words is one of the owner's words longer than two letters. +pub(crate) fn shares_receiver_word(receiver: &str, method: &Node) -> bool { + static SPLIT: OnceLock = OnceLock::new(); + let split = SPLIT.get_or_init(|| Regex::new(r"::|\.").expect("owner split")); + let Some((owner_qualified, _)) = method.qualified_name.rsplit_once("::") else { + return false; + }; + let flat = |word: &str| { + let letters: String = word.chars().filter(char::is_ascii_alphanumeric).collect(); + letters + .trim_end_matches(|c: char| c.is_ascii_digit()) + .to_ascii_lowercase() + }; + let receiver_last = receiver.rsplit('.').next().unwrap_or(receiver); + let owner_last = split + .split(owner_qualified) + .last() + .unwrap_or(owner_qualified); + if flat(receiver_last) == flat(owner_last) { + return true; + } + let owner: HashSet = crate::name_matcher::split_camel_case(owner_qualified) + .iter() + .map(|word| flat(word)) + .filter(|word| word.len() > 2) + .collect(); + crate::name_matcher::split_camel_case(receiver) + .iter() + .any(|word| owner.contains(&flat(word))) +} + +/// Key prefix of the Kotlin surface rows (W4-JVM-03): one +/// `project_metadata` row per Kotlin file, so that a sync can tell which of +/// the facts other files' bare calls read a change moved once the old text +/// is gone. +pub const SURFACE_ROW_KEY_PREFIX: &str = "kotlin_surface:"; + +/// The `project_metadata` key of `file_path`'s surface row. +pub fn surface_row_key(file_path: &str) -> String { + format!("{SURFACE_ROW_KEY_PREFIX}{file_path}") +} + +/// A Kotlin type declaration's head, as the hierarchy walk reads it. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct KotlinHead { + pub name: String, + pub qualified_name: String, + pub supertypes: Vec, +} + +/// A Kotlin function or method declaration's last parameter type. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct LambdaParameter { + pub name: String, + pub written: String, +} + +/// A Kotlin typealias's right side. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct AliasRhs { + pub name: String, + pub rhs: String, +} + +/// What other files' Kotlin resolution reads of one Kotlin file, by the +/// readers resolution uses: its type heads, the receiver types it mentions, +/// its functions' last parameter types, its typealiases (each list in the +/// order of the file), the names its hierarchy walks start from and the +/// calls its lambdas are passed to (each sorted). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct KotlinSurface { + pub heads: Vec, + pub function_types: Vec, + pub extensions: Vec, + pub lambdas: Vec, + pub aliases: Vec, + pub starts: Vec, + pub lambda_calls: Vec, +} + +impl KotlinSurface { + /// The row of the Kotlin file `path` whose text is `source` and whose + /// nodes are `nodes`. + pub fn of(path: &str, source: &str, nodes: &[Node]) -> Self { + static SPLIT: OnceLock = OnceLock::new(); + let split = SPLIT.get_or_init(|| Regex::new(r"::|\.").expect("owner split")); + let facts = crate::source_facts::SourceFacts::new(Arc::from(source)); + let mut ordered: Vec<&Node> = nodes.iter().collect(); + ordered.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + let mut surface = Self::default(); + let mut starts: BTreeSet = BTreeSet::new(); + for node in ordered { + if is_member_class_kind(node.kind) { + starts.insert(node.name.clone()); + } else if matches!(node.kind, NodeKind::Method | NodeKind::Function) + && let Some((owner, _)) = node.qualified_name.rsplit_once("::") + && let Some(last) = split.split(owner).last() + { + starts.insert(last.to_string()); + } + if node.language != Language::Kotlin { + continue; + } + if is_member_class_kind(node.kind) { + let supertypes = crate::class_heads::class_head_supertypes_in(&facts, node); + if !supertypes.is_empty() { + surface.heads.push(KotlinHead { + name: node.name.clone(), + qualified_name: node.qualified_name.clone(), + supertypes, + }); + } + } else if matches!(node.kind, NodeKind::Function | NodeKind::Method) { + if let Some(written) = lambda_parameter_type(&facts, node) { + surface.lambdas.push(LambdaParameter { + name: node.name.clone(), + written, + }); + } + } else if node.kind == NodeKind::TypeAlias + && let Some(rhs) = alias_rhs(&facts, node) + { + surface.aliases.push(AliasRhs { + name: node.name.clone(), + rhs, + }); + } + } + let mentions = ReceiverMentions::read(source); + surface.function_types = mentions.function_types; + surface.extensions = mentions.extensions; + let mut lambda_calls: BTreeSet = BTreeSet::new(); + for brace in scan_braces(source) { + match brace.head { + BraceHead::Scoped => {} + BraceHead::Names(names) => starts.extend(names), + BraceHead::Lambda(call) => { + lambda_calls.insert(call); + } + } + } + if path.ends_with(".gradle.kts") { + let settings = path == "settings.gradle.kts" || path.ends_with("/settings.gradle.kts"); + starts.insert(if settings { "Settings" } else { "Project" }.to_string()); + } + surface.starts = starts.into_iter().collect(); + surface.lambda_calls = lambda_calls.into_iter().collect(); + surface + } + + /// No fact: no row records it. + pub fn is_empty(&self) -> bool { + self.heads.is_empty() + && self.function_types.is_empty() + && self.extensions.is_empty() + && self.lambdas.is_empty() + && self.aliases.is_empty() + && self.starts.is_empty() + && self.lambda_calls.is_empty() + } + + pub fn mentions(&self) -> ReceiverMentions { + ReceiverMentions { + function_types: self.function_types.clone(), + extensions: self.extensions.clone(), + } + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("kotlin surface row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +/// The names Android's platform ancestry table maps to `name`: the types +/// whose walk reaches it through the table alone. +pub fn platform_subtypes(name: &str) -> Vec<&'static str> { + const ROOTS: [&str; 14] = [ + "AppCompatActivity", + "FragmentActivity", + "ComponentActivity", + "Activity", + "ContextThemeWrapper", + "ContextWrapper", + "Application", + "Service", + "ComponentCallbacks2", + "Fragment", + "DialogFragment", + "AppCompatDialogFragment", + "BottomSheetDialogFragment", + "AndroidViewModel", + ]; + ROOTS + .into_iter() + .filter(|root| platform_supers(root).contains(&name)) + .collect() +} + +/// The owner a bare call reads for a member whose qualified name is +/// `qualified_name` (a companion's members by the type around it). +pub fn member_owner_name(qualified_name: &str) -> Option { + member_owner(qualified_name).map(|(owner, _)| owner) +} + +/// Whether `node` is a Kotlin / Java class-like declaration an extension +/// receiver's outside test reads (upstream `MEMBER_CLASS_KINDS`). +pub fn is_receiver_class(node: &Node) -> bool { + is_member_class_kind(node.kind) && matches!(node.language, Language::Kotlin | Language::Java) +} + +/// Whether `node` makes its name a project class for a Gradle script's bare +/// call (upstream #2232: `isMethodOwnerKind` or a module). +pub fn is_gradle_owner(node: &Node) -> bool { + is_method_owner_kind(node) || node.kind == NodeKind::Module +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_scope_reads_package_imports_and_stars() { + let scope = KotlinFileScope::read( + "/* package nope */\npackage app.jdbc\n\nimport app.jdbc.tests.assertEquals\nimport `app`.r2dbc.tests.*\nimport okio.Buffer as Buf // a comment\n", + ); + assert_eq!(scope.package, "app.jdbc"); + assert!(scope.imports.contains("app.jdbc.tests.assertEquals")); + assert!(scope.imports.contains("okio.Buffer")); + assert!(scope.stars.contains("app.r2dbc.tests")); + } + + #[test] + fn head_names_read_declarations_and_extension_receivers() { + assert_eq!( + head_names("\nclass AppTest : Base(1), Runnable by impl"), + Some(vec![ + "AppTest".to_string(), + "Base".to_string(), + "Runnable".to_string() + ]) + ); + assert_eq!( + head_names("val table = object : Table() "), + Some(vec!["Table".to_string()]) + ); + assert_eq!( + head_names("fun Scope.new(x: T) "), + Some(vec!["Scope".to_string()]) + ); + assert_eq!(head_names("if (x) "), None); + assert_eq!(head_names("companion object "), Some(Vec::new())); + } + + #[test] + fn listed_capitalized_names_follow_the_lookahead() { + assert_eq!( + listed_capitalized_names(" Base, Runnable by x"), + ["Base", "Runnable"] + ); + assert_eq!(listed_capitalized_names(" fooBar,"), ["Bar"]); + // `where` is cut before this runs; on its own the bound is a name. + assert_eq!(listed_capitalized_names(" Base where T"), ["T"]); + // `byX` is no `by`; its capital starts a name, as in the regex. + assert_eq!(listed_capitalized_names(" Base byX, Y"), ["X", "Y"]); + } + + #[test] + fn receiver_mentions_read_function_types_and_outside_extensions() { + let mentions = ReceiverMentions::read( + "typealias Def = Scope.(Int) -> T\nfun withTables(statement: JdbcTransaction.() -> Unit) {}\nfun StringBuilder.padInt(x: Int) {}\nfun a.B.C.deep() {}\n// Hidden.() -> Unit\n", + ); + assert_eq!(mentions.function_types, ["Scope", "JdbcTransaction"]); + assert_eq!(mentions.extensions, ["StringBuilder"]); + } + + #[test] + fn receiver_names_read_from_the_right() { + assert_eq!(receiver_name(" list."), "list"); + assert_eq!(receiver_name("x = foo(bar)."), "foo"); + assert_eq!(receiver_name("a.b.c."), "a.b.c"); + assert_eq!(receiver_name("(JSON{data})."), "JSON"); + assert_eq!(receiver_name("x!!."), "x"); + } + + #[test] + fn expansion_stops_at_the_limit() { + let expanded = expand_receiver_types(vec!["A".to_string()], |name| { + if name == "A" { + vec!["B".to_string(), "C".to_string()] + } else { + Vec::new() + } + }); + assert_eq!(expanded.len(), 3); + } + + fn node(kind: NodeKind, name: &str, qualified: &str, line: i64) -> Node { + Node { + id: format!("{}:{qualified}", kind.as_str()), + kind, + name: name.to_string(), + qualified_name: qualified.to_string(), + file_path: "app/build.gradle.kts".to_string(), + language: Language::Kotlin, + start_line: line, + end_line: line, + start_column: 0, + end_column: 0, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + } + } + + #[test] + fn surface_rows_read_heads_mentions_lambdas_aliases_starts_and_calls() { + let source = "class Child : Base(), Runnable {\n fun go() { single { get() } }\n}\ntypealias Def = Scope.(Int) -> Unit\nfun single(definition: Def) {}\nfun Foo.ext() = 1\nval t = object : Table() { }\n"; + let nodes = vec![ + node(NodeKind::Class, "Child", "Child", 1), + node(NodeKind::Method, "go", "Child::go", 2), + node(NodeKind::TypeAlias, "Def", "Def", 4), + node(NodeKind::Function, "single", "single", 5), + node(NodeKind::Method, "ext", "Foo::ext", 6), + ]; + let surface = KotlinSurface::of("app/build.gradle.kts", source, &nodes); + assert_eq!( + surface.heads, + [KotlinHead { + name: "Child".to_string(), + qualified_name: "Child".to_string(), + supertypes: vec!["Base".to_string(), "Runnable".to_string()], + }] + ); + assert_eq!(surface.function_types, ["Scope"]); + assert_eq!(surface.extensions, ["Foo"]); + assert_eq!( + surface.lambdas, + [LambdaParameter { + name: "single".to_string(), + written: "Def".to_string(), + }] + ); + assert_eq!( + surface.aliases, + [AliasRhs { + name: "Def".to_string(), + rhs: "Scope.(Int) -> Unit".to_string(), + }] + ); + assert_eq!( + surface.starts, + ["Base", "Child", "Foo", "Project", "Runnable", "Table"] + ); + // `fun go() {` opens a block the call reader takes for a lambda + // passed to `go`, as upstream's does: `go` names no lambda receiver. + assert_eq!(surface.lambda_calls, ["go", "single"]); + assert_eq!(KotlinSurface::decode(&surface.encode()), Some(surface)); + } + + #[test] + fn std_method_table_is_upstreams() { + let unique: HashSet<&str> = KOTLIN_STD_METHODS.iter().copied().collect(); + assert_eq!(unique.len(), KOTLIN_STD_METHODS.len()); + assert_eq!(KOTLIN_STD_METHODS.len(), 105); + assert!(unique.contains("apply") && unique.contains("coerceIn")); + } +} diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index 2c04616e..9fb5f6ae 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -19,27 +19,72 @@ mod alias_binding; mod awaited; mod c_macro_visibility; +pub mod cfml_scope; +pub mod class_heads; pub mod context; +mod cpp_includers; +pub mod cpp_namespaces; +mod cpp_supertypes; +mod cpp_type_aliases; +mod csharp_extensions; +mod csharp_scopes; +pub mod csharp_types; +mod dart_annotations; +pub mod dart_chains; +pub mod dart_libraries; +mod dart_local_scope; +pub mod dart_members; +mod dart_prefixes; +pub mod dart_scope; +pub mod destructured_calls; +mod erlang_imports; pub mod framework; // the FrameworkResolver extension point pub mod frameworks; // concrete react/vue/nestjs FrameworkResolvers +mod go_calls; +pub mod go_packages; +mod go_scope; pub mod import_resolver; +pub mod java_scope; mod js_store; +mod jsx_tags; +pub mod kotlin_scope; pub mod lru_cache; +pub mod lua_scope; +pub mod member_types; mod member_value; +pub mod module_surface; pub mod name_matcher; +pub mod objc_scope; mod object_literal; +mod object_owners; +mod overloads; +pub mod package_manifests; pub mod path_aliases; pub mod pathutil; +pub mod php_scope; +pub mod python_globals; +mod python_values; +pub mod r_scope; pub mod resolver; +pub mod ruby_scope; mod rust_lexical; +pub mod rust_scope; +pub mod scala_scope; +pub mod sfc_scripts; +mod shopify_themes; pub mod snapshot_context; +pub mod solidity_scope; pub mod source_facts; pub mod strip_comments; +mod swift_scope; +pub mod swift_type_visibility; +mod type_parameters; pub mod types; pub mod workspace_packages; pub use context::StoreResolutionContext; pub use framework::FrameworkResolver; +pub use name_matcher::CASE_INSENSITIVE_LANGUAGES; pub use resolver::{ReferenceResolver, ResolutionObservation}; pub use snapshot_context::{EdgeAdjacency, SnapshotResolutionContext, build_edge_adjacency}; pub use types::{ diff --git a/crates/codegraph-resolve/src/lru_cache.rs b/crates/codegraph-resolve/src/lru_cache.rs index 48535408..d74aa4c7 100644 --- a/crates/codegraph-resolve/src/lru_cache.rs +++ b/crates/codegraph-resolve/src/lru_cache.rs @@ -2,12 +2,15 @@ //! //! Ports `upstream resolution/lru-cache.ts`. The upstream backs its cache //! with JavaScript's insertion-ordered `Map`; we replicate the same eviction -//! semantics with an `IndexMap`-free approach: a `HashMap` for O(1) lookup plus -//! a `VecDeque` recency queue. On `set`, when full, the least-recently-used key -//! (front of the queue) is evicted (`lru-cache.ts:46-57`); `get` refreshes -//! recency by moving the key to the back (`lru-cache.ts:29-40`). +//! semantics without an insertion-ordered map: a `HashMap` from each key to +//! its value and the stamp of its last use, plus a `BTreeMap` from stamp to +//! key, so a use and an eviction are O(log n) rather than a scan of a recency +//! queue (W5-08). On `set`, when full, the least-recently-used key (the +//! oldest stamp) is evicted (`lru-cache.ts:46-57`); `get` refreshes recency +//! by giving the key a new stamp (`lru-cache.ts:29-40`). -use std::collections::{HashMap, VecDeque}; +use std::borrow::Borrow; +use std::collections::{BTreeMap, HashMap}; use std::hash::Hash; /// A plain LRU cache bounded to `max` entries. @@ -15,8 +18,10 @@ use std::hash::Hash; /// Ports the upstream `LRUCache` class (`lru-cache.ts:14-62`). pub struct LruCache { max: usize, - store: HashMap, - order: VecDeque, + store: HashMap, + order: BTreeMap, + /// The stamp the next use takes. + next: u64, } impl LruCache @@ -36,7 +41,8 @@ where Self { max, store: HashMap::new(), - order: VecDeque::new(), + order: BTreeMap::new(), + next: 0, } } @@ -46,37 +52,47 @@ where } /// Whether `key` is present (`has`, `lru-cache.ts:42-44`). - pub fn has(&self, key: &K) -> bool { + pub fn has(&self, key: &Q) -> bool + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { self.store.contains_key(key) } - /// Get `key`, refreshing recency (`get`, `lru-cache.ts:29-40`). - pub fn get(&mut self, key: &K) -> Option { - if let Some(value) = self.store.get(key).cloned() { - self.touch(key); - Some(value) - } else { - None + /// Get `key`, refreshing recency (`get`, `lru-cache.ts:29-40`). A + /// borrowed key (`&str` for a `String` key) needs no allocation. + pub fn get(&mut self, key: &Q) -> Option + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + let stamp = self.next; + let (value, last) = self.store.get_mut(key)?; + let value = value.clone(); + let previous = std::mem::replace(last, stamp); + self.next += 1; + if let Some(key) = self.order.remove(&previous) { + self.order.insert(stamp, key); } + Some(value) } /// Insert/update `key`, evicting the oldest entry when full /// (`set`, `lru-cache.ts:46-57`). pub fn set(&mut self, key: K, value: V) { - if self.store.contains_key(&key) { - self.touch(&key); + let stamp = self.next; + self.next += 1; + if let Some((_, previous)) = self.store.get(&key) { + self.order.remove(previous); } else if self.store.len() >= self.max { - // Evict the oldest entry — front of the recency queue. - while let Some(oldest) = self.order.pop_front() { - if self.store.remove(&oldest).is_some() { - break; - } + // Evict the oldest entry: the smallest stamp. + if let Some((_, oldest)) = self.order.pop_first() { + self.store.remove(&oldest); } - self.order.push_back(key.clone()); - } else { - self.order.push_back(key.clone()); } - self.store.insert(key, value); + self.order.insert(stamp, key.clone()); + self.store.insert(key, (value, stamp)); } /// Drop all entries (`clear`, `lru-cache.ts:59-61`). @@ -84,14 +100,6 @@ where self.store.clear(); self.order.clear(); } - - /// Move `key` to the most-recently-used position. - fn touch(&mut self, key: &K) { - if let Some(pos) = self.order.iter().position(|k| k == key) { - self.order.remove(pos); - } - self.order.push_back(key.clone()); - } } #[cfg(test)] @@ -121,6 +129,59 @@ mod tests { assert_eq!(cache.size(), 1); } + /// W5-08: a `set` of a present key refreshes its recency as a `get` + /// does, so the untouched key goes first. + #[test] + fn set_of_a_present_key_refreshes_recency() { + let mut cache: LruCache<&str, i32> = LruCache::new(2); + cache.set("a", 1); + cache.set("b", 2); + cache.set("a", 3); + cache.set("c", 4); // evicts "b" + assert!(!cache.has(&"b")); + assert_eq!(cache.get(&"a"), Some(3)); + assert_eq!(cache.get(&"c"), Some(4)); + } + + /// W5-08: over many uses the evicted keys are always the least recently + /// used ones, as a recency queue gives them. + #[test] + fn evicts_in_least_recent_use_order() { + let mut cache: LruCache = LruCache::new(3); + let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); + let touch = |queue: &mut std::collections::VecDeque, key: u32| { + queue.retain(|k| *k != key); + queue.push_back(key); + }; + for step in 0..200u32 { + let key = (step * 7 + step / 3) % 8; + if step % 3 == 0 { + let hit = cache.get(&key); + assert_eq!(hit.is_some(), queue.contains(&key), "step {step}"); + if hit.is_some() { + touch(&mut queue, key); + } + } else { + if !queue.contains(&key) && queue.len() == 3 { + queue.pop_front(); + } + touch(&mut queue, key); + cache.set(key, step); + } + for key in 0..8 { + assert_eq!( + cache.has(&key), + queue.contains(&key), + "step {step} key {key}" + ); + } + } + cache.clear(); + assert_eq!(cache.size(), 0); + cache.set(1, 1); + assert_eq!(cache.get(&1), Some(1)); + } + #[test] #[should_panic(expected = "positive")] fn rejects_zero_capacity() { diff --git a/crates/codegraph-resolve/src/lua_scope.rs b/crates/codegraph-resolve/src/lua_scope.rs new file mode 100644 index 00000000..be2cc7d1 --- /dev/null +++ b/crates/codegraph-resolve/src/lua_scope.rs @@ -0,0 +1,1260 @@ +//! Lua calls through `local` aliases, Lua's own globals, and chunk-local +//! declarations (upstream #2143, #2188, #2202). +//! +//! Kong localizes everything it calls: `local splitn = +//! require("kong.tools.string").splitn`, `local check_phase = +//! phase_checker.check`, `local fmt = string.format`, `local type = type`. +//! A bare call through such an alias is followed to what the alias names: a +//! module's member (through what the module hands out under that name, or +//! re-exports from a module of its own, a few hops deep), a host table's +//! method, or nothing for the standard library. A `local` belongs to its +//! chunk, and a bare call reaches neither a table's method nor, for a Lua +//! global, a project namesake. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; + +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// Lua's global functions, and the test runner's: `local type = type` is the +/// standard library's `type` (upstream `LUA_GLOBAL_FUNCTIONS`). +pub(crate) const LUA_GLOBAL_FUNCTIONS: [&str; 44] = [ + "assert", + "error", + "ipairs", + "pairs", + "next", + "type", + "tostring", + "tonumber", + "setmetatable", + "getmetatable", + "rawget", + "rawset", + "rawequal", + "rawlen", + "select", + "pcall", + "xpcall", + "unpack", + "print", + "load", + "loadstring", + "loadfile", + "dofile", + "collectgarbage", + "require", + "setfenv", + "getfenv", + "newproxy", + "typeof", + "warn", + "tick", + "wait", + "describe", + "it", + "before_each", + "after_each", + "setup", + "teardown", + "lazy_setup", + "lazy_teardown", + "pending", + "finally", + "insulate", + "expose", +]; + +/// Lua's standard and host libraries (upstream `LUA_LIBRARY_TABLES`). +const LUA_LIBRARY_TABLES: [&str; 20] = [ + "string", + "table", + "math", + "io", + "os", + "coroutine", + "debug", + "utf8", + "package", + "bit", + "bit32", + "jit", + "ffi", + "vim", + "ngx", + "assert", + "spy", + "stub", + "mock", + "love", +]; + +/// How deep an alias is followed through modules' exports (upstream's 3). +const ALIAS_DEPTH: usize = 3; + +/// `\s` with JavaScript's whitespace, U+FEFF included. +const JS_SPACE: &str = r"[\s\x{FEFF}]"; + +/// What an alias names: no alias at all (`Plain`: the reference resolves +/// as before), nothing in the project (the standard library, an outside +/// module), or a project node. +#[derive(Debug, Clone)] +enum Alias { + Plain, + Nothing, + Target(Arc), +} + +/// Per-pass memo of module members: `(file, path, depth)` → what that +/// member of that module is. KEEP-RUST: keyed by depth too, so a member a +/// shallow lookup finds is never one a deep lookup gave up on, whichever +/// reference of a parallel pass asks first. +#[derive(Debug, Default)] +pub struct LuaTables { + members: Mutex>, + /// Each file's nodes in source order. + nodes: Mutex>>>>, + /// What each call site's alias names. + aliases: Mutex, + /// The project files each `require` path suffix names + /// ([`lua_require_matches`]). + require_matches: Mutex>>>, +} + +/// The project files a Lua `require` path suffix names, `suffix` itself or +/// one ending in `/suffix`, in the file list's order, read once per pass +/// (W5-09): every `require` of a module reads the same list. +pub(crate) fn lua_require_matches( + suffix: &str, + context: &dyn ResolutionContext, +) -> Arc> { + let tables = context.lua_tables(); + if let Some(hit) = tables + .require_matches + .lock() + .ok() + .and_then(|memo| memo.get(suffix).cloned()) + { + return hit; + } + let slash = format!("/{suffix}"); + let matches = Arc::new( + context + .get_all_files_shared() + .iter() + .filter(|file| file.as_str() == suffix || file.ends_with(&slash)) + .cloned() + .collect::>(), + ); + if let Ok(mut memo) = tables.require_matches.lock() { + memo.insert(suffix.to_string(), Arc::clone(&matches)); + } + matches +} + +/// `(file, line, name)` of a bare call → [`lua_alias_target`]'s answer, the +/// target's node id standing for the resolved reference. +type AliasMemo = HashMap<(String, i64, String), Option>>; + +/// `file`'s nodes in source order, read once per pass. +fn file_nodes(file: &str, context: &dyn ResolutionContext) -> Arc>> { + let tables = context.lua_tables(); + if let Some(hit) = tables + .nodes + .lock() + .ok() + .and_then(|memo| memo.get(file).cloned()) + { + return hit; + } + let nodes = Arc::new(in_order(context.get_nodes_in_file_shared(file))); + if let Ok(mut memo) = tables.nodes.lock() { + memo.insert(file.to_string(), Arc::clone(&nodes)); + } + nodes +} + +impl LuaTables { + pub fn new() -> Self { + Self::default() + } +} + +pub(crate) fn is_lua(language: Language) -> bool { + matches!(language, Language::Lua | Language::Luau) +} + +/// A Lua `calls` reference written as a bare name (`[A-Za-z_]\w*`). +pub(crate) fn is_lua_bare_call(reference: &RefView) -> bool { + is_lua(reference.language) + && reference.reference_kind == EdgeKind::Calls + && is_lua_name(&reference.reference_name) +} + +fn is_lua_name(name: &str) -> bool { + name.bytes() + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_') + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +/// `require "m"` (or a loader named for it, kong's +/// `reload_module("spec.internal.misc")`), then any `.member`s (upstream +/// `LUA_REQUIRE_ALIAS`; the quote back-reference spelled out). +fn require_alias() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(&format!( + r#"^(?:require|[A-Za-z_][0-9A-Za-z_]*(?:[Rr]equire|_module|[Ii]mport)){s}*\(?{s}*(?:"([^"']+)"|'([^"']+)'){s}*\)?((?:{s}*\.{s}*[A-Za-z_][0-9A-Za-z_]*)*){s}*$"#, + s = JS_SPACE + )) + .expect("lua require alias") + }) +} + +/// `name.member…` (upstream `LUA_NAME_ALIAS`). +fn name_alias() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(&format!( + r"^([A-Za-z_][0-9A-Za-z_]*)((?:{s}*\.{s}*[A-Za-z_][0-9A-Za-z_]*)*){s}*$", + s = JS_SPACE + )) + .expect("lua name alias") + }) +} + +/// The module and member path a `require` alias names. +fn require_parts(rhs: &str) -> Option<(String, Vec)> { + let caps = require_alias().captures(rhs)?; + let module = caps.get(1).or_else(|| caps.get(2))?.as_str().to_string(); + Some((module, members(caps.get(3).map_or("", |m| m.as_str())))) +} + +/// The `.`-separated members of a chain, trimmed (upstream `luaMembers`). +fn members(chain: &str) -> Vec { + chain + .split('.') + .map(|part| part.trim_matches(|c: char| c.is_whitespace() || c == '\u{FEFF}')) + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect() +} + +/// A declaration's initializer without its `=`. +fn initializer(decl: &Node) -> Option<&str> { + let signature = decl.signature.as_deref()?; + let trimmed = signature.trim_start_matches(|c: char| c.is_whitespace() || c == '\u{FEFF}'); + let rest = trimmed.strip_prefix('=').unwrap_or(trimmed); + Some(rest.trim_matches(|c: char| c.is_whitespace() || c == '\u{FEFF}')) +} + +/// `nodes` in source order (start line, column, id). +fn in_order(mut nodes: Vec>) -> Vec> { + nodes.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + nodes +} + +/// JavaScript's `\s`. +fn is_js_space(c: char) -> bool { + codegraph_core::generated_header::is_js_whitespace(c) +} + +/// The initializer of a `local name = …` line (upstream's +/// `^\s*local\s+name\s*=\s*(.+?)\s*$`, without compiling it per name). +fn local_line_initializer<'t>(text: &'t str, name: &str) -> Option<&'t str> { + let rest = text.trim_start_matches(is_js_space).strip_prefix("local")?; + if !rest.starts_with(is_js_space) { + return None; + } + let rest = rest.trim_start_matches(is_js_space).strip_prefix(name)?; + let rest = rest.trim_start_matches(is_js_space).strip_prefix('=')?; + let rhs = rest.trim_matches(is_js_space); + (!rhs.is_empty()).then_some(rhs) +} + +/// Whether a declaration line declares `local` (as +/// `name_matcher::is_lua_local` reads it). +pub(crate) fn is_local_line(text: &str) -> bool { + text.trim_start() + .strip_prefix("local") + .is_some_and(|rest| !rest.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')) +} + +/// [`lua_local_decl`] over a file's nodes in source order, its lines +/// (1-based) and its locality test. +fn local_decl_in( + name: &str, + line: i64, + nodes: &[Arc], + line_text: &dyn Fn(i64) -> Option, + is_local: &dyn Fn(&Node) -> bool, +) -> Option> { + let functions: Vec<&Arc> = nodes + .iter() + .filter(|node| matches!(node.kind, NodeKind::Function | NodeKind::Method)) + .collect(); + let mut best: Option<&Arc> = None; + for node in nodes { + if node.name != name + || node.kind != NodeKind::Variable + || node.start_line > line + || node.signature.is_none() + { + continue; + } + let held_elsewhere = functions.iter().any(|function| { + function.start_line <= node.start_line + && node.start_line <= function.end_line + && !(function.start_line <= line && line <= function.end_line) + }); + if held_elsewhere || !is_local(node) { + continue; + } + if best.is_none_or(|best| node.start_line > best.start_line) { + best = Some(node); + } + } + let around = functions + .iter() + .filter(|function| { + function.start_line <= line && line <= function.end_line && function.start_line < line + }) + .min_by_key(|function| (function.end_line - function.start_line, function.start_line)); + if let Some(function) = around + && best.is_none_or(|best| best.start_line < function.start_line) + { + for at in ((function.start_line + 1)..line).rev() { + let Some(text) = line_text(at) else { + continue; + }; + let Some(rhs) = local_line_initializer(text.trim_end_matches('\r'), name) else { + continue; + }; + let file_node = nodes.iter().find(|node| node.kind == NodeKind::File)?; + let mut alias = (**file_node).clone(); + alias.name = name.to_string(); + alias.kind = NodeKind::Variable; + alias.signature = Some(format!("= {rhs}")); + alias.start_line = at; + alias.end_line = at; + return Some(Arc::new(alias)); + } + } + best.cloned() +} + +/// The `local name = …` in scope at `line` of `file`: the nearest one above +/// it that no other function holds, or, inside a function, the nearest +/// `local name = …` line above the call within it (a function's own locals +/// are no nodes; upstream `luaLocalDecl`). +fn lua_local_decl( + name: &str, + file: &str, + line: i64, + context: &dyn ResolutionContext, +) -> Option> { + let nodes = file_nodes(file, context); + let facts = context.source_facts(file); + let line_text = |at: i64| -> Option { + let facts = facts.as_ref()?; + usize::try_from(at - 1) + .ok() + .and_then(|index| facts.raw_line(index)) + .map(str::to_string) + }; + local_decl_in(name, line, &nodes, &line_text, &|node| { + crate::name_matcher::is_lua_local(node, context) + }) +} + +/// Where an alias is written: its file, line and name, and a node there to +/// resolve `require`s from (upstream `LuaSite`). +struct LuaSite<'n> { + file: &'n str, + line: i64, + name: &'n str, + node: &'n Node, +} + +/// What an alias variable names (upstream `luaAliasOf`). +fn lua_alias_of(decl: &Node, context: &dyn ResolutionContext, depth: usize) -> Alias { + let Some(rhs) = initializer(decl) else { + return Alias::Plain; + }; + lua_alias_expr( + rhs, + &LuaSite { + file: &decl.file_path, + line: decl.start_line, + name: &decl.name, + node: decl, + }, + context, + depth, + ) +} + +/// What an alias's initializer names (upstream `luaAliasExpr`). +fn lua_alias_expr( + rhs: &str, + site: &LuaSite<'_>, + context: &dyn ResolutionContext, + depth: usize, +) -> Alias { + if let Some((module, path)) = require_parts(rhs) { + return lua_module_member(&module, &path, site.node, context, depth); + } + let Some(named) = name_alias().captures(rhs) else { + return Alias::Plain; + }; + let root = &named[1]; + let path = members(named.get(2).map_or("", |m| m.as_str())); + let root_decl = (root != site.name) + .then(|| lua_local_decl(root, site.file, site.line - 1, context)) + .flatten(); + if let Some(root_decl) = root_decl { + if let Some((module, mut module_path)) = initializer(&root_decl).and_then(require_parts) + && !path.is_empty() + { + module_path.extend(path); + return lua_module_member(&module, &module_path, &root_decl, context, depth); + } + return Alias::Plain; + } + if path.is_empty() { + return if LUA_GLOBAL_FUNCTIONS.contains(&root) { + Alias::Nothing + } else { + Alias::Plain + }; + } + if !LUA_LIBRARY_TABLES.contains(&root) { + // A member of a global table the host provides (kong's `local + // clear_header = kong.response.clear_header`) is the one method of a + // table named after its holder. + let member = path.last().expect("a non-empty path"); + let holder = if path.len() > 1 { + path[path.len() - 2].as_str() + } else { + root + }; + let owned: Vec> = context + .get_nodes_by_name_shared(member) + .into_iter() + .filter(|node| { + is_lua(node.language) + && node.kind == NodeKind::Method + && crate::kotlin_scope::shares_receiver_word(holder, node) + && !(codegraph_core::file_class::is_test_path(&node.file_path) + && !codegraph_core::file_class::is_test_path(site.file)) + }) + .collect(); + return match owned.as_slice() { + [one] => Alias::Target(Arc::clone(one)), + _ => Alias::Plain, + }; + } + if path.len() != 1 { + return Alias::Plain; + } + // A library function the project patches itself (kong's `ngx.sleep`) is + // the project's; a test's stand-in only for that test. + let patched: Vec> = context + .get_nodes_by_name_shared(&path[0]) + .into_iter() + .filter(|node| { + matches!(node.kind, NodeKind::Function | NodeKind::Method) + && node + .qualified_name + .split(['.', ':']) + .next() + .is_some_and(|first| first == root) + && (node.file_path == site.file + || !codegraph_core::file_class::is_test_path(&node.file_path)) + }) + .collect(); + match patched.as_slice() { + [one] => Alias::Target(Arc::clone(one)), + _ => Alias::Nothing, + } +} + +/// `path` of the module `require(name)` returns, from `decl`'s file +/// (upstream `luaModuleMember`). +fn lua_module_member( + name: &str, + path: &[String], + decl: &Node, + context: &dyn ResolutionContext, + depth: usize, +) -> Alias { + let Some(file) = lua_module_file(name, decl, context) else { + return Alias::Nothing; + }; + // `require "kong.conf_loader"` called directly: the module's value. + if path.is_empty() { + return Alias::Plain; + } + let tables = context.lua_tables(); + let key = (file.clone(), path.join("."), depth); + if let Some(hit) = tables + .members + .lock() + .ok() + .and_then(|memo| memo.get(&key).cloned()) + { + return hit; + } + let found = lua_member_in(&file, path, context, depth); + if let Ok(mut memo) = tables.members.lock() { + memo.insert(key, found.clone()); + } + found +} + +/// The project file `require(name)` loads from `decl`'s file, or `None` for +/// a module outside the project (upstream `luaModuleFile`). +pub(crate) fn lua_module_file( + name: &str, + decl: &Node, + context: &dyn ResolutionContext, +) -> Option { + let reference = RefView { + row_id: None, + from_node_id: decl.id.clone(), + reference_name: name.to_string(), + reference_kind: EdgeKind::Imports, + line: decl.start_line, + column: 0, + file_path: decl.file_path.clone(), + language: decl.language, + is_function_ref: false, + reference_subkind: None, + }; + let resolved = crate::import_resolver::resolve_lua_require(&reference, context)?; + context + .get_node_by_id_shared(&resolved.target_node_id) + .map(|node| node.file_path.clone()) +} + +/// `source` with Lua comments removed, the newlines of a block comment kept +/// (upstream's two replacements). +pub(crate) fn strip_lua_comments(source: &str) -> String { + let mut out = String::with_capacity(source.len()); + let mut rest = source; + while let Some(at) = rest.find("--") { + out.push_str(&rest[..at]); + let after = &rest[at + 2..]; + let level = after + .strip_prefix('[') + .map(|tail| tail.bytes().take_while(|&b| b == b'=').count()) + .filter(|&equals| after.as_bytes().get(1 + equals) == Some(&b'[')); + match level { + Some(equals) => { + let close = format!("]{}]", "=".repeat(equals)); + let body_start = 2 + equals; + match after[body_start..].find(&close) { + Some(end) => { + let comment = &rest[at..at + 2 + body_start + end + close.len()]; + out.extend(comment.chars().filter(|&c| c == '\n')); + rest = &rest[at + 2 + body_start + end + close.len()..]; + } + None => { + // An unclosed block comment: a line comment, as the + // second pattern reads it. + let end = rest[at..].find('\n').map_or(rest.len(), |end| at + end); + rest = &rest[end..]; + } + } + } + None => { + let end = rest[at..].find('\n').map_or(rest.len(), |end| at + end); + rest = &rest[end..]; + } + } + } + out.push_str(rest); + out +} + +/// `path`'s member in a module file (upstream `luaMemberIn`): its function +/// of that name, else, within the alias depth, what the module exports under +/// it (`return { check = check_phase }`, `_M.check = check_phase`), an alias +/// of its own, or a global variable of that name. +fn lua_member_in( + file: &str, + path: &[String], + context: &dyn ResolutionContext, + depth: usize, +) -> Alias { + let member = path.last().expect("a non-empty path").as_str(); + let nodes = file_nodes(file, context); + let in_file: Vec<&Arc> = nodes.iter().filter(|node| node.name == member).collect(); + let functions: Vec<&Arc> = in_file + .iter() + .copied() + .filter(|node| matches!(node.kind, NodeKind::Function | NodeKind::Method)) + .collect(); + if !functions.is_empty() { + let owner = (path.len() > 1).then(|| path[path.len() - 2].as_str()); + let chosen = owner + .and_then(|owner| { + functions + .iter() + .find(|node| node.qualified_name.ends_with(&format!("{owner}::{member}"))) + }) + .or_else(|| functions.iter().find(|node| node.kind == NodeKind::Method)) + .unwrap_or(&functions[0]); + return Alias::Target(Arc::clone(chosen)); + } + if depth >= ALIAS_DEPTH { + return Alias::Nothing; + } + let source = strip_lua_comments(&context.read_file(file).unwrap_or_default()); + if let Some((line, rhs)) = first_export(&source, member) + && rhs != member + { + let rhs = rhs.to_string(); + if is_lua_name(&rhs) + && let Some(function) = nodes.iter().find(|node| { + node.name == rhs && matches!(node.kind, NodeKind::Function | NodeKind::Method) + }) + { + return Alias::Target(Arc::clone(function)); + } + if let Some(at_node) = nodes + .iter() + .find(|node| node.kind == NodeKind::File) + .or_else(|| nodes.first()) + { + let next = lua_alias_expr( + &rhs, + &LuaSite { + file, + line, + name: member, + node: at_node, + }, + context, + depth + 1, + ); + if !matches!(next, Alias::Plain) { + return next; + } + } + } + // The module re-exports an alias of its own. + if let Some(alias) = in_file.iter().find(|node| { + node.kind == NodeKind::Variable + && node.signature.is_some() + && crate::name_matcher::is_lua_local(node, context) + }) { + let next = lua_alias_of(alias, context, depth + 1); + if !matches!(next, Alias::Plain) { + return next; + } + } + match in_file.iter().find(|node| { + node.kind == NodeKind::Variable && !crate::name_matcher::is_lua_local(node, context) + }) { + Some(global) => Alias::Target(Arc::clone(global)), + None => Alias::Nothing, + } +} + +/// A bare Lua call through a `local` alias, followed to what the alias names +/// (upstream `luaAliasTarget`): `None` when the call is not through such an +/// alias, `Some(None)` when the alias names nothing in the project. +pub(crate) fn lua_alias_target( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + let tables = context.lua_tables(); + let key = ( + reference.file_path.clone(), + reference.line, + reference.reference_name.clone(), + ); + let memo = tables + .aliases + .lock() + .ok() + .and_then(|memo| memo.get(&key).cloned()); + let target = match memo { + Some(hit) => hit, + None => { + let target = lua_local_decl( + &reference.reference_name, + &reference.file_path, + reference.line, + context, + ) + .and_then(|decl| match lua_alias_of(&decl, context, 0) { + Alias::Plain => None, + Alias::Nothing => Some(None), + Alias::Target(target) => Some(Some(target.id.clone())), + }); + if let Ok(mut memo) = tables.aliases.lock() { + memo.insert(key, target.clone()); + } + target + } + }; + target.map(|target| { + target.map(|target_node_id| ResolvedRef { + original: reference.clone(), + target_node_id, + confidence: 0.9, + resolved_by: ResolvedBy::Import, + }) + }) +} + +/// Where a dotted name (`[A-Za-z_]\w*` segments joined by `.`, whitespace +/// allowed around each `.`) starting at `start` can end, longest first. +fn dotted_ends(source: &str, start: usize) -> Vec { + let segment = |at: usize| -> usize { + let bytes = source.as_bytes(); + if !bytes + .get(at) + .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_') + { + return 0; + } + bytes[at..] + .iter() + .take_while(|b| b.is_ascii_alphanumeric() || **b == b'_') + .count() + }; + let mut ends = Vec::new(); + let first = segment(start); + if first == 0 { + return ends; + } + let mut end = start + first; + ends.push(end); + loop { + let after = source[end..].trim_start_matches(is_js_space); + let Some(dotted) = after.strip_prefix('.') else { + break; + }; + let next = source.len() - dotted.trim_start_matches(is_js_space).len(); + let length = segment(next); + if length == 0 { + break; + } + end = next + length; + ends.push(end); + } + ends.reverse(); + ends +} + +/// Whether upstream's tail `\s*(?:[,;}]|$)` (multiline) matches at the +/// start of `after`: a line terminator within the whitespace, the end of +/// the text, or `,`, `;` or `}` after it. +fn export_tail(after: &str) -> bool { + for c in after.chars() { + if matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') { + return true; + } + if !is_js_space(c) { + return matches!(c, ',' | ';' | '}'); + } + } + true +} + +/// The assignment `member = a.b` the pattern of upstream's `luaMemberIn` +/// matches at `at`, the start of `member` in `source`: its right side as +/// written. +fn export_at(source: &str, at: usize, member: &str) -> Option { + let boundary = at == 0 + || source[..at] + .chars() + .next_back() + .is_some_and(|c| is_js_space(c) || matches!(c, '{' | ',' | '.')); + if !boundary || !source[at..].starts_with(member) { + return None; + } + let after = source[at + member.len()..].trim_start_matches(is_js_space); + let after = after.strip_prefix('=')?.trim_start_matches(is_js_space); + let start = source.len() - after.len(); + dotted_ends(source, start) + .into_iter() + .find(|&end| export_tail(&source[end..])) + .map(|end| end - start) +} + +/// The line number upstream's `luaMemberIn` reads for a match at `at` (the +/// member's start): the match begins at the boundary character before it, +/// or at the text's start. +fn export_line(source: &str, at: usize) -> i64 { + let start = source[..at] + .char_indices() + .next_back() + .map_or(0, |(index, _)| index); + i64::try_from(source[..start].matches('\n').count()).unwrap_or(i64::MAX - 2) + 2 +} + +/// The first export of `member` in a comment-free module source (upstream +/// `luaMemberIn`'s `member\s*=\s*dotted` pattern, scanned instead of +/// compiled per member): the line upstream reads and the right side. +fn first_export<'s>(source: &'s str, member: &str) -> Option<(i64, &'s str)> { + source.match_indices(member).find_map(|(at, _)| { + export_rhs(source, at, member).map(|rhs| (export_line(source, at), rhs)) + }) +} + +/// The right side of the export of `member` at `at`, if one is there. +fn export_rhs<'s>(source: &'s str, at: usize, member: &str) -> Option<&'s str> { + let length = export_at(source, at, member)?; + let start = source.len() + - source[at + member.len()..] + .trim_start_matches(is_js_space) + .strip_prefix('=')? + .trim_start_matches(is_js_space) + .len(); + Some(&source[start..start + length]) +} + +/// Every first export of a comment-free module source, by member. +fn all_exports(source: &str) -> std::collections::BTreeMap { + let mut out = std::collections::BTreeMap::new(); + let bytes = source.as_bytes(); + let mut at = 0; + while at < bytes.len() { + let starts_name = (bytes[at].is_ascii_alphabetic() || bytes[at] == b'_') + && (at == 0 || !(bytes[at - 1].is_ascii_alphanumeric() || bytes[at - 1] == b'_')); + if !starts_name { + at += 1; + continue; + } + let length = bytes[at..] + .iter() + .take_while(|b| b.is_ascii_alphanumeric() || **b == b'_') + .count(); + let member = &source[at..at + length]; + if !out.contains_key(member) + && let Some(rhs) = export_rhs(source, at, member) + { + out.insert( + member.to_string(), + (export_line(source, at), rhs.to_string()), + ); + } + at += length; + } + out +} + +/// Key prefix of the Lua module rows (W4-LUA-01): one `project_metadata` +/// row per Lua file, written by every full index and migration and kept +/// current by sync. +pub const MODULE_ROW_KEY_PREFIX: &str = "lua_module:"; + +/// The `project_metadata` key of `file_path`'s Lua module row. +pub fn module_row_key(file_path: &str) -> String { + format!("{MODULE_ROW_KEY_PREFIX}{file_path}") +} + +/// A declaration a row records as `local`: its name, kind and line. +pub type LocalDecl = (String, String, i64); + +/// What another file's Lua resolution reads of one file: its `local` +/// declarations (a `local` is its chunk's alone, upstream #2143), the +/// modules its `local` aliases `require` (whose members they name, #2188), +/// the names its aliases and exports can read through a global table, and a +/// digest of what an alias through this module reads of it (its functions, +/// its variables and the first export of each name). +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LuaModuleRow { + /// Every `local` declaration, in source order, by name, kind and line: of + /// two namesakes, the one another file's name matches first can change + /// locality alone. + pub locals: Vec, + /// The modules the file's aliases `require`: read from each variable's + /// initializer as the resolver reads it (with a trailing comment, over + /// lines, as one target of several), and from every `local … = …` line + /// (a function's own locals are no nodes). + pub reads: Vec, + /// The last member of every dotted chain written after an `=` (`local + /// snooze = ngx.sleep`, `exit = kong.response.exit,`), each shorter + /// chain's too: an alias of a library or host table's member names the + /// one project node of that name it finds (upstream's library and + /// host-table rules), so it reads every node of the name. + pub globals: Vec, + pub surface: String, +} + +/// [`LuaModuleRow::globals`] of a source and its comment-free text. +fn global_members(sources: [&str; 2]) -> Vec { + let mut out = std::collections::BTreeSet::new(); + for source in sources { + for (at, _) in source.match_indices('=') { + let start = source.len() - source[at + 1..].trim_start_matches(is_js_space).len(); + for end in dotted_ends(source, start) { + if let Some((_, last)) = source[start..end].rsplit_once('.') { + out.insert(last.trim_start_matches(is_js_space).to_string()); + } + } + } + } + out.into_iter().collect() +} + +impl LuaModuleRow { + /// The row of a Lua file whose source is `source` and whose nodes are + /// `nodes`. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let lines: Vec<&str> = source.split('\n').collect(); + let line_text = |at: i64| -> Option { + usize::try_from(at - 1) + .ok() + .and_then(|index| lines.get(index)) + .map(|text| (*text).to_string()) + }; + let is_local = |node: &Node| { + matches!( + node.kind, + NodeKind::Variable | NodeKind::Constant | NodeKind::Function + ) && line_text(node.start_line) + .is_some_and(|text| is_local_line(text.trim_end_matches('\r'))) + }; + let shared = in_order(nodes.iter().cloned().map(Arc::new).collect()); + let locals: Vec = shared + .iter() + .filter(|node| is_local(node)) + .map(|node| { + ( + node.name.clone(), + node.kind.as_str().to_string(), + node.start_line, + ) + }) + .collect(); + let mut reads: Vec = shared + .iter() + .filter(|node| node.kind == NodeKind::Variable) + .filter_map(|node| initializer(node).and_then(require_parts)) + .map(|(module, _)| module) + .chain(lines.iter().filter_map(|text| { + let rest = text.trim_start_matches(is_js_space).strip_prefix("local")?; + let (_, rhs) = rest.split_once('=')?; + require_parts(rhs.trim_matches(is_js_space)).map(|(module, _)| module) + })) + .collect(); + reads.sort(); + reads.dedup(); + // In source order: a module's member is the first of its namesakes. + let functions: Vec<(String, String, String)> = shared + .iter() + .filter(|node| matches!(node.kind, NodeKind::Function | NodeKind::Method)) + .map(|node| { + ( + node.kind.as_str().to_string(), + node.name.clone(), + node.qualified_name.clone(), + ) + }) + .collect(); + let variables: Vec<(String, Option, bool)> = shared + .iter() + .filter(|node| node.kind == NodeKind::Variable) + .map(|node| (node.name.clone(), node.signature.clone(), is_local(node))) + .collect(); + let stripped = strip_lua_comments(source); + let exports: Vec<(String, String, Option)> = all_exports(&stripped) + .into_iter() + .map(|(member, (line, rhs))| { + let root = rhs + .split(|c: char| c == '.' || is_js_space(c)) + .next() + .unwrap_or_default() + .to_string(); + let root_decl = local_decl_in(&root, line - 1, &shared, &line_text, &is_local) + .and_then(|decl| decl.signature.clone()); + (member, rhs, root_decl) + }) + .collect(); + let material = serde_json::json!({ + "functions": functions, + "variables": variables, + "exports": exports, + }); + Self { + locals, + reads, + globals: global_members([source, &stripped]), + surface: codegraph_core::node_id::hash_content(&material.to_string()), + } + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("a Lua module row serializes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +/// The module paths a `require` of `module` can load (upstream +/// `resolveLuaRequire`'s suffixes): `a.b` is `a/b.lua`, `a/b.luau`, +/// `a/b/init.lua` or `a/b/init.luau`, under any folder. +pub fn lua_module_may_be(module: &str, file_path: &str) -> bool { + let base = module.replace('.', "/"); + ["lua", "luau"].iter().any(|extension| { + [ + format!("{base}.{extension}"), + format!("{base}/init.{extension}"), + ] + .iter() + .any(|suffix| file_path == suffix || file_path.ends_with(&format!("/{suffix}"))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Expected values from upstream's two replacements, run under Node. + #[test] + fn lua_comments_are_stripped_keeping_block_newlines() { + let cases: [(&str, &str); 4] = [ + ( + "a = 1 -- note\n--[[ one\ntwo ]] b = 2\n--[==[ x ]] y\n]==] c = 3\n", + "a = 1 \n\n b = 2\n\n c = 3\n", + ), + ("x --[[ open\nno close\ny = 1\n", "x \nno close\ny = 1\n"), + ("s = '--not' -- c\n", "s = '\n"), + ("--[=[a]=]b", "b"), + ]; + for (source, expected) in cases { + assert_eq!(strip_lua_comments(source), expected, "{source:?}"); + } + } + + /// The scanner finds what upstream's per-member pattern finds, line + /// included (expected values from the pattern itself, run under Node). + #[test] + fn the_export_scanner_matches_upstreams_pattern() { + type Case<'a> = (&'a str, &'a str, Option<(i64, &'a str)>); + let cases: [Case; 10] = [ + ( + "return {\n check = check_phase,\n}\n", + "check", + Some((3, "check_phase")), + ), + ( + "local M = {}\nM.check = phases.check\nreturn M\n", + "check", + Some((3, "phases.check")), + ), + ("x = a.b c\ncheck = a\n.b c\n", "check", Some((2, "a"))), + ("return { a = b,check = c }\n", "check", Some((2, "c"))), + ( + "if check == other then end\ncheck = done\n", + "check", + Some((2, "done")), + ), + ( + "xcheck = a\n_check = b\nM.check=c;", + "check", + Some((4, "c")), + ), + ("check = a .\n b . c\n", "check", Some((2, "a .\n b . c"))), + ("check = 1\ncheck = real\n", "check", Some((2, "real"))), + (" check = tail ", "check", Some((2, "tail"))), + ("{check = x}", "check", Some((2, "x"))), + ]; + for (source, member, expected) in cases { + assert_eq!(first_export(source, member), expected, "{source:?}"); + } + let all = all_exports("return {\n check = check_phase,\n run = M.run,\n}\n"); + assert_eq!( + all.into_iter().collect::>(), + vec![ + ("check".to_string(), (3, "check_phase".to_string())), + ("run".to_string(), (4, "M.run".to_string())), + ] + ); + } + + /// A Lua node of `kind` named `name` on lines `start..=end`. + fn lua_node(kind: NodeKind, name: &str, start: i64, end: i64, signature: Option<&str>) -> Node { + Node { + id: format!("{}:{name}:{start}", kind.as_str()), + kind, + name: name.to_string(), + qualified_name: name.to_string(), + file_path: "m.lua".to_string(), + language: Language::Lua, + start_line: start, + end_line: end, + start_column: 0, + end_column: 0, + docstring: None, + signature: signature.map(str::to_string), + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + } + } + + #[test] + fn a_module_row_records_locals_reads_and_a_surface_blind_to_bodies() { + let source = "local m = require(\"a.b\")\nlocal function check_phase(v)\n local n = require 'c'.d\n return v\nend\nfunction helper()\nend\nreturn { check = check_phase }\n"; + let nodes = [ + lua_node(NodeKind::File, "m.lua", 1, 8, None), + lua_node(NodeKind::Variable, "m", 1, 1, Some("= require(\"a.b\")")), + lua_node(NodeKind::Function, "check_phase", 2, 5, Some("(v)")), + lua_node(NodeKind::Function, "helper", 6, 7, Some("()")), + ]; + let row = LuaModuleRow::of(source, &nodes); + assert_eq!( + row.locals, + [ + ("m".to_string(), "variable".to_string(), 1), + ("check_phase".to_string(), "function".to_string(), 2), + ] + ); + assert_eq!(row.reads, ["a.b", "c"]); + assert!(row.globals.is_empty(), "{:?}", row.globals); + let body_edit = source.replace(" return v\n", " return v + 1\n"); + assert_eq!(LuaModuleRow::of(&body_edit, &nodes).surface, row.surface); + let export_edit = source.replace("check = check_phase", "check = helper"); + assert_ne!(LuaModuleRow::of(&export_edit, &nodes).surface, row.surface); + let decoded = LuaModuleRow::decode(&row.encode()); + assert_eq!(decoded.as_ref(), Some(&row)); + } + + /// A row reads the module of every variable's initializer as the + /// resolver reads it, a trailing comment, lines or other targets beside + /// it; and its digest follows the source order of namesakes, the first of + /// which a member lookup takes. + #[test] + fn a_module_row_reads_each_initializer_and_keeps_namesakes_in_order() { + let source = "local p1 = require \"a.one\" -- note\nlocal p2 = require(\n \"a.two\"\n)\nlocal n, p3 = 1, require \"a.three\"\n"; + let nodes = [ + lua_node(NodeKind::File, "m.lua", 1, 5, None), + lua_node(NodeKind::Variable, "p1", 1, 1, Some("= require \"a.one\"")), + lua_node( + NodeKind::Variable, + "p2", + 2, + 4, + Some("= require(\n \"a.two\"\n)"), + ), + lua_node(NodeKind::Variable, "n", 5, 5, Some("= 1")), + lua_node( + NodeKind::Variable, + "p3", + 5, + 5, + Some("= require \"a.three\""), + ), + ]; + let row = LuaModuleRow::of(source, &nodes); + assert_eq!(row.reads, ["a.one", "a.three", "a.two"]); + let swapped = "local x = require(\"b\").x\nlocal x = require(\"a\").x\n"; + let ordered = "local x = require(\"a\").x\nlocal x = require(\"b\").x\n"; + let namesakes = |first: &str, second: &str| { + [ + lua_node(NodeKind::File, "m.lua", 1, 2, None), + lua_node(NodeKind::Variable, "x", 1, 1, Some(first)), + lua_node(NodeKind::Variable, "x", 2, 2, Some(second)), + ] + }; + assert_ne!( + LuaModuleRow::of( + ordered, + &namesakes("= require(\"a\").x", "= require(\"b\").x") + ) + .surface, + LuaModuleRow::of( + swapped, + &namesakes("= require(\"b\").x", "= require(\"a\").x") + ) + .surface + ); + } + + /// Every member a chain after an `=` ends in, a shorter chain's too, in + /// the code or (for an export) once the comments are gone. + #[test] + fn a_module_row_records_the_members_its_chains_read_through_global_tables() { + let source = "local snooze = ngx.sleep\nlocal respond = kong . response\n . exit\nlocal m = require(\"a.b\").c\nlocal t = { exit = kong.--[[ x ]]service.stop, n = 1 }\nif a == b.c then end\nlocal x = y\n"; + let row = LuaModuleRow::of(source, &[]); + assert_eq!( + row.globals, + ["c", "exit", "response", "service", "sleep", "stop"] + ); + } + + #[test] + fn a_require_alias_names_its_module_and_members() { + assert_eq!( + require_parts(r#"require("kong.tools.string").splitn"#), + Some(("kong.tools.string".to_string(), vec!["splitn".to_string()])) + ); + assert_eq!( + require_parts("require 'a.b'"), + Some(("a.b".to_string(), Vec::new())) + ); + assert_eq!( + require_parts(r#"reload_module("spec.internal.misc") . x . y"#), + Some(( + "spec.internal.misc".to_string(), + vec!["x".to_string(), "y".to_string()] + )) + ); + assert_eq!(require_parts("string.format"), None); + } + + /// One pass over a module finds, for every name in it, the export the + /// per-member scan finds for that name alone. + #[test] + fn the_one_pass_export_scan_matches_the_per_member_scan() { + let sources = [ + "return {\n check = check_phase,\n}\n", + "local M = {}\nM.check = phases.check\nreturn M\n", + "x = a.b c\ncheck = a\n.b c\n", + "return { a = b,check = c }\n", + "if check == other then end\ncheck = done\n", + "xcheck = a\n_check = b\nM.check=c;", + "check = a .\n b . c\n", + "check = 1\ncheck = real\n", + " check = tail ", + "{check = x}", + "\u{e9}check = a\ncheck_x = b\ncheckcheck = c, check = d\n", + "a=b\nb=c\nc = d.e\n+f = g\n", + ]; + for source in sources { + let all = all_exports(source); + let names: std::collections::BTreeSet<&str> = source + .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .filter(|name| name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')) + .collect(); + for name in names { + assert_eq!( + all.get(name).map(|(line, rhs)| (*line, rhs.as_str())), + first_export(source, name), + "{source:?} {name:?}" + ); + } + } + } +} diff --git a/crates/codegraph-resolve/src/member_types.rs b/crates/codegraph-resolve/src/member_types.rs new file mode 100644 index 00000000..f1c21b67 --- /dev/null +++ b/crates/codegraph-resolve/src/member_types.rs @@ -0,0 +1,906 @@ +//! The type a C# / Java / Kotlin receiver is declared with as a field or +//! property of the class around a call, or of a class it inherits (upstream +//! #2171 `inferMemberReceiverType`, in #2181's shape): Newtonsoft's +//! `private readonly JsonWriter _innerWriter;` makes `_innerWriter.WriteValue(…)` +//! JsonWriter's, never TraceJsonWriter's own `WriteValue` guessed by name. +//! +//! Only a class's member-level lines are read (brace depth one, or its head +//! for a primary constructor), never a method body or a nested type. The walk +//! into inherited classes carries what their type parameters stand for +//! (`: IntegrationTest`), and a member typed by a type +//! parameter of the class declaring it is its bound when the subclass gives +//! no argument. Every read is memoized for the pass in [`MemberTables`]. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::generated_header::is_js_whitespace; +use codegraph_core::types::{Language, Node, NodeKind}; +use regex::Regex; + +use crate::types::{RefView, ResolutionContext}; + +/// The languages whose fields and properties declare their type where the +/// class declares them (upstream `MEMBER_TYPED_LANGUAGES`; Swift joined with +/// #2205). +pub fn is_member_typed_language(language: Language) -> bool { + matches!( + language, + Language::CSharp | Language::Java | Language::Kotlin | Language::Swift + ) +} + +/// The languages whose files record a member surface row: the member-typed +/// ones, and Dart, whose member reads type a receiver by the fields of the +/// class around it and of what that class extends (W4-DART-03, upstream +/// #2338's `inferMemberReceiverType`). +pub fn has_member_surface(language: Language) -> bool { + is_member_typed_language(language) || language == Language::Dart +} + +/// Upstream `MEMBER_CLASS_KINDS` (`record` has no kind of its own here). +pub fn is_member_class_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Class | NodeKind::Struct | NodeKind::Interface | NodeKind::Enum + ) +} + +/// Words that can stand where a declaration's type does without being one +/// (upstream `MEMBER_TYPE_NON_TYPES`). +const MEMBER_TYPE_NON_TYPES: [&str; 46] = [ + "return", + "new", + "case", + "throw", + "else", + "in", + "out", + "ref", + "params", + "await", + "yield", + "is", + "as", + "using", + "var", + "val", + "goto", + "nameof", + "typeof", + "sizeof", + "default", + "when", + "where", + "get", + "set", + "init", + "class", + "interface", + "enum", + "struct", + "record", + "object", + "namespace", + "package", + "import", + "extends", + "implements", + "fun", + "static", + "final", + "abstract", + "sealed", + "override", + "virtual", + "delegate", + "event", +]; + +/// A Java / C# type's optional type arguments (one level of nesting) and +/// array ranks, as a regex source: ``, `>`, `[]`, +/// `[,]` (upstream `TYPE_ARGS`). +pub(crate) const TYPE_ARGS: &str = + r"(?:<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\s*(?:\[[\s,]*\]\s*)*"; + +/// What a type parameter of a declaration around a call is bounded by +/// (upstream `typeParameterBound`'s `string | null | undefined`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TypeParameterBound { + /// Not a type parameter there at all. + NotParameter, + /// A type parameter with no named bound. + Unbounded, + /// A type parameter bounded by this type (its last `.` segment). + Bound(String), +} + +/// A class's member-declaration lines, each with its brace depth. +type MemberLines = Arc>; + +/// The per-pass memos of the member walk (upstream #2181's `MEMBER_LINES`, +/// `MEMBER_TYPE_MEMO`, `MEMBER_WALKS` and `MEMBER_SHADOWS`). +#[derive(Debug, Default)] +pub struct MemberTables { + lines: Mutex>, + member_types: Mutex>>, + walks: Mutex>>, + shadows: Mutex>, +} + +impl MemberTables { + pub fn new() -> Self { + Self::default() + } +} + +fn memo_get(memo: &Mutex>, key: &K) -> Option { + memo.lock().ok().and_then(|memo| memo.get(key).cloned()) +} + +fn memo_set(memo: &Mutex>, key: K, value: V) { + if let Ok(mut memo) = memo.lock() { + memo.insert(key, value); + } +} + +/// `^[A-Za-z_]\w*$`. +fn is_simple_name(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// `lines.slice(start - 1, end).join(separator)` of `node`'s file, the lines +/// as `split(/\r?\n/)` yields them, clamped to the file. +fn joined_lines( + node: &Node, + end: usize, + separator: &str, + context: &dyn ResolutionContext, +) -> String { + let Some(facts) = context.source_facts(&node.file_path) else { + return String::new(); + }; + let start = usize::try_from(node.start_line) + .unwrap_or(1) + .saturating_sub(1); + let end = end.min(facts.line_count()); + if start >= end { + return String::new(); + } + facts.join_lines(start, end, separator) +} + +/// The head of `node`'s declaration: `lines` lines from its first, joined with +/// a space, cut at the first `{`. +fn head_before_brace(node: &Node, lines: usize, context: &dyn ResolutionContext) -> String { + let start = usize::try_from(node.start_line).unwrap_or(1); + let head = joined_lines(node, start.saturating_sub(1) + lines, " ", context); + head.split('{').next().unwrap_or_default().to_string() +} + +/// The type a C# / Java / Kotlin receiver has as a field or property of the +/// class around the call, or of one it inherits, or `None` when no class +/// declares such a member or the calling function binds the name itself +/// (upstream `inferMemberReceiverType`). +pub(crate) fn infer_member_receiver_type( + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let name = receiver + .strip_prefix("this.") + .or_else(|| receiver.strip_prefix("self.")) + .unwrap_or(receiver); + if !is_simple_name(name) { + return None; + } + let mut class: Option> = None; + let mut function: Option> = None; + for node in context.get_nodes_in_file_shared(&reference.file_path) { + if node.language != reference.language + || node.start_line > reference.line + || node.end_line < reference.line + { + continue; + } + if is_member_class_kind(node.kind) { + if class + .as_ref() + .is_none_or(|class| node.start_line >= class.start_line) + { + class = Some(node); + } + } else if matches!(node.kind, NodeKind::Method | NodeKind::Function) + && function + .as_ref() + .is_none_or(|function| node.start_line >= function.start_line) + { + function = Some(node); + } + } + let class = class?; + let found = member_type_through_hierarchy(&class, name, context)?; + // A lambda parameter, `var` local, `out` variable or `foreach` binding in + // the calling function shadows the member. + if function + .as_ref() + .is_some_and(|function| binds_name_itself(function, name, context)) + { + return None; + } + Some(found) +} + +/// Whether a C# / Java / Kotlin function body binds `name` itself: a +/// `var`/`val`, an `out` or loop variable, a lambda parameter (upstream +/// `bindsNameItself`, over the whole function as #2181 reads it). +fn binds_name_itself(function: &Node, name: &str, context: &dyn ResolutionContext) -> bool { + let tables = context.member_tables(); + let key = (function.id.clone(), name.to_string()); + if let Some(hit) = memo_get(&tables.shadows, &key) { + return hit; + } + let end = usize::try_from(function.end_line).unwrap_or(0); + let body = joined_lines(function, end, "\n", context); + let r = regex::escape(name); + let w = "[A-Za-z0-9_]"; + let binds = Regex::new(&format!( + r"(?-u:\b)(?:var|val|let|out\s+[{w}.<>?]+|foreach\s*\(\s*[{w}.<>?,\s]+?)\s+{r}(?-u:\b)|(?-u:\b)for\s*\([^;)]*\s{r}\s*:|(?-u:\b){r}\s*=>|[(,]\s*{r}\s*(?:,[^()]*)?\)\s*=>|(?-u:\b){r}\s*(?:,[^{{}}]*)?->" + )) + .is_ok_and(|binds| binds.is_match(&body)); + memo_set(&tables.shadows, key, binds); + binds +} + +/// The type `class` declares a member `name` with, or one of its supertypes +/// does, its own members first, each inherited class carrying what its type +/// parameters stand for in the class the walk came from (upstream +/// `memberTypeThroughHierarchy`, at most eight types). +fn member_type_through_hierarchy( + class: &Node, + name: &str, + context: &dyn ResolutionContext, +) -> Option { + let tables = context.member_tables(); + let key = (class.id.clone(), name.to_string()); + if let Some(hit) = memo_get(&tables.walks, &key) { + return hit; + } + let mut result = None; + let mut seen: HashSet = HashSet::new(); + let mut queue: VecDeque<(Arc, HashMap)> = VecDeque::new(); + queue.push_back((Arc::new(class.clone()), HashMap::new())); + while seen.len() < 8 { + let Some((owner, args)) = queue.pop_front() else { + break; + }; + if !seen.insert(owner.id.clone()) { + continue; + } + if let Some(found) = class_member_type(&owner, name, context) { + if owner.id == class.id { + result = Some(found); + break; + } + // A member typed by the declaring class's own type parameter + // (`protected TFixture Fixture { get; }`) is the argument the + // subclass gave it, else its bound; with neither, only `object`'s + // members. + if let Some(given) = args.get(&found) { + result = Some(given.clone()); + break; + } + result = Some( + match type_parameter_bound_in(&found, std::slice::from_ref(&owner), context) { + TypeParameterBound::NotParameter => found, + TypeParameterBound::Unbounded => "object".to_string(), + TypeParameterBound::Bound(bound) => bound, + }, + ); + break; + } + for sup in crate::class_heads::class_head_supertypes(&owner, context) { + let given: Vec = head_type_arguments(&owner, &sup, context) + .into_iter() + .map(|argument| args.get(&argument).cloned().unwrap_or(argument)) + .collect(); + for decl in context.get_nodes_by_name_shared(&sup) { + if decl.language != owner.language || !is_member_class_kind(decl.kind) { + continue; + } + let params = class_type_parameters(&decl, context); + let bound: HashMap = params + .into_iter() + .zip( + given + .iter() + .cloned() + .chain(std::iter::repeat(String::new())), + ) + .filter(|(_, argument)| !argument.is_empty()) + .collect(); + queue.push_back((decl, bound)); + } + } + } + memo_set(&tables.walks, key, result.clone()); + result +} + +/// The first `<…>` of a declaration's head, split at its top-level commas +/// (upstream `angleArguments`). +pub(crate) fn angle_arguments(text: &str) -> Vec { + let Some(open) = text.find('<') else { + return Vec::new(); + }; + let mut out = Vec::new(); + let mut depth = 0usize; + let mut current = String::new(); + for ch in text[open + 1..].chars() { + if ch == '<' { + depth += 1; + } else if ch == '>' { + if depth == 0 { + break; + } + depth -= 1; + } + if ch == ',' && depth == 0 { + out.push(std::mem::take(&mut current)); + } else { + current.push(ch); + } + } + out.push(current); + out.into_iter() + .map(|argument| argument.trim_matches(is_js_whitespace).to_string()) + .collect() +} + +/// The type parameters a class declares: `TFixture` for `class +/// IntegrationTest` (upstream `declaredTypeParameters`, four lines +/// of its head). +pub(crate) fn class_type_parameters(decl: &Node, context: &dyn ResolutionContext) -> Vec { + context + .source_facts(&decl.file_path) + .map(|facts| class_type_parameters_in(&facts, decl)) + .unwrap_or_default() +} + +/// [`class_type_parameters`] of `decl` in a file whose facts are `facts`. +fn class_type_parameters_in(facts: &crate::source_facts::SourceFacts, decl: &Node) -> Vec { + static PARAMETER: OnceLock = OnceLock::new(); + static VARIANCE: OnceLock = OnceLock::new(); + let start = usize::try_from(decl.start_line) + .unwrap_or(1) + .saturating_sub(1); + let end = (start + 4).min(facts.line_count()); + let head = if start < end { + facts.join_lines(start, end, " ") + } else { + String::new() + }; + // The pattern writes the name, then past whitespace `<`: a head writing + // no such thing (occurrences may overlap, `$a` in `$a$a`) compiles + // nothing. + let name = decl.name.as_str(); + let mut from = 0; + let mut written = false; + while !name.is_empty() + && let Some(found) = head.get(from..).and_then(|rest| rest.find(name)) + { + let at = from + found; + if head[at + name.len()..].trim_start().starts_with('<') { + written = true; + break; + } + from = at + name.chars().next().map_or(1, char::len_utf8); + } + if !written { + return Vec::new(); + } + let Ok(at) = Regex::new(&format!(r"(?-u:\b){}\s*<", regex::escape(&decl.name))) else { + return Vec::new(); + }; + let Some(found) = at.find(&head) else { + return Vec::new(); + }; + let parameter = PARAMETER.get_or_init(|| { + Regex::new(r"([A-Za-z_][A-Za-z0-9_]*)\s*(?:extends(?-u:\b).*|:.*)?$").expect("parameter") + }); + let variance = + VARIANCE.get_or_init(|| Regex::new(r"^(?:in|out|reified)\s+").expect("variance")); + angle_arguments(&head[found.start()..]) + .into_iter() + .map(|p| { + let p = variance.replace(&p, ""); + parameter + .captures(&p) + .map(|caps| caps[1].to_string()) + .unwrap_or_default() + }) + .collect() +} + +/// The type arguments a class's head gives a supertype, as simple names: +/// `DatabaseInitializer` for `: IntegrationTest` +/// (upstream `headTypeArguments`, nine lines of its head). +fn head_type_arguments(class: &Node, sup: &str, context: &dyn ResolutionContext) -> Vec { + let head = head_before_brace(class, 9, context); + let Ok(at) = Regex::new(&format!( + r"(?:[:,]|(?-u:\b)extends|(?-u:\b)implements)\s*(?:[A-Za-z0-9_.]+\.)?{}\s*<", + regex::escape(sup) + )) else { + return Vec::new(); + }; + let Some(found) = at.find(&head) else { + return Vec::new(); + }; + angle_arguments(&head[found.start()..]) + .into_iter() + .map(|argument| { + let argument = argument.split('<').next().unwrap_or_default(); + argument + .rsplit('.') + .next() + .unwrap_or_default() + .trim_matches(is_js_whitespace) + .to_string() + }) + .collect() +} + +/// The type `class` declares a member `name` with, read from its +/// member-level lines (upstream `classMemberType`); inside parentheses at +/// member depth is a method's parameter list, only the head's (a primary +/// constructor's) declares members. +pub(crate) fn class_member_type( + class: &Node, + name: &str, + context: &dyn ResolutionContext, +) -> Option { + let tables = context.member_tables(); + let key = (class.id.clone(), name.to_string()); + if let Some(hit) = memo_get(&tables.member_types, &key) { + return hit; + } + let found = member_type_in_lines(&class_member_lines(class, context), name, class.language); + memo_set(&tables.member_types, key, found.clone()); + found +} + +/// The type the member-level `lines` of a class of `language` declare a +/// member `name` with ([`class_member_type`] over lines read once): the first +/// line whose declaration of `name` names a type, outside a member's +/// parameter list. +pub fn member_type_in_lines( + lines: &[(String, usize)], + name: &str, + language: Language, +) -> Option { + let r = regex::escape(name); + let pattern = if matches!(language, Language::Kotlin | Language::Swift) { + Regex::new(&format!( + r"(?-u:\b)(?:val|var|let)\s+{r}\s*:\s*([A-Z][A-Za-z0-9_.]*)" + )) + } else { + Regex::new(&format!( + r"(?:^|[\s(,])([A-Za-z_][A-Za-z0-9_.]*)\s*{TYPE_ARGS}\??\s+{r}\s*(?:[=;,)]|\{{)" + )) + }; + let pattern = pattern.ok()?; + for (text, depth) in lines { + if !text.contains(name) { + continue; + } + let Some(caps) = pattern.captures(text) else { + continue; + }; + let at = caps.get(0).map_or(0, |m| m.start()); + let before = &text[..at]; + let in_parens = *depth == 1 && before.matches('(').count() > before.matches(')').count(); + let written = caps.get(1).map_or("", |m| m.as_str()); + if !in_parens && !MEMBER_TYPE_NON_TYPES.contains(&written) { + return crate::name_matcher::normalize_inferred_type_name(written); + } + } + None +} + +/// A class's own member-declaration lines with their brace depth: those at +/// its body's depth, or its head, with comments and string contents dropped +/// (upstream `classMemberLines`, read once per class). +pub(crate) fn class_member_lines(class: &Node, context: &dyn ResolutionContext) -> MemberLines { + let tables = context.member_tables(); + if let Some(hit) = memo_get(&tables.lines, &class.id) { + return hit; + } + let lines = context + .source_facts(&class.file_path) + .map(|facts| member_lines_of(&facts, class)) + .unwrap_or_default(); + let lines = Arc::new(lines); + memo_set(&tables.lines, class.id.clone(), Arc::clone(&lines)); + lines +} + +/// [`class_member_lines`] of `class` in a file whose facts are `facts`. +pub(crate) fn member_lines_of( + facts: &crate::source_facts::SourceFacts, + class: &Node, +) -> Vec<(String, usize)> { + static STRINGS: OnceLock = OnceLock::new(); + static BLOCK: OnceLock = OnceLock::new(); + static LINE: OnceLock = OnceLock::new(); + let strings = STRINGS.get_or_init(|| { + Regex::new(r#""(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'"#).expect("member strings") + }); + let block = BLOCK.get_or_init(|| Regex::new(r"/\*.*?\*/").expect("member block")); + let line_comment = LINE.get_or_init(|| Regex::new(r"//.*$").expect("member line")); + let mut out = Vec::new(); + let mut depth = 0usize; + let mut in_comment = false; + let start = usize::try_from(class.start_line).unwrap_or(1).max(1); + let end = usize::try_from(class.end_line).unwrap_or(0); + for line in start..=end { + let raw = if line <= facts.line_count() { + facts.line(line - 1) + } else { + "" + }; + let mut raw = raw; + if in_comment { + let Some(close) = raw.find("*/") else { + continue; + }; + raw = &raw[close + 2..]; + in_comment = false; + } + let text = strings.replace_all(raw, "\"\""); + let text = block.replace_all(&text, " "); + let mut text = line_comment.replace(&text, "").into_owned(); + if let Some(open) = text.find("/*") { + text.truncate(open); + in_comment = true; + } + if depth <= 1 && !text.trim_matches(is_js_whitespace).is_empty() { + out.push((text.clone(), depth)); + } + for ch in text.chars() { + if ch == '{' { + depth += 1; + } else if ch == '}' { + depth = depth.saturating_sub(1); + } + } + } + out +} + +/// What a Java / C# / Kotlin type parameter of the class or method around a +/// call is bounded by (upstream `typeParameterBound`). +pub(crate) fn type_parameter_bound( + type_name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> TypeParameterBound { + let around: Vec> = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|n| { + n.start_line <= reference.line + && n.end_line >= reference.line + && (is_member_class_kind(n.kind) + || matches!(n.kind, NodeKind::Method | NodeKind::Function)) + }) + .collect(); + type_parameter_bound_in(type_name, &around, context) +} + +/// [`type_parameter_bound`] over the heads (six lines, to the first `{`) of +/// the given declarations (upstream `typeParameterBoundIn`). +fn type_parameter_bound_in( + type_name: &str, + decls: &[Arc], + context: &dyn ResolutionContext, +) -> TypeParameterBound { + let uppercase = type_name.starts_with(|c: char| c.is_ascii_uppercase()) + && type_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'); + if !uppercase { + return TypeParameterBound::NotParameter; + } + let t = regex::escape(type_name); + let variance = r"(?:in\s+|out\s+|reified\s+)?"; + let (Ok(bound), Ok(where_bound), Ok(declared)) = ( + Regex::new(&format!( + r"[<,]\s*{variance}{t}\s*(?:extends|:)\s*([A-Z][A-Za-z0-9_.]*)" + )), + Regex::new(&format!(r"(?-u:\b)where\s+{t}\s*:\s*([A-Z][A-Za-z0-9_.]*)")), + Regex::new(&format!(r"[<,]\s*{variance}{t}\s*[,>]")), + ) else { + return TypeParameterBound::NotParameter; + }; + let mut is_declared = false; + for decl in decls { + let head = head_before_brace(decl, 6, context); + let found = bound + .captures(&head) + .or_else(|| where_bound.captures(&head)) + .map(|caps| caps[1].to_string()); + if let Some(found) = found { + let last = found.rsplit('.').next().unwrap_or(&found).to_string(); + return TypeParameterBound::Bound(last); + } + if declared.is_match(&head) { + is_declared = true; + } + } + if is_declared { + TypeParameterBound::Unbounded + } else { + TypeParameterBound::NotParameter + } +} + +/// The `project_metadata` key prefix of a file's member surface row. +pub const SURFACE_ROW_KEY_PREFIX: &str = "member_surface:"; + +/// The `project_metadata` key of `file_path`'s member surface row. +pub fn surface_row_key(file_path: &str) -> String { + format!("{SURFACE_ROW_KEY_PREFIX}{file_path}") +} + +/// What the member walk reads of one class declaration of another file: its +/// name and qualified name, the supertypes its head names +/// ([`crate::class_heads::class_head_supertypes`]), and a digest of its +/// member-level lines, of its head (nine lines, cut at the first `{`), which +/// the type arguments it gives and the bounds are read from, and of the type +/// parameters it declares. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub struct MemberClass { + pub name: String, + pub qualified_name: String, + pub supers: Vec, + pub digest: String, + /// A Dart class's digest of its head and type parameters alone, so a sync + /// tells a change of its members from one of its head (W4-DART-03). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub head: String, + /// A Dart class's member-level lines, `\t`, which the walk + /// reads a member's type from: a sync compares the types of the members + /// before and after. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lines: Vec, + /// A Dart class's member declarations, as a chain's typing reads them + /// ([`crate::dart_chains::dart_statements`], W4-DART-03b). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub statements: Vec, +} + +impl MemberClass { + /// [`Self::lines`] as [`member_type_in_lines`] reads them. + pub fn member_lines(&self) -> Vec<(String, usize)> { + self.lines + .iter() + .filter_map(|line| { + let (depth, text) = line.split_once('\t')?; + Some((text.to_string(), depth.parse().ok()?)) + }) + .collect() + } +} + +/// What other files' member walks (W4-CS-03, upstream #2171) read of one C#, +/// Java or Kotlin file: each class, struct, interface and enum declaration, +/// in node order. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MemberSurface { + pub classes: Vec, + /// A Dart file's top-level declarations, its types' bodies shown as + /// `{}`, as a chain's typing reads them (W4-DART-03b). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub top: Vec, +} + +impl MemberSurface { + /// The row of the file whose text is `source` and whose nodes are + /// `nodes`. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let facts = crate::source_facts::SourceFacts::new(Arc::from(source)); + let dart_file = nodes.iter().any(|node| node.language == Language::Dart); + let code = dart_file.then(|| facts.dart_code()); + let mut ordered: Vec<&Node> = nodes + .iter() + .filter(|node| has_member_surface(node.language) && is_member_class_kind(node.kind)) + .collect(); + ordered.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + let classes = ordered + .into_iter() + .map(|node| { + let mut read = String::new(); + let member_lines = member_lines_of(&facts, node); + for (text, depth) in &member_lines { + read.push_str(&format!("{depth}\t{text}\n")); + } + let mut head_read = String::from("\n--head--\n"); + let start = usize::try_from(node.start_line) + .unwrap_or(1) + .saturating_sub(1); + let end = (start + 9).min(facts.line_count()); + if start < end { + let head = facts.join_lines(start, end, " "); + head_read.push_str(head.split('{').next().unwrap_or_default()); + } + head_read.push_str("\n--parameters--\n"); + head_read.push_str(&class_type_parameters_in(&facts, node).join(",")); + read.push_str(&head_read); + let digest = codegraph_core::node_id::hash_content(&read); + let dart = node.language == Language::Dart; + MemberClass { + name: node.name.clone(), + qualified_name: node.qualified_name.clone(), + supers: crate::class_heads::class_head_supertypes_in(&facts, node), + digest: digest[..16].to_string(), + head: if dart { + codegraph_core::node_id::hash_content(&head_read)[..16].to_string() + } else { + String::new() + }, + lines: if dart { + member_lines + .iter() + .map(|(text, depth)| format!("{depth}\t{text}")) + .collect() + } else { + Vec::new() + }, + statements: match (&code, dart) { + (Some(code), true) => crate::dart_chains::dart_class_statements(code, node), + _ => Vec::new(), + }, + } + }) + .collect(); + let top = code.map_or_else(Vec::new, |code| { + crate::dart_chains::dart_statements(&code.text, 0, code.text.len()) + }); + Self { classes, top } + } + + /// No class and no top-level declaration: no row records it. + pub fn is_empty(&self) -> bool { + self.classes.is_empty() && self.top.is_empty() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("member surface row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn member_lines_keep_member_depth_and_drop_comments_and_strings() { + let source = "public class Writer : Base\n{\n private readonly JsonWriter _inner; // the inner \"one\"\n /* a block comment\n naming TextWriter _text; */\n private string Name = \"x { y\";\n public void Write()\n {\n var local = 1;\n }\n}\n"; + let facts = crate::source_facts::SourceFacts::new(Arc::from(source)); + let mut class = test_node("Writer"); + class.start_line = 1; + class.end_line = 11; + let lines = member_lines_of(&facts, &class); + let texts: Vec<(&str, usize)> = lines.iter().map(|(t, d)| (t.trim(), *d)).collect(); + assert_eq!( + texts, + [ + ("public class Writer : Base", 0), + ("{", 0), + ("private readonly JsonWriter _inner;", 1), + ("private string Name = \"\";", 1), + ("public void Write()", 1), + ("{", 1), + ("}", 2), + ("}", 1), + ] + .into_iter() + .filter(|(_, depth)| *depth <= 1) + .collect::>() + ); + } + + fn test_node(name: &str) -> Node { + Node { + id: format!("class:{name}"), + kind: NodeKind::Class, + name: name.to_string(), + qualified_name: format!("App::{name}"), + file_path: "A.cs".to_string(), + language: Language::CSharp, + start_line: 1, + end_line: 1, + start_column: 0, + end_column: 0, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + } + } + + #[test] + fn a_member_surface_digest_ignores_method_bodies() { + let before = "public class Base\n{\n protected JsonWriter Writer { get; set; }\n public int Depth()\n {\n return 1;\n }\n}\n"; + let after_body = before.replace("return 1;", "return 2;"); + let after_member = before.replace("JsonWriter Writer", "TextWriter Writer"); + let mut class = test_node("Base"); + class.end_line = 8; + let digest = |source: &str| { + MemberSurface::of(source, std::slice::from_ref(&class)).classes[0] + .digest + .clone() + }; + assert_eq!(digest(before), digest(&after_body)); + assert_ne!(digest(before), digest(&after_member)); + } + + #[test] + fn a_member_surface_digest_reads_the_head_to_its_body() { + let before = "public abstract class Middle : IntegrationTest where TFixture : FirstInit\n{\n}\n"; + let mut class = test_node("Middle"); + class.end_line = 3; + let digest = |source: &str| { + MemberSurface::of(source, std::slice::from_ref(&class)).classes[0] + .digest + .clone() + }; + assert_ne!( + digest(before), + digest(&before.replace("", "")) + ); + assert_ne!( + digest(before), + digest(&before.replace(": FirstInit", ": SecondInit")) + ); + let generic = "public class Box\n{\n}\n"; + let mut boxed = test_node("Box"); + boxed.end_line = 3; + let parameters = |source: &str| { + MemberSurface::of(source, std::slice::from_ref(&boxed)).classes[0] + .digest + .clone() + }; + assert_ne!( + parameters(generic), + parameters(&generic.replace("", "")) + ); + } + + #[test] + fn angle_arguments_split_at_top_level_commas() { + assert_eq!(angle_arguments(": Pair {"), ["L", "R"]); + assert_eq!( + angle_arguments("Map> x"), + ["string", "List"] + ); + assert!(angle_arguments("class Plain").is_empty()); + } +} diff --git a/crates/codegraph-resolve/src/member_value.rs b/crates/codegraph-resolve/src/member_value.rs index 9e64ab08..3375064d 100644 --- a/crates/codegraph-resolve/src/member_value.rs +++ b/crates/codegraph-resolve/src/member_value.rs @@ -16,42 +16,9 @@ use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; use regex::Regex; use crate::import_resolver::resolve_via_import; -use crate::name_matcher::{ - infer_local_receiver_type, prefer_call_site_file, resolve_method_on_type, same_language_family, -}; +use crate::name_matcher::{resolve_method_on_type, same_language_family}; use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; -/// Primitive and built-in Go field types: a field of one never names a -/// project type. -const GO_BUILTIN_FIELD_TYPES: [&str; 26] = [ - "string", - "bool", - "byte", - "rune", - "error", - "any", - "int", - "int8", - "int16", - "int32", - "int64", - "uint", - "uint8", - "uint16", - "uint32", - "uint64", - "uintptr", - "float32", - "float64", - "complex64", - "complex128", - "chan", - "map", - "func", - "struct", - "interface", -]; - /// Resolve a Python or Go member value `receiver.member` (#1820). pub(crate) fn match_member_function_ref( reference: &RefView, @@ -62,15 +29,26 @@ pub(crate) fn match_member_function_ref( let root = receiver.split('.').next().unwrap_or(receiver); // An import is authoritative even when it points outside the project. if imports.iter().any(|import| import.local_name == root) { - if reference.language == Language::Python - && let Some(class) = python_ref_class(receiver, reference, context) - { - return unique_callable( - python_members(&class, member, reference, context, &mut HashSet::new()), - reference, - context, - 0.9, - ); + if reference.language == Language::Python { + if let Some(class) = python_ref_class(receiver, reference, context) { + return unique_callable( + python_members(&class, member, reference, context, &mut HashSet::new()), + reference, + context, + 0.9, + ); + } + // `mod.global` / an imported `global` itself (upstream #2291). + if let Some(global) = + crate::python_globals::imported_global(receiver, reference, context) + { + return unique_callable( + crate::python_globals::global_members(&global, member, reference, context), + reference, + context, + 0.9, + ); + } } let imported = resolve_via_import(reference, context)?; let node = context.get_node_by_id_shared(&imported.target_node_id)?; @@ -82,11 +60,30 @@ pub(crate) fn match_member_function_ref( return unique_callable(same_name, reference, context, 0.9); } if reference.language == Language::Go { - if receiver.contains('.') { - return match_go_field_chain_value(receiver, member, reference, context); + if let Some((base, field)) = receiver.split_once('.') { + if base.is_empty() || field.is_empty() || field.contains('.') { + return None; + } + return crate::go_packages::go_field_chain_call( + base, + field, + member, + reference, + context, + 0.85, + ResolvedBy::FunctionRef, + ); } - if let Some(type_name) = infer_local_receiver_type(receiver, reference, context) { - return go_method_value(&type_name, member, reference, context); + if let Some((type_name, written)) = + crate::name_matcher::infer_local_receiver_declaration(receiver, reference, context) + { + // The package declaring the receiver's type (upstream #2323). + let pkg_dir = crate::go_packages::go_declared_type_package( + Some(&written), + &reference.file_path, + context, + ); + return go_method_value(&type_name, member, pkg_dir.as_deref(), reference, context); } let types = context .get_nodes_by_name_shared(receiver) @@ -99,7 +96,7 @@ pub(crate) fn match_member_function_ref( if types > 0 { // A method expression `Store.Fetch` names its type directly. return (types == 1) - .then(|| go_method_value(receiver, member, reference, context)) + .then(|| go_method_value(receiver, member, None, reference, context)) .flatten(); } } else { @@ -128,7 +125,20 @@ pub(crate) fn match_member_function_ref( { python_field_type(field, owner.as_deref()?, reference, context) } else { - python_local_type(receiver, reference, context) + let local = python_local_type(receiver, reference, context); + // An untyped module global of this file (upstream #2291). + if local.is_none() + && let Some(global) = + crate::python_globals::own_file_global(receiver, reference, context) + { + return unique_callable( + crate::python_globals::global_members(&global, member, reference, context), + reference, + context, + 0.9, + ); + } + local }; // A type name used directly (`Store.fetch`) is scoped like an annotation. if type_name.is_none() @@ -211,6 +221,7 @@ fn unique_callable( fn go_method_value( type_name: &str, member: &str, + pkg_dir: Option<&str>, reference: &RefView, context: &dyn ResolutionContext, ) -> Option { @@ -221,132 +232,48 @@ fn go_method_value( context, 0.9, ResolvedBy::FunctionRef, - None, + pkg_dir, 0, ) } -/// `base.field.Method` — the field's declared type read off the base type's -/// own struct declaration (upstream `matchGoFieldChainCall`, #1276/#1316). A -/// package-qualified field type is followed only when the package is in the -/// module; built-in types name no project type. -fn match_go_field_chain_value( - receiver_chain: &str, - method: &str, - reference: &RefView, - context: &dyn ResolutionContext, -) -> Option { - let (base, field) = receiver_chain.split_once('.')?; - if base.is_empty() || field.is_empty() || field.contains('.') { - return None; - } - let base_type = infer_local_receiver_type(base, reference, context)?; - let field_type = Regex::new(&format!( - r"(?-u:\b){}\s+\*?\[?\]?([A-Za-z_][\w.]*)", - regex::escape(field) - )) - .ok()?; - let structs = prefer_call_site_file( - context.get_nodes_by_name_shared(&base_type), - &reference.file_path, - ) - .into_iter() - .filter(|node| { - matches!(node.kind, NodeKind::Struct | NodeKind::Class) && node.language == Language::Go - }) - .collect::>(); - for declaration in structs { - let Some(facts) = context.source_facts(&declaration.file_path) else { - continue; - }; - let start = declaration.start_line.saturating_sub(1).max(0) as usize; - let end = (declaration.end_line.max(0) as usize).min(facts.line_count()); - for index in start..end { - let line = strip_go_line_comments(facts.line(index)); - let Some(raw_type) = field_type - .captures(&line) - .and_then(|captures| captures.get(1)) - .map(|capture| capture.as_str().to_string()) - else { - continue; - }; - if let Some((package, _)) = raw_type.split_once('.') { - let in_module = context.get_go_modules().iter().any(|module| { - context - .get_import_mappings(&declaration.file_path, Language::Go) - .iter() - .any(|import| { - import.local_name == package - && (import.source == module.module_path - || import - .source - .starts_with(&format!("{}/", module.module_path))) - }) - }); - if !in_module { - continue; - } - } - let Some(type_name) = raw_type.rsplit('.').next() else { - continue; - }; - if !type_name - .chars() - .next() - .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_') - || GO_BUILTIN_FIELD_TYPES.contains(&type_name) - { - continue; - } - if let Some(resolved) = resolve_method_on_type( - type_name, - method, - reference, - context, - 0.85, - ResolvedBy::FunctionRef, - None, - 0, - ) { - return Some(resolved); - } - } - } - None -} - -/// A Go declaration line without its `//` and single-line `/* */` comments. -fn strip_go_line_comments(line: &str) -> String { - let line = line.split("//").next().unwrap_or(line); - let mut out = String::with_capacity(line.len()); - let mut rest = line; - while let Some(open) = rest.find("/*") { - out.push_str(&rest[..open]); - match rest[open + 2..].find("*/") { - Some(close) => rest = &rest[open + 2 + close + 2..], - None => { - rest = ""; - break; - } - } - } - out.push_str(rest); - out -} - /// The project class a Python name refers to: an import binding when the /// name's root is imported, else the one same-file class of that name. -fn python_ref_class( +pub(crate) fn python_ref_class( name: &str, reference: &RefView, context: &dyn ResolutionContext, ) -> Option { + let imports = context.get_import_mappings(&reference.file_path, Language::Python); + // `import pkg.mod` then `pkg.mod.Cls`: the mapping keys the module by its + // last segment (upstream #2291). Two modules with the same last segment + // (`import a.foo`, `import b.foo`) share the key: refuse. + let mut name = name.to_string(); + if let Some(module) = imports.iter().find(|import| { + import.is_namespace + && name + .strip_prefix(import.source.as_str()) + .and_then(|rest| rest.strip_prefix('.')) + .is_some_and(|member| { + !member.is_empty() + && member + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + }) + }) { + if imports + .iter() + .filter(|import| import.local_name == module.local_name) + .count() + != 1 + { + return None; + } + name = format!("{}.{}", module.local_name, &name[module.source.len() + 1..]); + } + let name = name.as_str(); let root = name.split('.').next().unwrap_or(name); - if context - .get_import_mappings(&reference.file_path, Language::Python) - .iter() - .any(|import| import.local_name == root) - { + if imports.iter().any(|import| import.local_name == root) { let mut lookup = reference.clone(); lookup.reference_name = name.to_string(); lookup.reference_kind = EdgeKind::References; @@ -374,7 +301,11 @@ fn python_ref_class( } /// The classes named in a Python class's base list, resolved from its file. -fn python_bases(class: &Node, reference: &RefView, context: &dyn ResolutionContext) -> Vec { +pub(crate) fn python_bases( + class: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Vec { let Some(facts) = context.source_facts(&class.file_path) else { return Vec::new(); }; @@ -404,7 +335,7 @@ fn python_bases(class: &Node, reference: &RefView, context: &dyn ResolutionConte .collect() } -fn python_derives_from( +pub(crate) fn python_derives_from( class: &Node, base: &Node, reference: &RefView, @@ -424,7 +355,7 @@ fn python_derives_from( /// What `class.member` names: an instance or class assignment shadows any /// method (the class itself is returned, which is not callable), else the /// class's own method, else what its bases provide. -fn python_members( +pub(crate) fn python_members( class: &Node, member: &str, reference: &RefView, diff --git a/crates/codegraph-resolve/src/module_surface.rs b/crates/codegraph-resolve/src/module_surface.rs new file mode 100644 index 00000000..ef2f10ca --- /dev/null +++ b/crates/codegraph-resolve/src/module_surface.rs @@ -0,0 +1,367 @@ +//! A JavaScript-family module's surface: what other files read from its own +//! text when they resolve against it, which no node records (W4a-F3). +//! +//! Import resolution reads a module's re-exports and export aliases (as +//! `get_re_exports` reads a barrel), its CommonJS default (`module.exports = +//! …`) and whether it has an `export default` statement. Name matching reads +//! the names its export lists give and whether it is a sealed module +//! (upstream #2162), and a component's `\n"; + let module = "\n"; + assert_ne!(surface(instance), surface(module)); + } + + #[test] + fn an_unread_file_has_a_surface_of_its_own() { + let unread = module_surface("src/big.ts", Language::TypeScript, None).expect("unread"); + assert!(unread.sources.is_empty()); + assert_ne!( + Some(unread.digest), + digest("src/big.ts", "export * from './a';\n") + ); + } + + #[test] + fn a_row_round_trips_and_rejects_another_format() { + let row = SurfaceRow { + digest: "d".into(), + sources: vec!["./a".into(), "pkg".into()], + targets: vec!["src/a.js".into(), "src/a.ts".into()], + }; + assert_eq!(SurfaceRow::decode(&row.encode()), Some(row)); + // Format 1 aligned a nullable target with each source. + assert_eq!( + SurfaceRow::decode(r#"{"digest":"d","sources":["./a"],"targets":[null]}"#), + None + ); + assert_eq!(SurfaceRow::decode("not json"), None); + } +} diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index 318a7709..4632ee7b 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -5,9 +5,8 @@ //! cross-language family gate — mirror the upstream exactly. Every strategy cites its //! upstream source range. -use crate::object_literal::{ - LiteralLookup, object_literal_property, property_contains, resolve_object_literal_binding, -}; +use crate::member_types::TYPE_ARGS; +use crate::object_literal::{LiteralLookup, object_literal_property, property_contains}; use crate::source_facts::{SourceFacts, TsFieldDeclaration}; use crate::strip_comments::{CommentLang, strip_comments_for_regex}; use crate::types::{ @@ -17,7 +16,8 @@ use crate::types::{ use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; use regex::Regex; use std::borrow::Borrow; -use std::collections::HashMap; +use std::cell::OnceCell; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, OnceLock}; /// Ceiling on same-name candidates a proximity-SCORED strategy will rank @@ -350,53 +350,526 @@ fn apply_language_gate>(candidates: Vec, reference: &RefView) /// `join` defined inside a DIFFERENT function (`isLexicallyReachable`, #1230 / /// upstream `c472cfb`). /// -/// A candidate whose `qualified_name` parent resolves to a same-file -/// function/method ENCLOSING it is kept only when the ref originates inside that -/// parent's line range. Class members are unaffected (their parent resolves to a -/// class-like node), as are top-level symbols and C++ namespace-prefixed names -/// (the prefix has no node). +/// A candidate that a same-file function/method ENCLOSES, found by walking out +/// through its `qualified_name` ([`lexical_scope_of`]), is kept only when the +/// ref originates inside that function's line range. A method of a top-level +/// class is unaffected, as are top-level symbols and C++ namespace-prefixed +/// names (the prefix has no node); a method of a class declared inside a +/// function is scoped by that function (upstream #2125). pub(crate) fn is_lexically_reachable( candidate: &Node, reference: &RefView, context: &dyn ResolutionContext, ) -> bool { - if candidate.kind != NodeKind::Function { - return true; + is_reachable_within(candidate, reference, context, &CandidateReach::default()) +} + +/// What reaching a candidate by name depends on that no reference changes, +/// each read once it is needed (W5-08): a name's candidates keep it for the +/// pass ([`NameMatcherTables::named_candidates`]). +#[derive(Debug, Default)] +pub(crate) struct CandidateReach { + /// [`candidate_scope`]. + scope: OnceLock>, + /// The object literal a function is a member of. + object_owner: OnceLock>>, +} + +/// [`is_lexically_reachable`] with what the candidate's own reach depends on +/// kept in `reach`: what is left depends on the reference. +fn is_reachable_within( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, + reach: &CandidateReach, +) -> bool { + // An object literal's member is reached through its object (upstream + // #2363): a bare `load()` or `setTimeout(load)` never means `App.load`, + // whatever shares the name. + if candidate.kind == NodeKind::Function + && is_simple_js_identifier(&reference.reference_name) + && let Some(owner) = reach + .object_owner + .get_or_init(|| crate::object_owners::object_literal_owner_of(candidate, context)) + && !crate::object_owners::is_member_reachable_through(owner, candidate, reference, context) + { + return false; + } + if crate::object_owners::is_unreachable_path_holder(candidate, reference) { + return false; + } + reach + .scope + .get_or_init(|| candidate_scope(candidate, context)) + .is_none_or(|(start, end)| { + reference.file_path == candidate.file_path + && reference.line >= start + && reference.line <= end + }) +} + +/// The lines a candidate is in scope for, when it is local to them: what +/// reaching it by name depends on that no reference changes (W5-08), which +/// a name's candidates keep for the pass ([`NameMatcherTables`]). +fn candidate_scope(candidate: &Node, context: &dyn ResolutionContext) -> Option<(i64, i64)> { + // A `val` / `const` declared in a function body is that body's alone, and + // a Scala `val` filed under its class but written in a block of the + // class body that block's (upstream #2193). + if matches!(candidate.kind, NodeKind::Variable | NodeKind::Constant) + || (candidate.kind == NodeKind::Field && candidate.language == Language::Scala) + { + return local_declaration_scope(candidate, context); + } + // A function, or a type (`case class B()` in a test method), or a + // method of such a type, declared inside a function is only in scope in + // there (upstream #2123, #2125). + if !matches!( + candidate.kind, + NodeKind::Function + | NodeKind::Method + | NodeKind::Class + | NodeKind::Struct + | NodeKind::Enum + | NodeKind::Interface + | NodeKind::Trait + | NodeKind::TypeAlias + ) { + return None; } // C and C++ have no nested named functions: a function the graph shows // inside another is an extraction artifact (a macro whose designated // initializers derail tree-sitter-c error recovery), not a scope, so it // stays reachable (`NO_NESTED_FUNCTIONS`, upstream #1708). if matches!(candidate.language, Language::C | Language::Cpp) { - return true; + return None; } - let Some(sep) = candidate.qualified_name.rfind("::") else { - return true; + lexical_scope_of(candidate, context) +} + +/// The lines a variable declaration is in scope for when it is local +/// (upstream #2193 `localDeclarationScope`): the innermost function or +/// method of its file whose lines hold its start, or, for a Scala `val` the +/// graph files under its class but written inside a block of the class body, +/// that block. `None` for a declaration at file, class or object level. C and +/// C++ nesting is not trusted (see [`is_lexically_reachable`]), and a Lua +/// global assigned inside a function is still global. +pub(crate) fn local_declaration_scope( + candidate: &Node, + context: &dyn ResolutionContext, +) -> Option<(i64, i64)> { + if matches!(candidate.language, Language::C | Language::Cpp) { + return None; + } + let compute = || { + if matches!(candidate.language, Language::Lua | Language::Luau) + && !is_lua_local(candidate, context) + { + return None; + } + let mut scope: Option<(i64, i64)> = None; + for n in &context.file_callables(&candidate.file_path).in_file_order { + if n.id == candidate.id { + continue; + } + if n.start_line > candidate.start_line + || n.end_line < candidate.start_line + || n.start_line == n.end_line + { + continue; + } + if n.start_line == candidate.start_line && n.start_column >= candidate.start_column { + continue; + } + if scope.is_none_or(|(start, end)| n.end_line - n.start_line < end - start) { + scope = Some((n.start_line, n.end_line)); + } + } + if scope.is_none() + && candidate.kind == NodeKind::Field + && candidate.language == Language::Scala + { + scope = crate::scala_scope::scala_block_of(candidate, context); + } + scope }; - let parent_qualified = &candidate.qualified_name[..sep]; - if parent_qualified.is_empty() { - return true; + compute() +} + +/// Whether a Lua declaration is a `local` one, read from its line (upstream +/// #2143 `isLuaLocal`, which #2193 reads). +pub(crate) fn is_lua_local(candidate: &Node, context: &dyn ResolutionContext) -> bool { + if !matches!( + candidate.kind, + NodeKind::Variable | NodeKind::Constant | NodeKind::Function + ) { + return false; + } + let Some(facts) = context.source_facts(&candidate.file_path) else { + return false; + }; + let Some(index) = usize::try_from(candidate.start_line) + .ok() + .and_then(|line| line.checked_sub(1)) + .filter(|&index| index < facts.line_count()) + else { + return false; + }; + facts + .line(index) + .trim_start() + .strip_prefix("local") + .is_some_and(|rest| !rest.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')) +} + +/// Whether a same-file function or method encloses `candidate`, found by +/// walking out through its qualified name ([`lexical_scope_of`]). +pub(crate) fn is_in_function_body(candidate: &Node, context: &dyn ResolutionContext) -> bool { + lexical_scope_of(candidate, context).is_some() +} + +/// The innermost function or method BODY that scopes a declaration, walking +/// out through its qualified name (`test_x::Request::User::has_perm` → +/// `test_x`, upstream #2125 `lexicalScopeOf`). A method directly on a class +/// is reachable through its instances, and an object-literal method a +/// function returns through the object: neither is scoped by the function it +/// sits in. Production contexts look the container up in the candidate's +/// own file ([`FileCallables`]), not through every node of its name +/// (W5-08). +fn lexical_scope_of(candidate: &Node, context: &dyn ResolutionContext) -> Option<(i64, i64)> { + let own = candidate.qualified_name.as_str(); + let parent = own.rsplit_once("::").map_or("", |(parent, _)| parent); + let mut qualified = own; + let tables = context.name_matcher_tables(); + while let Some((up, _)) = qualified.rsplit_once("::") { + qualified = up; + // No function or method of the project bears the name: none of the + // candidate's file does either (W5-08). + if !tables.names_a_callable(qualified, || context.has_callable_named(qualified)) { + continue; + } + let Some(container) = context.enclosing_function_lines( + &candidate.file_path, + qualified, + candidate.start_line, + candidate.end_line, + ) else { + continue; + }; + if candidate.kind == NodeKind::Method && qualified == parent { + return None; + } + return Some(container); } - let containers: Vec> = context - .get_nodes_by_qualified_name_shared(parent_qualified) + None +} + +/// The lines of the first function or method of `file_path` named +/// `qualified` whose lines hold `start..=end`, through the qualified-name +/// lookup: the default of [`ResolutionContext::enclosing_function_lines`], +/// which the per-file index of a production context must match. +pub(crate) fn enclosing_by_qualified_name( + context: &C, + file_path: &str, + qualified: &str, + start: i64, + end: i64, +) -> Option<(i64, i64)> { + context + .get_nodes_by_qualified_name_shared(qualified) .into_iter() - .filter(|p| { - p.file_path == candidate.file_path + .find(|p| { + p.file_path == file_path && matches!(p.kind, NodeKind::Function | NodeKind::Method) - && p.start_line <= candidate.start_line - && p.end_line >= candidate.end_line + && p.start_line <= start + && p.end_line >= end }) - .collect(); - if containers.is_empty() { - return true; + .map(|p| (p.start_line, p.end_line)) +} + +/// The functions and methods of one file, read once per pass (W5-08), for +/// the two walks that read them: +/// - by qualified name, each name's in the order the qualified-name lookup +/// lists them ([`crate::context`]'s `order_candidates`: start line, start +/// column, id), as line ranges, so the first that encloses a declaration +/// is the one the lookup would find ([`lexical_scope_of`]); +/// - all of them by start line ([`local_declaration_scope`], whose answer +/// no order among the same line's functions changes). +#[derive(Debug, Default)] +pub struct FileCallables { + in_file_order: Vec>, + /// Built on first use. + by_qualified_name: OnceLock>>, +} + +impl FileCallables { + /// The index of a file's functions and methods among `nodes`, which are + /// by start line (the rest are skipped). + pub fn of(nodes: &[Arc]) -> Self { + Self { + in_file_order: nodes + .iter() + .filter(|node| matches!(node.kind, NodeKind::Function | NodeKind::Method)) + .cloned() + .collect(), + ..Self::default() + } } - reference.file_path == candidate.file_path - && containers + + /// The lines of the first function or method named `qualified` whose + /// lines hold `start..=end`. + pub fn first_enclosing(&self, qualified: &str, start: i64, end: i64) -> Option<(i64, i64)> { + self.by_qualified_name + .get_or_init(|| { + let mut found: HashMap> = HashMap::new(); + for node in &self.in_file_order { + found + .entry(node.qualified_name.clone()) + .or_default() + .push(node); + } + found + .into_iter() + .map(|(name, mut list)| { + list.sort_by(|a, b| { + a.start_line + .cmp(&b.start_line) + .then(a.start_column.cmp(&b.start_column)) + .then(a.id.cmp(&b.id)) + }); + let ranges = list + .into_iter() + .map(|node| (node.start_line, node.end_line)) + .collect(); + (name, ranges) + }) + .collect() + }) + .get(qualified)? .iter() - .any(|p| reference.line >= p.start_line && reference.line <= p.end_line) + .find(|(from, to)| *from <= start && *to >= end) + .copied() + } +} + +/// The methods of one file by owner (the segment of a method's qualified +/// name before its own) and name, each the first in the file's node order +/// ([`ResolutionContext::get_nodes_in_file_shared`]), read once per pass for +/// [`inherited_class_method`] (W5-08). +#[derive(Debug, Default)] +pub struct FileMethods { + first: HashMap<(String, String), Arc>, +} + +impl FileMethods { + /// The methods among `nodes`, a file's nodes in its order. + pub fn of(nodes: &[Arc]) -> Self { + let mut first: HashMap<(String, String), Arc> = HashMap::new(); + for node in nodes.iter().filter(|node| node.kind == NodeKind::Method) { + first + .entry(( + member_owner_name(&node.qualified_name).to_string(), + node.name.clone(), + )) + .or_insert_with(|| Arc::clone(node)); + } + Self { first } + } + + /// The file's first method named `name` of `owner`. + pub fn method_of(&self, owner: &str, name: &str) -> Option> { + self.first + .get(&(owner.to_string(), name.to_string())) + .cloned() + } +} + +/// One memo of [`NameMatcherTables`]. +type Memo = std::sync::RwLock>; + +/// A name's candidates, each with its [`CandidateReach`]. +type NamedCandidates = Vec<(Arc, CandidateReach)>; + +/// The name matcher's memos for one resolution pass (W5-08): what a call +/// through a class name inherits, by the reference's language, the class +/// name and the method name. The nodes do not change during a pass, so the +/// answer does not either. +#[derive(Debug, Default)] +pub struct NameMatcherTables { + inherited: Memo<(Language, String, String), Option>>, + /// The walks from the classes of a language and a name, as far as some + /// method's search took them ([`SupertypeWalk`]). + walks: Memo<(Language, String), Arc>>, + /// The candidates of a name with their reach ([`Self::named_candidates`]). + named: Memo>, + /// Whether a function or method of the project has the qualified name. + callable_names: Memo, + /// The owner names of the project's methods of a name. + method_owners: Memo>>, + /// The files holding a method of a name. + method_files: Memo>>, + /// The object-literal holders (constants and variables) of a name, by + /// the path each is reached by. + holders: Memo>, + /// The declarations of a language and name that own methods. + owners: Memo<(Language, String), Arc>>>, +} + +/// Constants and variables by the path each is reached by, each path's in +/// the name lookup's order. +pub(crate) type HoldersByPath = HashMap>>; + +/// The most answers a memo of the pass keeps before starting over. +const INHERITED_MEMO_LIMIT: usize = 65_536; + +/// [`memo_or`] for a memo keyed by a string, read without allocating. +fn memo_or_str(memo: &Memo, key: &str, compute: impl FnOnce() -> V) -> V { + if let Some(hit) = memo + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(key) + { + return hit.clone(); + } + memo_or(memo, key.to_string(), compute) +} + +/// Read a memo of [`NameMatcherTables`], or compute and keep the answer. +fn memo_or(memo: &Memo, key: K, compute: impl FnOnce() -> V) -> V +where + K: std::hash::Hash + Eq, + V: Clone, +{ + if let Some(hit) = memo + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + { + return hit.clone(); + } + let value = compute(); + let mut memo = memo + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if memo.len() >= INHERITED_MEMO_LIMIT { + memo.clear(); + } + memo.insert(key, value.clone()); + value +} + +impl NameMatcherTables { + pub fn new() -> Self { + Self::default() + } + + /// [`inherited_class_method`] of the classes named `class` in + /// `language` for `method`, computed once per pass. + fn inherited_method( + &self, + language: Language, + class: &str, + method: &str, + compute: impl FnOnce() -> Option>, + ) -> Option> { + memo_or( + &self.inherited, + (language, class.to_string(), method.to_string()), + compute, + ) + } + + /// The walk from the classes named `class` in `language`, started once + /// per pass from `classes`. + fn supertype_walk( + &self, + language: Language, + class: &str, + classes: impl FnOnce() -> Vec>, + ) -> Arc> { + memo_or(&self.walks, (language, class.to_string()), || { + Arc::new(Mutex::new(SupertypeWalk::new(classes()))) + }) + } + + /// Whether a function or method of the project has the qualified name + /// `qualified`, computed once per pass. + fn names_a_callable(&self, qualified: &str, compute: impl FnOnce() -> bool) -> bool { + memo_or_str(&self.callable_names, qualified, compute) + } + + /// The owner names (the segment before a method's own in its qualified + /// name) of the project's methods named `name`, computed once per pass. + fn method_owners( + &self, + name: &str, + compute: impl FnOnce() -> HashSet, + ) -> Arc> { + memo_or_str(&self.method_owners, name, || Arc::new(compute())) + } + + /// The files holding a method named `name`, computed once per pass. + fn method_files( + &self, + name: &str, + compute: impl FnOnce() -> HashSet, + ) -> Arc> { + memo_or_str(&self.method_files, name, || Arc::new(compute())) + } + + /// The constants and variables named `name` of an object-literal + /// language by their paths, computed once per pass. + pub(crate) fn object_holders( + &self, + name: &str, + compute: impl FnOnce() -> HoldersByPath, + ) -> Arc { + memo_or_str(&self.holders, name, || Arc::new(compute())) + } + + /// The declarations named `name` in `language` that own methods, in the + /// name lookup's order, computed once per pass. + fn owners_named( + &self, + language: Language, + name: &str, + compute: impl FnOnce() -> Vec>, + ) -> Arc>> { + memo_or(&self.owners, (language, name.to_string()), || { + Arc::new(compute()) + }) + } + + /// The nodes named `name`, as `nodes` lists them, each with its + /// [`CandidateReach`] once read: a pass reads every reference of a name + /// against the same candidates (W5-08). + fn named_candidates( + &self, + name: &str, + nodes: impl FnOnce() -> Vec>, + ) -> Arc { + memo_or_str(&self.named, name, || { + Arc::new( + nodes() + .into_iter() + .map(|node| (node, CandidateReach::default())) + .collect(), + ) + }) + } } +/// Languages whose calls are JS/TS calls (upstream `JS_FAMILY`): Vue, Svelte +/// and Astro components' scripts and template expressions included, so a +/// bare `t('key')` in a `.vue` file resolves lexically exactly as in a `.ts` +/// one (upstream #2128). pub(crate) fn is_js_family(language: Language) -> bool { + is_js_ts(language) || matches!(language, Language::Vue | Language::Svelte | Language::Astro) +} + +/// The four script languages themselves (upstream `JS_TS`), for the rules +/// upstream keeps to them: `this.` and `this.#field` calls and +/// call-result receivers. +pub(crate) fn is_js_ts(language: Language) -> bool { matches!( language, Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx @@ -406,14 +879,17 @@ pub(crate) fn is_js_family(language: Language) -> bool { /// Method names that require receiver evidence before a JS/TS call links to /// project code (`JS_BUILTIN_METHODS`, upstream v1.6.1 #1987): Array / typed /// array and collection, String, Promise, Function, EventTarget / -/// EventEmitter and iterator methods. Sorted for binary search. -const JS_BUILTIN_METHODS: [&str; 103] = [ +/// EventEmitter and iterator methods, and fetch's Response / Request / Blob +/// bodies (upstream #2177). Sorted for binary search. +const JS_BUILTIN_METHODS: [&str; 108] = [ "add", "addEventListener", "addListener", "apply", + "arrayBuffer", "at", "bind", + "blob", "call", "catch", "charAt", @@ -440,12 +916,14 @@ const JS_BUILTIN_METHODS: [&str; 103] = [ "flat", "flatMap", "forEach", + "formData", "get", "getMaxListeners", "has", "includes", "indexOf", "join", + "json", "keys", "lastIndexOf", "listenerCount", @@ -490,6 +968,7 @@ const JS_BUILTIN_METHODS: [&str; 103] = [ "substr", "substring", "take", + "text", "then", "throw", "toArray", @@ -521,7 +1000,7 @@ fn is_js_builtin_method(name: &str) -> bool { /// ArkTS participates in module visibility even though the narrower JS call /// inference gates deliberately exclude it (upstream #1719). pub(crate) fn is_esm_module_family(language: Language) -> bool { - is_js_family(language) || language == Language::ArkTs + is_js_ts(language) || language == Language::ArkTs } /// A JS/TS/ArkTS file containing an import statement but no export is a sealed @@ -535,42 +1014,195 @@ pub(crate) fn is_esm_module_family(language: Language) -> bool { /// template literal spelling `import fake from "fake"` is data, not an import. /// The decision is memoised per file for the resolution pass. fn is_sealed_js_module(file_path: &str, context: &dyn ResolutionContext) -> bool { + if context.file_has_exported_node(file_path) { + return false; + } + let Some(facts) = context.source_facts(file_path) else { + return false; + }; + facts.js_sealed_module(is_sealed_by_text) +} + +/// The text half of [`is_sealed_js_module`]: an `import` statement and no +/// `export`, in a file with no CommonJS export. +pub(crate) fn is_sealed_by_text(facts: &crate::source_facts::SourceFacts) -> bool { + let source = facts.source(); + if !source.contains("import") { + return false; + } + // CommonJS assignments can execute inside template interpolations, + // which the blanked view hides, so this exemption reads raw source. + if cjs_export_marker().is_match(source) { + return false; + } + facts.with_ts_blanked(|code| { + js_import_marker().is_match(code) && !js_export_marker().is_match(code) + }) +} + +/// A line-initial `import` statement (`HAS_IMPORT_STATEMENT`). +pub(crate) fn js_import_marker() -> &'static Regex { static HAS_IMPORT: OnceLock = OnceLock::new(); + HAS_IMPORT.get_or_init(|| Regex::new(r#"(?m)^[ \t]*import[\s{*'"]"#).expect("js import marker")) +} + +/// A line-initial `export` or `declare global` (`HAS_ESM_EXPORT`). +pub(crate) fn js_export_marker() -> &'static Regex { static HAS_EXPORT: OnceLock = OnceLock::new(); + HAS_EXPORT.get_or_init(|| { + Regex::new(r"(?m)^[ \t]*export[\s{*]|^[ \t]*declare\s+global(?-u:\b)") + .expect("js export marker") + }) +} + +/// `module.exports` or `exports.x` / `exports[…]` (`HAS_CJS_EXPORT`). +pub(crate) fn cjs_export_marker() -> &'static Regex { static HAS_CJS_EXPORT: OnceLock = OnceLock::new(); - if context.file_has_exported_node(file_path) { + HAS_CJS_EXPORT.get_or_init(|| { + Regex::new(r"(?-u:\b)module\.exports(?-u:\b)|(?-u:\b)exports\s*[.\[]") + .expect("CommonJS export marker") + }) +} + +/// Whether `candidate` is a top-level binding of an ES module that the module +/// does not export (upstream #2162 `isUnexportedModuleBinding`): declared +/// without `export` and absent from its `export { … }`, `export default x` +/// and `export default { … }` lists, so no other file can name it. The +/// sealed-module rule covers files that export nothing; this is the same +/// boundary per symbol. Classic scripts, CommonJS, `declare global` and +/// `.d.ts` files, members of a class or namespace (qualified names), and +/// anything not declared by a statement of its own (an object literal's +/// member, `proto.x = function x() {}`) are exempt. +fn is_unexported_module_binding(candidate: &Node, context: &dyn ResolutionContext) -> bool { + if candidate.is_exported + || !matches!( + candidate.kind, + NodeKind::Function + | NodeKind::Variable + | NodeKind::Constant + | NodeKind::Class + | NodeKind::Interface + | NodeKind::TypeAlias + | NodeKind::Enum + | NodeKind::Component + ) + || candidate.qualified_name.contains("::") + || [".d.ts", ".d.cts", ".d.mts"] + .iter() + .any(|suffix| candidate.file_path.ends_with(suffix)) + { return false; } - let Some(facts) = context.source_facts(file_path) else { + let Some(facts) = context.source_facts(&candidate.file_path) else { return false; }; - facts.js_sealed_module(|facts| { - let source = facts.source(); - if !source.contains("import") { - return false; - } - // CommonJS assignments can execute inside template interpolations, - // which the blanked view hides, so this exemption reads raw source. - let has_cjs_export = HAS_CJS_EXPORT + let Some(exported) = facts.js_module_exports(read_module_exports) else { + return false; + }; + if exported.contains(&candidate.name) { + return false; + } + let Some(line) = usize::try_from(candidate.start_line) + .ok() + .and_then(|line| line.checked_sub(1)) + .and_then(|index| facts.raw_line(index)) + else { + return false; + }; + static OWN_EXPORT: OnceLock = OnceLock::new(); + static DECLARATION: OnceLock = OnceLock::new(); + // Its own line says `export` (a node's flag can miss a form). + if OWN_EXPORT + .get_or_init(|| Regex::new(r"^\s*export(?-u:\b)").expect("export line")) + .is_match(line) + { + return false; + } + // Only a declaration statement is a module binding: an object literal's + // member (a zustand store action `setZipUri: (v) => set(…)`) is reached + // through the object, and so is `proto.x = function x() {}`. + DECLARATION + .get_or_init(|| { + Regex::new( + r"^\s*(?:declare\s+)?(?:async\s+)?(?:function\*?|const|let|var|(?:abstract\s+)?class|interface|type|enum)\s", + ) + .expect("declaration line") + }) + .is_match(line) +} + +/// The names an ES module's export lists name, or `None` for a file that is +/// no ES module (no `import` / `export`, CommonJS, or `declare global`). +pub(crate) fn read_module_exports( + facts: &crate::source_facts::SourceFacts, +) -> Option> { + facts.with_ts_blanked(|code| module_exports_in(facts.source(), code)) +} + +/// [`read_module_exports`] over the file's `source` and its blanked `code`. +fn module_exports_in(source: &str, code: &str) -> Option> { + static EXPORT_LIST: OnceLock = OnceLock::new(); + static EXPORT_DEFAULT_NAME: OnceLock = OnceLock::new(); + static EXPORT_DEFAULT_OBJECT: OnceLock = OnceLock::new(); + static DECLARE_GLOBAL: OnceLock = OnceLock::new(); + static AS: OnceLock = OnceLock::new(); + static IDENTIFIER: OnceLock = OnceLock::new(); + let module = (js_import_marker().is_match(code) || js_export_marker().is_match(code)) + && !cjs_export_marker().is_match(source) + && !DECLARE_GLOBAL .get_or_init(|| { - Regex::new(r"(?-u:\b)module\.exports(?-u:\b)|(?-u:\b)exports\s*[.\[]") - .expect("CommonJS export marker") + Regex::new(r"(?-u:\b)declare\s+global(?-u:\b)").expect("declare global") }) - .is_match(source); - if has_cjs_export { - return false; + .is_match(code); + if !module { + return None; + } + let mut names = HashSet::new(); + let export_list = EXPORT_LIST.get_or_init(|| { + Regex::new(r"(?m)^[ \t]*export\s+(?:type\s+)?\{([^}]*)\}").expect("export list") + }); + let alias = AS.get_or_init(|| Regex::new(r"\s+as\s+").expect("export alias")); + for captures in export_list.captures_iter(source) { + for item in captures[1].split(',') { + let item = item.trim(); + let item = item + .strip_prefix("type") + .filter(|rest| rest.starts_with(char::is_whitespace)) + .map_or(item, str::trim_start); + let local = alias.split(item).next().unwrap_or(item).trim(); + if !local.is_empty() { + names.insert(local.to_string()); + } } - let code = facts.ts_blanked(); - HAS_IMPORT - .get_or_init(|| Regex::new(r#"(?m)^[ \t]*import[\s{*'"]"#).expect("js import marker")) - .is_match(code) - && !HAS_EXPORT - .get_or_init(|| { - Regex::new(r"(?m)^[ \t]*export[\s{*]|^[ \t]*declare\s+global(?-u:\b)") - .expect("js export marker") - }) - .is_match(code) - }) + } + let export_default_name = EXPORT_DEFAULT_NAME.get_or_init(|| { + Regex::new(r"(?m)^[ \t]*export\s+(?:default|=)\s+([A-Za-z_$][0-9A-Za-z_$]*)\s*;?\s*$") + .expect("export default name") + }); + for captures in export_default_name.captures_iter(source) { + names.insert(captures[1].to_string()); + } + // `export default { getAdapter, adapters: known }` exposes its shorthand + // and value names; so does a `return { getDefaultActivityRoute, … }` (a + // composable hands the function out through its result, upstream #2163). + let export_default_object = EXPORT_DEFAULT_OBJECT.get_or_init(|| { + Regex::new(r"(?m)^[ \t]*export\s+default\s+\{([^}]*)\}|(?-u:\b)return\s*\{([^{}]*)\}") + .expect("export default object") + }); + let identifier = IDENTIFIER + .get_or_init(|| Regex::new(r"^\s*([A-Za-z_$][0-9A-Za-z_$]*)\s*$").expect("identifier")); + for captures in export_default_object.captures_iter(code) { + let Some(items) = captures.get(1).or_else(|| captures.get(2)) else { + continue; + }; + for item in items.as_str().split(',') { + let value = item.rsplit(':').next().unwrap_or(item); + if let Some(id) = identifier.captures(value) { + names.insert(id[1].to_string()); + } + } + } + Some(names) } /// Post-pipeline visibility gate for JS/TS name targets (`isCrossFileReachable`, @@ -595,6 +1227,20 @@ pub(crate) fn is_js_name_target_visible( || !is_sealed_js_module(&candidate.file_path, context) } +/// The name-match half of upstream's `isCrossFileReachable` (#2162): an ES +/// module's top-level binding it does not export is no other file's to +/// name. Import resolution still reaches it through what the module does +/// export (`UploadApi.localHelper()` through a default-exported object). +pub(crate) fn is_name_match_reachable( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + candidate.file_path == reference.file_path + || !is_esm_module_family(candidate.language) + || !is_unexported_module_binding(candidate, context) +} + /// `^=\s*require\s*\(\s*(['"])[^'"]+\.json\1\s*\)\s*;?\s*$` (upstream), with /// the back-reference spelled out per quote. fn is_json_require_signature(signature: &str) -> bool { @@ -615,7 +1261,7 @@ fn is_json_require_signature(signature: &str) -> bool { /// Keywords after which a name starts an expression, so the call that follows /// has no receiver (`BARE_CALL_KEYWORDS`, upstream v1.6.1; `go`/`defer` are the /// Go statement forms). -const BARE_CALL_KEYWORDS: [&str; 14] = [ +const BARE_CALL_KEYWORDS: [&str; 18] = [ "return", "await", "yield", @@ -630,6 +1276,11 @@ const BARE_CALL_KEYWORDS: [&str; 14] = [ "instanceof", "go", "defer", + // Go: `if T(x)`, `for range f()` (upstream #2408). + "if", + "switch", + "for", + "range", ]; fn is_ascii_word_char(ch: char) -> bool { @@ -689,6 +1340,70 @@ fn is_receiver_less_call(reference: &RefView, context: &dyn ResolutionContext) - BARE_CALL_KEYWORDS.contains(&&before[before.len() - word_len..]) } +/// Whether the line of a bare reference shows no receiver before its name +/// (upstream `hasNoReceiverOnLine`, #2091): the name at the reference's +/// column, or ending there, else its first `name(` on the line, has no `.` +/// before it. A name not on its line at all is a link of a chain written +/// across lines. Read case-insensitively, as upstream reads it. +pub(crate) fn has_no_receiver_on_line( + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let Some(index) = usize::try_from(reference.line) + .ok() + .and_then(|line| line.checked_sub(1)) + else { + return true; + }; + let Some(facts) = context.source_facts(&reference.file_path) else { + return true; + }; + let Some(line) = facts.raw_line(index) else { + return true; + }; + let lower = line.to_ascii_lowercase(); + let name = reference.reference_name.to_ascii_lowercase(); + let column = usize::try_from(reference.column).unwrap_or(0); + let named_at = |start: usize| { + lower + .get(start..) + .is_some_and(|rest| rest.starts_with(&name)) + }; + let start = if named_at(column) { + Some(column) + } else if column >= name.len() && named_at(column - name.len()) { + Some(column - name.len()) + } else { + first_call_of(&lower, &name) + }; + let Some(start) = start else { + return false; + }; + !line[..start] + .trim_end_matches(codegraph_core::generated_header::is_js_whitespace) + .ends_with('.') +} + +/// The offset of the first `name(` in `line` that no word character or `$` +/// precedes (upstream's `(? Option { + if name.is_empty() { + return None; + } + line.char_indices().map(|(at, _)| at).find(|&at| { + if !line[at..].starts_with(name) { + return false; + } + let free = line[..at] + .chars() + .next_back() + .is_none_or(|before| !(is_ascii_word_char(before) || before == '$')); + free && line[at + name.len()..] + .trim_start_matches(codegraph_core::generated_header::is_js_whitespace) + .starts_with('(') + }) +} + /// A receiver-less JS/TS call can never bind to a method (#1714). pub(crate) fn is_bare_js_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { is_js_family(reference.language) && is_receiver_less_call(reference, context) @@ -701,27 +1416,745 @@ fn is_bare_go_call(reference: &RefView, context: &dyn ResolutionContext) -> bool reference.language == Language::Go && is_receiver_less_call(reference, context) } -/// How a Rust `calls` ref recorded under a bare name was written (KEEP-RUST, -/// P15). The extractor keeps a receiver only for an identifier, `self`, one -/// `self.` hop and a `path::call()` receiver; any other receiver — -/// `root().join(x)`, a deeper field chain, `vec![…]`, `?`, `.await`, indexing, a -/// chain broken across lines — leaves the bare method name, with the ref's -/// column at the start of the whole call expression. The source tells the two -/// apart. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum RustCallShape { - /// `join(x)`: neither receiver nor path, so a free function, never a method. - Bare, - /// `root().join(x)`: a method call whose receiver the extractor dropped. - Method, -} - -/// How far past the ref's column a dropped receiver's `.name(` is looked for. -const RUST_RECEIVER_WINDOW: usize = 4096; - -/// The shape of a Rust `calls` ref whose name has neither `.` nor `::`, read from -/// its source: [`RustCallShape::Bare`] when the call expression starts with the -/// name and a `(` or `::<` follows, [`RustCallShape::Method`] when it starts with +/// Whether a Go reference's name is written bare, so that Go reads it from +/// the reference's own package (upstream `isGoBareName`, #2408): a type `Node` +/// (variadic `...Node` too), a composite literal `&Event{}`, a call +/// `Walk(v, n)` or a conversion `(*Block)(pb)`. Not `parser.Node` or +/// `...chunks.Meta`, written through an import, nor a name reached through a +/// value: `err[i].Error()`, a `.String()` chained onto the line above. +pub(crate) fn is_go_bare_name(reference: &RefView, context: &dyn ResolutionContext) -> bool { + let name = reference.reference_name.as_str(); + if reference.language != Language::Go || !is_go_identifier(name) { + return false; + } + if reference.reference_kind == EdgeKind::Calls && is_receiver_less_call(reference, context) { + return true; + } + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let Some(line) = usize::try_from(reference.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .and_then(|index| facts.raw_line(index)) + else { + return false; + }; + let column = usize::try_from(reference.column.max(0)).unwrap_or(0); + let Some(rest) = line.get(column..) else { + return false; + }; + // A call's column is its expression's: only a conversion's parenthesized + // type is still bare there. + if reference.reference_kind == EdgeKind::Calls { + let Some(inner) = rest.strip_prefix('(') else { + return false; + }; + let inner = inner.trim_start(); + let inner = inner.strip_prefix('*').unwrap_or(inner).trim_start(); + let Some(after) = inner.strip_prefix(name) else { + return false; + }; + return after + .trim_start() + .strip_prefix(')') + .is_some_and(|after| after.trim_start().starts_with('(')); + } + if rest.starts_with(name) { + let before = line[..column].trim_end(); + return !before.ends_with('.') || before.ends_with("..."); + } + // A name recorded away from its spelling (a route's handler, at the start + // of its line): its spelling there, outside strings. + let code = blank_go_strings(line); + code.match_indices(name).any(|(at, _)| { + let free = code[..at] + .bytes() + .next_back() + .is_none_or(|byte| !(byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'.')); + free && code[at + name.len()..] + .bytes() + .next() + .is_none_or(|byte| !(byte.is_ascii_alphanumeric() || byte == b'_')) + }) +} + +fn is_go_identifier(name: &str) -> bool { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +/// `line` with the contents of its Go string literals blanked (upstream's +/// `/"(?:[^"\\]|\\.)*"|`[^`]*`/g` replaced by spaces). +fn blank_go_strings(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut chars = line.char_indices().peekable(); + while let Some((_, c)) = chars.next() { + match c { + '"' => { + let mut literal = String::from('"'); + let mut closed = false; + while let Some((_, next)) = chars.next() { + literal.push(next); + if next == '\\' { + if let Some((_, escaped)) = chars.next() { + literal.push(escaped); + } + } else if next == '"' { + closed = true; + break; + } + } + if closed { + out.extend(std::iter::repeat_n(' ', literal.chars().count())); + } else { + out.push_str(&literal); + } + } + '`' => { + let mut literal = String::from('`'); + let mut closed = false; + for (_, next) in chars.by_ref() { + literal.push(next); + if next == '`' { + closed = true; + break; + } + } + if closed { + out.extend(std::iter::repeat_n(' ', literal.chars().count())); + } else { + out.push_str(&literal); + } + } + _ => out.push(c), + } + } + out +} + +/// Whether a receiver written as `Name` is a type in `language`'s conventions +/// (upstream #2166 `namesExternalType`). Not in Go (an exported package +/// variable is `FormPost`), nor C, C++ (CUDA and Metal included) or Rust +/// (their type paths use `::`); in Pascal every identifier is capitalized, so only Delphi's type +/// prefixes (`TFoo`, `EFoo`, `IFoo`) and `Exception` count. +fn names_external_type(receiver: &str, language: Language) -> bool { + let mut bytes = receiver.bytes(); + if !bytes.next().is_some_and(|first| first.is_ascii_uppercase()) + || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return false; + } + if language == Language::Pascal { + let prefixed = receiver.len() >= 2 + && matches!(receiver.as_bytes()[0], b'T' | b'E' | b'I') + && receiver.as_bytes()[1].is_ascii_uppercase(); + return prefixed || receiver == "Exception"; + } + // Rust: `Vec::new()`, `String::from(…)`, `Default::default()` — but + // `Self::` is the impl's own type, and a SCREAMING_CASE receiver + // (`REQ_ID.scope(…)`) a static (upstream #2177). + if language == Language::Rust { + return receiver != "Self" && receiver.bytes().any(|byte| byte.is_ascii_lowercase()); + } + // CUDA and Metal sources are extracted as C++. + !matches!(language, Language::Go | Language::C | Language::Cpp) +} + +/// A class, struct, union or interface, or a Scala `object` (upstream +/// `isMethodOwnerKind`). +fn is_method_owner(node: &Node) -> bool { + matches!( + node.kind, + NodeKind::Class | NodeKind::Struct | NodeKind::Union | NodeKind::Interface + ) || crate::types::is_scala_singleton(node) +} + +/// A type name that reads as a type (capitalized) yet names no class, +/// struct or interface of the reference's language family the project +/// declares: one from outside the project (upstream #2169). +fn is_undeclared_type_name( + type_name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + type_name.starts_with(|c: char| c.is_ascii_uppercase()) + && !context + .get_nodes_by_name_shared(type_name) + .iter() + .any(|n| is_method_owner(n) && same_language_family(n.language, reference.language)) +} + +/// Words that end a C++ declarator scan without naming a type (upstream +/// `CPP_NON_TYPE_TOKENS`). +const CPP_NON_TYPE_TOKENS: &[&str] = &[ + "return", + "if", + "else", + "for", + "while", + "do", + "switch", + "case", + "default", + "break", + "continue", + "goto", + "throw", + "new", + "delete", + "co_await", + "co_yield", + "co_return", + "static_cast", + "const_cast", + "dynamic_cast", + "reinterpret_cast", + "sizeof", + "alignof", + "typeid", + "and", + "or", + "not", + "xor", +]; + +/// The type a C++ declared type names, as upstream's `normalizeCppTypeName` +/// reads it: [`normalize_cpp_type_name`]'s reading, its last `::` segment +/// kept (`const SkipList&` → `SkipList`). +fn cpp_declared_type_name(declared: &str) -> Option { + normalize_cpp_type_name(declared) + .map(|normalized| cpp_last_segment(&normalized).trim().to_string()) +} + +/// The simple name of the type a member belongs to: the last `::` / `.` +/// segment before the member's own (`THorseCore` of `THorseCore::Get`). +fn member_owner_name(qualified_name: &str) -> &str { + let Some(cut) = qualified_name.rfind("::").filter(|&cut| cut > 0) else { + return ""; + }; + qualified_name[..cut] + .rsplit("::") + .next() + .and_then(|segment| segment.rsplit('.').next()) + .unwrap_or_default() +} + +/// A method named `name` on a supertype of the walk's classes, nearest +/// first, at most five levels up (upstream #2180 `inheritedClassMethod`): +/// the first declaration the walk visits with such a method. +fn inherited_class_method( + walk: &Mutex, + name: &str, + context: &dyn ResolutionContext, +) -> Option> { + // Only a declaration some method of the name names as its owner can hold + // it: the others' files are not read (W5-08). + let owners = context.name_matcher_tables().method_owners(name, || { + context + .get_nodes_by_name_shared(name) + .iter() + .filter(|node| node.kind == NodeKind::Method) + .map(|node| member_owner_name(&node.qualified_name).to_string()) + .collect() + }); + if owners.is_empty() { + return None; + } + let mut walk = walk.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let method = |decl: &Arc| { + if !owners.contains(decl.name.as_str()) { + return None; + } + context + .file_methods(&decl.file_path) + .method_of(&decl.name, name) + }; + if let Some(found) = walk.visited.iter().find_map(method) { + return Some(found); + } + loop { + let from = walk.visited.len(); + if !walk.step(context) { + return None; + } + if let Some(found) = walk.visited[from..].iter().find_map(method) { + return Some(found); + } + } +} + +/// The walk of upstream #2180 from the classes of one language and name, +/// level by level up to five: each class's declared supertypes, each name's +/// declarations of the class's language that own methods, every declaration +/// once. The walk stops at the first declaration with the method it looks +/// for; the declarations it visits before that are the same, in the same +/// order, whatever the method, so the declarations visited so far serve +/// every method name's search, and a search that finds none there carries +/// the walk on, one class at a time, as far as it needs (W5-08). +#[derive(Debug)] +pub(crate) struct SupertypeWalk { + /// The declarations visited so far, in the walk's order. + visited: Vec>, + seen: std::collections::HashSet, + /// The languages and supertype names the walk has taken the + /// declarations of: every declaration of one met again is seen already. + expanded: std::collections::HashSet<(Language, String)>, + /// The level being expanded, the next of its classes, and the next level. + frontier: Vec>, + cursor: usize, + next: Vec>, + level: usize, +} + +impl SupertypeWalk { + fn new(classes: Vec>) -> Self { + Self { + visited: Vec::new(), + seen: classes.iter().map(|class| class.id.clone()).collect(), + expanded: std::collections::HashSet::new(), + frontier: classes, + cursor: 0, + next: Vec::new(), + level: 0, + } + } + + /// Expand the next class: its supertypes' declarations join the visited + /// ones. `false` once the walk is done. + fn step(&mut self, context: &dyn ResolutionContext) -> bool { + let tables = context.name_matcher_tables(); + if self.cursor == self.frontier.len() { + self.level += 1; + self.frontier = std::mem::take(&mut self.next); + self.cursor = 0; + } + if self.level >= 5 || self.frontier.is_empty() { + return false; + } + let class = Arc::clone(&self.frontier[self.cursor]); + self.cursor += 1; + for supertype in crate::class_heads::declared_supertypes(&class, context) { + // A name's declarations of the language, once taken, are all + // seen: the name met again adds none. + let key = (class.language, supertype); + if self.expanded.contains(&key) { + continue; + } + let supertype = &key.1; + // The name's declarations of the class's language that own + // methods, in the name lookup's order, read once per pass. + let owners = tables.owners_named(class.language, supertype, || { + context + .get_nodes_by_name_shared(supertype) + .into_iter() + .filter(|decl| decl.language == class.language && is_method_owner(decl)) + .collect() + }); + for decl in owners.iter() { + if !self.seen.insert(decl.id.clone()) { + continue; + } + self.visited.push(Arc::clone(decl)); + self.next.push(Arc::clone(decl)); + } + self.expanded.insert(key); + } + true + } +} + +/// A request handler the web framework dispatches to: a Django / DRF / +/// Flask view's `get` / `post` / …, a controller's `index` / `store` / +/// `update` / `destroy` (upstream #2175 `DISPATCHED_OWNER` / +/// `DISPATCHED_ACTIONS`). Nothing calls one through an instance by name. +fn is_dispatched_handler(method: &Node) -> bool { + const ACTIONS: [&str; 17] = [ + "get", + "post", + "put", + "patch", + "delete", + "head", + "options", + "index", + "show", + "store", + "update", + "destroy", + "create", + "edit", + "list", + "retrieve", + "partial_update", + ]; + const OWNERS: [&str; 6] = [ + "View", + "ViewSet", + "APIView", + "Controller", + "Endpoint", + "ViewMixin", + ]; + ACTIONS.contains(&method.name.as_str()) && { + let owner = member_owner_name(&method.qualified_name); + OWNERS.iter().any(|suffix| owner.ends_with(suffix)) + } +} + +/// A test double's word: Fake…, Mock…, Mocked…, Stub…, Dummy…, Spy… (upstream +/// `TEST_DOUBLE_OWNER`, matched per split word). +fn is_test_double_word(word: &str) -> bool { + ["fake", "mock", "mocked", "stub", "dummy", "spy"] + .iter() + .any(|double| word.eq_ignore_ascii_case(double)) +} + +/// The link of a dotted receiver its value is named after: the last, or for a +/// constant (`InitializationPhase.CONTROLLERS`) the type it belongs to +/// (upstream `receiverLink`). +fn receiver_link(receiver: &str) -> &str { + let mut links = receiver.rsplit('.'); + let last = links.next().unwrap_or(receiver); + let constant = last.len() >= 2 + && last.starts_with(|c: char| c.is_ascii_uppercase()) + && last + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_'); + match links.next() { + Some(owner) if constant => owner, + _ => last, + } +} + +/// Methods of .NET's base types, collections, streams, strings, LINQ and +/// reflection (upstream #2171 `CSHARP_STD_METHODS`): names a project type +/// overrides or wraps, which a call through an untyped receiver means only +/// when the receiver is named after the owner. +const CSHARP_STD_METHODS: [&str; 108] = [ + "ToString", + "Equals", + "GetHashCode", + "GetType", + "CompareTo", + "Add", + "AddRange", + "Remove", + "RemoveAt", + "RemoveAll", + "Contains", + "ContainsKey", + "ContainsValue", + "Clear", + "Insert", + "IndexOf", + "CopyTo", + "ToArray", + "ToList", + "ToDictionary", + "GetEnumerator", + "MoveNext", + "Reset", + "TryGetValue", + "GetValueOrDefault", + "TryAdd", + "Write", + "WriteLine", + "WriteAsync", + "WriteLineAsync", + "Read", + "ReadAsync", + "ReadLine", + "ReadToEnd", + "Flush", + "FlushAsync", + "Close", + "Dispose", + "DisposeAsync", + "Parse", + "TryParse", + "Format", + "Join", + "Split", + "Replace", + "Substring", + "Trim", + "TrimStart", + "TrimEnd", + "StartsWith", + "EndsWith", + "ToUpper", + "ToLower", + "ToUpperInvariant", + "ToLowerInvariant", + "Select", + "Where", + "First", + "FirstOrDefault", + "Single", + "SingleOrDefault", + "Last", + "LastOrDefault", + "Any", + "All", + "Count", + "Sum", + "Max", + "Min", + "OrderBy", + "OrderByDescending", + "GroupBy", + "Skip", + "Take", + "Distinct", + "Concat", + "Cast", + "OfType", + "Aggregate", + "Invoke", + "DynamicInvoke", + "GetMethod", + "GetProperty", + "GetField", + "GetConstructor", + "GetCustomAttributes", + "GetGenericArguments", + "MakeGenericType", + "IsAssignableFrom", + "ConfigureAwait", + "Wait", + "ContinueWith", + "Append", + "AppendLine", + "Peek", + "Push", + "Pop", + "Enqueue", + "Dequeue", + "HasFlag", + "Find", + "FindAll", + "ForEach", + "Sort", + "Reverse", + "Clone", + "Seek", + "SetLength", +]; + +/// The standard-library method names of a language whose untyped-receiver +/// guesses need the receiver to name the owner (upstream #2171 +/// `stdMethodNames`, without Go's set, which W4-GO-03 brings). +fn is_std_method_name(language: Language, name: &str) -> bool { + match language { + Language::CSharp => CSHARP_STD_METHODS.contains(&name), + Language::Rust => is_rust_std_method(name), + Language::Kotlin => crate::kotlin_scope::is_kotlin_std_method(name), + Language::Go => crate::go_calls::is_go_std_method(name), + Language::Dart => crate::dart_scope::DART_STD_METHODS.contains(&name), + _ => false, + } +} + +/// Whether a receiver is named after the owner of `method`, for a Dart +/// extension's member the type it is `on` (upstream #2207 +/// `receiverNamesOwner`): getx's `ext.endsWith(".avi")` on a String shares a +/// word with `RxStringExt`, none with its `Rx`. +fn receiver_names_owner(receiver: &str, method: &Node, context: &dyn ResolutionContext) -> bool { + if let Some(qualified_name) = crate::dart_scope::dart_receiver_owner(method, context) { + let on = Node { + qualified_name, + ..method.clone() + }; + return crate::kotlin_scope::shares_receiver_word(receiver, &on); + } + crate::kotlin_scope::shares_receiver_word(receiver, method) +} + +/// A method of a test double (`MockedResponse`, `_FakeHTTPResponse`) the +/// receiver and the calling file never name (upstream #2197 +/// `isUnnamedTestDouble`). +fn is_unnamed_test_double( + method: &Node, + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let owner = member_owner_name(&method.qualified_name); + if owner.is_empty() + || !split_camel_case(owner) + .iter() + .any(|w| is_test_double_word(w)) + { + return false; + } + if split_camel_case(receiver_link(receiver)) + .iter() + .any(|w| is_test_double_word(w)) + { + return false; + } + !context + .read_file_shared(&reference.file_path) + .is_some_and(|text| text.contains(owner)) +} + +/// Languages whose identifiers resolve regardless of case (upstream +/// `CASE_INSENSITIVE_LANGUAGES`: PHP, Pascal/Delphi, CFML; COBOL is not +/// extracted, and VB.NET joins with its extractor). +pub const CASE_INSENSITIVE_LANGUAGES: [Language; 3] = + [Language::Php, Language::Pascal, Language::Cfml]; + +/// Whether `language` is one of [`CASE_INSENSITIVE_LANGUAGES`]. +pub(crate) fn is_case_insensitive_language(language: Language) -> bool { + CASE_INSENSITIVE_LANGUAGES.contains(&language) +} + +/// What only exists inside a type, reachable through a receiver alone +/// (upstream #2120 `TYPE_MEMBER_KINDS`). +fn is_type_member_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Method | NodeKind::Property | NodeKind::Field | NodeKind::EnumMember + ) +} + +/// The first import binding the file declares under `name`. +fn import_binding_named( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + context + .get_import_mappings(&reference.file_path, reference.language) + .into_iter() + .find(|mapping| mapping.local_name == name) +} + +/// Does the file bind `name` by importing it from outside the repository +/// (upstream #2120 `isOutOfRepoBinding`)? +fn is_out_of_repo_binding( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + import_binding_named(name, reference, context).is_some_and(|mapping| { + crate::import_resolver::is_out_of_repo_import( + &mapping.source, + &reference.file_path, + reference.language, + context, + ) + }) +} + +/// Is the root of a member call's receiver (`z` in `z.string`, `ns` in +/// `ns.util.fn`) an import binding that is a module namespace, or one from +/// outside the repository (upstream #2120 `isImportedModuleReceiver`)? +fn is_imported_module_receiver( + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let root = receiver.split('.').next().unwrap_or(receiver); + import_binding_named(root, reference, context).is_some_and(|mapping| { + mapping.is_namespace + || crate::import_resolver::is_out_of_repo_import( + &mapping.source, + &reference.file_path, + reference.language, + context, + ) + }) +} + +/// Is the root of a member call's receiver (`CameraManager` in +/// `CameraManager.x`) one of the file's imports (upstream #2134 +/// `isImportBinding`)? +pub(crate) fn is_import_binding( + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let root = receiver.split('.').next().unwrap_or(receiver); + import_binding_named(root, reference, context).is_some() +} + +/// A method a Vue Options API component declares for itself: +/// `index::handleLogin` in `index.vue` (upstream #2140). +fn is_vue_component_method(node: &Node) -> bool { + if node.kind != NodeKind::Method || !node.file_path.ends_with(".vue") { + return false; + } + let base = node.file_path.rsplit('/').next().unwrap_or(&node.file_path); + let stem = base.strip_suffix(".vue").unwrap_or(base); + node.qualified_name + .strip_prefix(stem) + .and_then(|rest| rest.strip_prefix("::")) + .is_some_and(|name| name == node.name) +} + +/// Whether `reference` is written as `this.(` in the file that declares +/// `node`. +fn is_this_call_in_own_file( + node: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if node.file_path != reference.file_path { + return false; + } + let Some(line) = context + .source_facts(&reference.file_path) + .and_then(|facts| { + usize::try_from(reference.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .and_then(|index| facts.raw_line(index).map(str::to_string)) + }) + else { + return false; + }; + Regex::new(&format!( + r"(?-u:\b)this\s*\??\.\s*{}\s*\(", + regex::escape(&node.name) + )) + .is_ok_and(|pattern| pattern.is_match(&line)) +} + +/// A plain JS identifier, `/^[A-Za-z_$][\w$]*$/`. +pub(crate) fn is_simple_js_identifier(name: &str) -> bool { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || matches!(first, b'_' | b'$')) + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')) +} + +/// How a Rust `calls` ref recorded under a bare name was written (KEEP-RUST, +/// P15). The extractor keeps a receiver only for an identifier, `self`, one +/// `self.` hop and a `path::call()` receiver; any other receiver — +/// `root().join(x)`, a deeper field chain, `vec![…]`, `?`, `.await`, indexing, a +/// chain broken across lines — leaves the bare method name, with the ref's +/// column at the start of the whole call expression. The source tells the two +/// apart. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RustCallShape { + /// `join(x)`: neither receiver nor path, so a free function, never a method. + Bare, + /// `root().join(x)`: a method call whose receiver the extractor dropped. + Method, +} + +/// How far past the ref's column a dropped receiver's `.name(` is looked for. +const RUST_RECEIVER_WINDOW: usize = 4096; + +/// The shape of a Rust `calls` ref whose name has neither `.` nor `::`, read from +/// its source: [`RustCallShape::Bare`] when the call expression starts with the +/// name and a `(` or `::<` follows, [`RustCallShape::Method`] when it starts with /// something else and a `.name(` follows within [`RUST_RECEIVER_WINDOW`]. /// `None` — no source, a column off its line, neither pattern — keeps every /// gate that reads the shape inert. @@ -817,7 +2250,7 @@ fn rust_method_call_follows(text: &str, name: &str) -> bool { /// reached through call chains. A unique project method that shares one of /// these names is no evidence of the receiver's type: `root().join(name)` is /// `Path::join`. Sorted for binary search; changing it is a resolution change. -const RUST_STD_METHODS: [&str; 307] = [ +const RUST_STD_METHODS: [&str; 309] = [ "abs", "all", "ancestors", @@ -894,6 +2327,7 @@ const RUST_STD_METHODS: [&str; 307] = [ "extend", "extend_from_slice", "extension", + "failure", "fetch_add", "fetch_sub", "file_name", @@ -1072,6 +2506,7 @@ const RUST_STD_METHODS: [&str; 307] = [ "step_by", "strip_prefix", "strip_suffix", + "success", "sum", "swap", "take", @@ -1127,6 +2562,18 @@ const RUST_STD_METHODS: [&str; 307] = [ "zip", ]; +/// Whether `receiver` is a Rust module path, every segment lowercase +/// (upstream `/^[a-z_][\w]*(?:::[a-z_]\w*)*$/`): `task`, `io`, `std::io`. +fn is_rust_module_path(receiver: &str) -> bool { + receiver.split("::").all(|segment| { + let mut bytes = segment.bytes(); + bytes + .next() + .is_some_and(|first| first.is_ascii_lowercase() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }) +} + fn is_rust_std_method(name: &str) -> bool { RUST_STD_METHODS.binary_search(&name).is_ok() } @@ -1393,6 +2840,19 @@ pub fn match_by_exact_name( context: &dyn ResolutionContext, ) -> Option { let bare_js = is_bare_js_call(reference, context); + let bare_php = crate::php_scope::is_bare_php_call(reference, context); + let lua_bare = crate::lua_scope::is_lua_bare_call(reference); + // A type, a value or an import the file binds from a package outside the + // repository names nothing in it, whatever kind of reference it is + // (upstream #2134). + if !bare_js + && is_js_family(reference.language) + && reference.reference_kind != EdgeKind::Calls + && is_simple_js_identifier(&reference.reference_name) + && is_out_of_repo_binding(&reference.reference_name, reference, context) + { + return None; + } // A bare call bound to a store action (#1862) resolves inside that store, // ahead of any same-named function. if bare_js @@ -1400,6 +2860,20 @@ pub fn match_by_exact_name( { return Some(action); } + // A name destructured from a call's result is what the callee returns + // under that key (upstream #2163). + if bare_js + && let Some(returned) = + crate::destructured_calls::match_destructured_call_result(reference, context) + { + return Some(returned); + } + // `import { useQuery } from '@tanstack/react-query'`, a CommonJS + // `require('supertest')`: the bare call means the package's, and no + // same-named project symbol (upstream #2120). + if bare_js && is_out_of_repo_binding(&reference.reference_name, reference, context) { + return None; + } // KEEP-RUST (P15): a bare-named Rust call's shape, read from its source. A // method call whose receiver the extractor dropped never binds a std // method's name (R3). @@ -1409,7 +2883,14 @@ pub fn match_by_exact_name( } let rust_bare = rust_shape == Some(RustCallShape::Bare); let rust_method = rust_shape == Some(RustCallShape::Method); - let same_name = context.get_nodes_by_name_shared(&reference.reference_name); + // A bare Rust name reaches what is in scope (upstream #2124, #2214). + let rust_scoped = crate::rust_scope::is_rust_bare_identifier(reference); + let named = context + .name_matcher_tables() + .named_candidates(&reference.reference_name, || { + context.get_nodes_by_name_shared(&reference.reference_name) + }); + let same_name = named.iter().map(|(node, _)| node); // `NAME(...)` where NAME is a function-like macro somewhere in the project // is an expansion or a call to a same-named function — never the macro // itself (#1839), and never a type that happens to share the name (#2070: @@ -1419,13 +2900,24 @@ pub fn match_by_exact_name( let macro_call = reference.reference_kind == EdgeKind::Calls && matches!(reference.language, Language::C | Language::Cpp) && same_name - .iter() + .clone() .any(|n| crate::c_macro_visibility::is_define_constant(n)); - let reachable: Vec> = apply_language_gate(same_name, reference) - .into_iter() - .filter(|n| !macro_call || matches!(n.kind, NodeKind::Function | NodeKind::Method)) + drop(same_name); + let gated = reference.reference_kind == EdgeKind::References; + let reachable: Vec> = named + .iter() + // The language gate ([`apply_language_gate`]). + .filter(|(n, _)| !gated || same_language_family(n.language, reference.language)) + // An `import` node is a statement, not a definition: a reference never + // binds one by name, its own or another file's, and an import named + // like a definition never makes that definition look ambiguous + // (upstream #965, `0a91d0f5`). Linking an import to what it names is + // the import resolver's job. + .filter(|(n, _)| n.kind != NodeKind::Import) + .filter(|(n, _)| !macro_call || matches!(n.kind, NodeKind::Function | NodeKind::Method)) // Nested locals are only reachable from inside their container (#1230). - .filter(|n| is_lexically_reachable(n, reference, context)) + .filter(|(n, reach)| is_reachable_within(n, reference, context, reach)) + .map(|(n, _)| Arc::clone(n)) .collect(); // A same-named non-type is not a supertype, and a type member is not @@ -1433,12 +2925,134 @@ pub fn match_by_exact_name( // winner afterwards — is what lets a legitimate supertype OUTRANK a // same-named enum member instead of the whole reference being dropped. let bare_go = is_bare_go_call(reference, context); + // A plain R call is a function call, never an object's method (upstream + // #2177). + let bare_r = crate::r_scope::is_bare_r_call(reference, context); + // A bare Dart call stays in the class hierarchy it is written in + // (upstream #2145). + let dart_bare = crate::dart_scope::is_dart_bare_call(reference, context); + // A bare Solidity call stays in the `is` chain of the contract it is + // written in (upstream #2178). + let solidity_bare = crate::solidity_scope::is_receiver_less_solidity_call(reference, context); + // A Dart type name — a declared type, a supertype, the `Color` of + // `Color.red` — is in scope as a call is, or through its import prefix + // (upstream #2386). + let dart_type_ref = crate::dart_libraries::is_dart_type_ref(reference); + let dart_prefix = if dart_type_ref { + crate::dart_libraries::dart_import_prefix_of(reference, context) + } else { + None + }; + // A Python call's shape at its column decides what it can mean (upstream + // #2125): a bare call no method, a chained one a member of its last link. + let python_shape = crate::python_values::python_call_shape(reference, context); + // An Objective-C name in C call syntax calls a function, a block or a + // function pointer, never a method or property; a message to `self` or + // `super` reaches the sender's hierarchy, past the sender for `super` + // (upstream #2150, #2179). + let objc_shape = objc_call_shape(reference, context); + let objc_c_call = objc_shape == Some(ObjcCallShape::CCall); + let objc_send = objc_send_target(objc_shape); + let objc_hierarchy = OnceCell::new(); + // A bare Java call reaches a method in scope (upstream #2126), and a bare + // Java type name a type in reach (upstream #2236), both read once. + let java_bare = crate::java_scope::is_java_bare_call(reference); + let java_call_scope = OnceCell::new(); + let java_type_site = crate::java_scope::JavaTypeSite::new(reference); + // A bare Ruby call inside a class reaches its ancestry (upstream #2147), + // a bare CFML call in a component its `extends` chain (#2148). + let ruby_bare = crate::ruby_scope::is_ruby_bare_call(reference); + let cfml_bare = crate::cfml_scope::is_cfml_bare_call(reference); + // A Kotlin call reaches a top-level declaration only where it is visible + // (upstream #2146), a bare one only a member in reach (#2172, #2206, + // #2232), and a standard-library name on an untyped chain link only a + // member its receiver names (#2168). + let kotlin_call = crate::kotlin_scope::is_kotlin_call(reference); + let kotlin_bare = + kotlin_call && crate::kotlin_scope::is_receiver_less_kotlin_call(reference, context); + let kotlin_site = crate::kotlin_scope::KotlinSite::new(reference); + let kotlin_std_chain = crate::kotlin_scope::std_chain_receiver(reference, context); + let scala_bare = crate::scala_scope::is_scala_bare_call(reference); + let csharp_type_ref = crate::csharp_types::is_csharp_type_ref(reference, context); + // A bare C# name whose line shows no receiver (upstream #2151). + let csharp_bare = crate::csharp_types::is_csharp_bare_name(reference) + && has_no_receiver_on_line(reference, context); + // How a bare-named Swift call is written decides what it can mean + // (upstream #2170). + let swift_shape = crate::swift_scope::swift_call_shape(reference, context); + // How a bare-named Go call is written decides what it can mean (upstream + // #2167, #2177). + let go_shape = crate::go_calls::go_call_shape(reference, context); + // A PHP call through `$this`, `self`, `static` or `parent` stays in the + // class hierarchy (upstream #2142). + let php_self = crate::php_scope::php_self_scope(reference, context); let candidates: Vec> = reachable .iter() + .filter(|n| { + python_shape.as_ref().is_none_or(|shape| { + crate::python_values::fits_python_call_shape(n, shape, reference, context) + }) + }) + .filter(|n| !(objc_c_call && crate::objc_scope::is_objc_member_kind(n.kind))) + .filter(|n| { + objc_send.is_none_or(|to_super| { + crate::objc_scope::is_objc_self_send_target( + n, + reference, + objc_hierarchy + .get_or_init(|| crate::objc_scope::objc_hierarchy_at(reference, context)) + .as_deref(), + to_super, + ) + }) + }) .filter(|n| node_is_eligible_target(reference.reference_kind, n)) - // A receiver-less JS/TS, Go or Rust call cannot reach a method (#1714, - // #1857; P15 R1). - .filter(|n| !((bare_js || bare_go || rust_bare) && n.kind == NodeKind::Method)) + // 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 (upstream #2123). Nor does a kind-projector placeholder + // (`F[*]`, `G[?]`, upstream #2217). + .filter(|n| { + !(reference.language == Language::Scala + && reference.reference_kind == EdgeKind::References + && (reference + .reference_name + .starts_with(|c: char| c.is_ascii_uppercase()) + || crate::scala_scope::is_symbolic_name(&reference.reference_name)) + && matches!(n.kind, NodeKind::Method | NodeKind::Function)) + }) + // A receiver-less JS/TS or Go call cannot reach a member of a type: a + // method (#1714, #1857), nor a property, field or enum case (upstream + // #2120: mocha's global `it(…)` bound an interface's `it` property). A + // bare Rust call cannot reach a method (P15 R1). + .filter(|n| !((bare_js || bare_go || bare_r) && is_type_member_kind(n.kind))) + // A bare PHP call is a function call: nothing else is callable without + // a receiver (upstream #2103). + .filter(|n| !(bare_php && n.kind != NodeKind::Function)) + // Nor is a table's method (`function M.x`, `function M:x`) in Lua, + // without its table (upstream #2202). + .filter(|n| !(lua_bare && n.kind == NodeKind::Method)) + .filter(|n| { + go_shape + .as_ref() + .is_none_or(|shape| crate::go_calls::is_go_call_target(n, shape)) + }) + // `$this->assertEquals()` in a test of a vendor base class is no + // comparator's method (upstream #2142). + .filter(|n| { + php_self + .as_ref() + .is_none_or(|scope| n.kind == NodeKind::Method && scope.admits(n, context)) + }) + // A Vue component's own method is `this.m()` inside that component, + // not `this.$refs['input'].click()` on an element another component + // renders (upstream #2140). + .filter(|n| { + !(reference.reference_kind == EdgeKind::Calls + && is_js_family(reference.language) + && is_vue_component_method(n) + && !is_this_call_in_own_file(n, reference, context)) + }) + .filter(|n| !(rust_bare && n.kind == NodeKind::Method)) // A Rust method call whose receiver was dropped reaches only a method: // never a function, type, enum member or import (P15 R2). .filter(|n| !rust_method || n.kind == NodeKind::Method) @@ -1450,6 +3064,11 @@ pub fn match_by_exact_name( && is_rust_prelude_variant(&reference.reference_name) && !rust_variant_import_in_scope(reference, context)) }) + // Another file's item only through a `use` that binds it or a glob + // over its module, a prelude name only through a project `use`, an + // enum variant only through a `use` of it and its enum, a path's + // name only in that path's module (upstream #2124, #2214). + .filter(|n| !rust_scoped || crate::rust_scope::is_rust_name_in_scope(n, reference, context)) // A name the file binds itself shadows every other file's symbol of // that name, so a bare call has no cross-file candidate. .filter(|n| { @@ -1461,11 +3080,164 @@ pub fn match_by_exact_name( context, )) }) - .cloned() - .collect(); - - if candidates.is_empty() { - return None; + // halo's tests' Mockito `verify(…)` is no `EmailVerificationService` + // method (upstream #2126). + .filter(|n| { + !(java_bare + && n.kind == NodeKind::Method + && !java_call_scope + .get_or_init(|| crate::java_scope::JavaCallScope::of(reference, context)) + .reaches(n, reference)) + }) + // rubocop's `format(…)`, Kernel's, is no LSP runtime's `format`: a + // bare call in a class reaches only the class's ancestry (upstream + // #2147). + .filter(|n| { + !(ruby_bare + && n.kind == NodeKind::Method + && !crate::ruby_scope::is_ruby_method_in_scope(n, reference, context)) + }) + // coldbox's `now()`, the built-in, is no date helper's `now`: a bare + // call in a component reaches only its `extends` chain (upstream + // #2148). + .filter(|n| { + !(cfml_bare + && n.kind == NodeKind::Method + && !crate::cfml_scope::is_cfml_method_in_scope(n, reference, context)) + }) + // OpenZeppelin's `_msgSender()` in a Context-derived Governor is + // Context's, no ERC2771Context override: a bare Solidity call reaches + // a function of the contract around it or of one it inherits + // (upstream #2178). + .filter(|n| { + !(solidity_bare + && !crate::solidity_scope::is_solidity_member_in_scope(n, reference, context)) + }) + // riverpod's tests' `test(…)`, package:test's function, is no + // `ProviderContainer.test`: a bare Dart call reaches only a member of + // the class it is written in, of what that class extends, mixes in or + // implements, or of an extension on one of those (upstream #2145). + .filter(|n| { + !(dart_bare + && crate::dart_scope::is_dart_type_member(n, context) + && !crate::dart_scope::is_dart_method_in_scope(n, reference, context)) + }) + // A Dart constructor is reached through its class (`Foo.named(…)`, + // `@Foo.named(…)`), never by its name alone: not a bare call, not the + // last link of a chain, not an annotation (riverpod's 210 `@internal` + // annotations went to its providers' `internal` constructors, + // upstream #2380). + .filter(|n| { + !(reference.language == Language::Dart + && crate::dart_members::is_dart_constructor(n, context)) + }) + // bloc's brick hook calls `info(…)` on mason's `Logger`, no enum + // constant of bloc_lint's: a bare Dart call reaches a type's value + // only from inside the type, as its methods (upstream #2386). + .filter(|n| { + !(dart_bare + && crate::dart_libraries::is_dart_type_value(n) + && !crate::dart_scope::is_dart_method_in_scope(n, reference, context)) + }) + // A receiver-less Dart name reaches only what its library declares or + // imports, ahead of the ranking, so a visible namesake wins (upstream + // #2386). + .filter(|n| { + !((dart_bare || dart_type_ref) + && !crate::dart_libraries::is_dart_top_level_visible( + n, + reference, + context, + dart_prefix.as_deref(), + )) + }) + // Ahead of the ranking, so a visible namesake wins: retrofit's tests' + // `new Builder()` is not a wire converter test's nested + // `CrashingPhone.Builder` (upstream #2236). + .filter(|n| java_type_site.visible(n, context)) + // A bare PHP class name, only its namespace's or the imported one, ahead + // of the ranking: koel's `extends Request` under `use + // App\Http\Requests\API\Request;` is that class, not the first `Request` + // indexed (upstream #2235). + .filter(|n| crate::php_scope::is_php_class_visible(n, reference, context)) + .filter(|n| { + kotlin_std_chain + .as_deref() + .is_none_or(|receiver| crate::kotlin_scope::is_std_chain_target(n, receiver)) + }) + .filter(|n| { + !kotlin_call || crate::kotlin_scope::is_kotlin_top_level_visible(n, reference, context) + }) + .filter(|n| !kotlin_bare || kotlin_site.reaches(n, context)) + // A project's bitwise extension on a number type is never the + // standard library's `x and 0xff` (upstream #2174). + .filter(|n| !crate::kotlin_scope::is_kotlin_number_bitwise(n, reference)) + // A bare Scala name reaches only a member in reach (upstream #2160, + // #2193, #2199). + .filter(|n| { + !scala_bare || crate::scala_scope::is_scala_member_in_scope(n, reference, context) + }) + // A C# type position names a type, never a member sharing its name + // (upstream #2121). + .filter(|n| !csharp_type_ref || crate::csharp_types::can_name_in_type_position(n)) + // A bare C# name is a member of the types around it, of their bases + // or of a static using's type (upstream #2151). + .filter(|n| { + !csharp_bare || crate::csharp_types::is_csharp_member_in_scope(n, reference, context) + }) + // A C# type name is a type its namespaces can see, and a nested one + // only from inside its owner, ahead of the ranking, so a visible + // namesake wins (upstream #2216, #2220). + .filter(|n| { + !crate::csharp_types::is_csharp_bare_type_candidate(n, reference) + || (crate::csharp_types::is_csharp_nested_type_in_scope(n, reference, context) + && (n.file_path == reference.file_path + || crate::csharp_types::is_csharp_type_visible(n, reference, context))) + }) + // A Scala package object's member, only where it is in scope, ahead + // of the ranking, so cats.laws' `Eq` can be the `cats` package + // object's (upstream #2230). + .filter(|n| { + !(reference.language == Language::Scala + && n.language == Language::Scala + && n.file_path != reference.file_path + && !crate::scala_scope::is_scala_package_object_member_visible( + n, reference, context, + )) + }) + // Charts' `min(a, b)` is no range type's `min` field, `super.init(…)` + // no Objective-C demo's `init` (upstream #2170). + .filter(|n| { + swift_shape.as_ref().is_none_or(|shape| { + crate::swift_scope::is_swift_call_target(n, shape, reference, context) + }) + }) + .cloned() + .collect(); + let candidates = if kotlin_bare { + kotlin_site.lexical(candidates, context) + } else if dart_bare { + // Of those, the library's own (upstream #2386), then the nearest: + // bloc's `emit(…)` in a `Cubit` is `BlocBase.emit`, not the + // `Emittable` interface's (upstream #2145). + crate::dart_scope::nearest_dart_members( + crate::dart_libraries::prefer_own_dart_library(candidates, reference, context), + reference, + context, + ) + } else if dart_type_ref && dart_prefix.is_none() { + crate::dart_libraries::prefer_own_dart_library(candidates, reference, context) + } else if swift_shape + .as_ref() + .is_some_and(|shape| shape.shape != crate::swift_scope::Shape::Chained) + { + crate::swift_scope::nearest_swift_members(candidates, reference, context) + } else { + candidates + }; + + if candidates.is_empty() { + return None; } // Branch on the PRE-FILTER count. The single-candidate shortcut skips @@ -1515,7 +3287,37 @@ pub fn match_by_qualified_name( return None; } - let candidates = context.get_nodes_by_qualified_name_shared(&reference.reference_name); + // A C or C++ name written from the global scope is the declaration of + // exactly that name (upstream #2445); the partial match below would take + // a namesake nested in another namespace or class. + if matches!(reference.language, Language::C | Language::Cpp) + && reference.reference_name.starts_with("::") + { + return crate::cpp_namespaces::match_cpp_global_name(reference, context); + } + + let mut candidates = context.get_nodes_by_qualified_name_shared(&reference.reference_name); + // A C# `using X.Y;` names a namespace: one the project declares, else it + // is the file's own (external) using, never another file's using of that + // name (upstream #2220). + if reference.language == Language::CSharp && reference.reference_kind == EdgeKind::Imports { + let namespaces: Vec> = candidates + .iter() + .filter(|n| n.kind == NodeKind::Namespace) + .cloned() + .collect(); + candidates = if namespaces.is_empty() { + candidates + .into_iter() + .filter(|n| n.kind != NodeKind::Import || n.file_path == reference.file_path) + .collect() + } else { + prefer_call_site_file(namespaces, &reference.file_path) + .into_iter() + .take(1) + .collect() + }; + } if candidates.len() == 1 { return Some(ResolvedRef { original: reference.clone(), @@ -1629,6 +3431,10 @@ pub(crate) fn prefer_call_site_file>( } } +/// Languages whose object literals declare callable members (upstream +/// `OBJECT_LITERAL_LANGUAGES`): `export const api = { call() {…} }` used as a +/// namespace (#1573), including the script blocks of single-file components +/// (#2363). pub(crate) fn is_object_literal_language(language: Language) -> bool { matches!( language, @@ -1637,6 +3443,9 @@ pub(crate) fn is_object_literal_language(language: Language) -> bool { | Language::JavaScript | Language::Jsx | Language::ArkTs + | Language::Vue + | Language::Svelte + | Language::Astro ) } @@ -1653,7 +3462,7 @@ pub(crate) fn range_within(inner: &Node, outer: &Node) -> bool { true } -fn same_range(left: &Node, right: &Node) -> bool { +pub(crate) fn same_range(left: &Node, right: &Node) -> bool { left.start_line == right.start_line && left.start_column == right.start_column && left.end_line == right.end_line @@ -1773,13 +3582,36 @@ pub(crate) fn resolve_method_on_type( .into_iter() .filter(|m| { // One language family shares one type system: a `.tsx` caller - // reaches a `.ts` class's method (upstream #1862). + // reaches a `.ts` class's method (upstream #1862). A Dart named + // constructor is reached through its class, never a value of it + // ([`callable_on_a_value`]). m.kind == NodeKind::Method && same_language_family(m.language, reference.language) && (m.qualified_name == want || m.qualified_name.ends_with(&format!("::{want}"))) + && callable_on_a_value(m, reference, context) }) .collect(); + // Go: only the declaring package's methods count, then what its + // declaration embeds; `preferred_fqn` is that package's directory + // (upstream #2323). + if reference.language == Language::Go + && let Some(pkg_dir) = preferred_fqn + && let Some(scoped) = crate::go_packages::resolve_go_method_in_package( + type_name, + method_name, + pkg_dir, + &matches, + reference, + context, + confidence, + resolved_by, + depth, + ) + { + return scoped; + } + if matches.is_empty() { // Conformance fallback via supertypes (name-matcher.ts:289-305). if depth < 4 { @@ -1798,10 +3630,29 @@ pub(crate) fn resolve_method_on_type( } } } + // A Dart extension's member: no declaration of the type has it, so an + // extension `on` the type (or a supertype) supplies it — `s.shout()` + // on an enum through `extension ShapeInfo on Shape` (upstream #2338). + if reference.language == Language::Dart + && depth == 0 + && let Some(member) = crate::dart_members::dart_extension_member_of( + type_name, + method_name, + reference, + context, + ) + { + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: member.id.clone(), + confidence, + resolved_by, + }); + } return None; } - if matches.len() > 1 { + if matches.len() > 1 && reference.language != Language::Go { if let Some(fqn) = preferred_fqn { let ext = if reference.language == Language::Kotlin { ".kt" @@ -1882,15 +3733,162 @@ fn build_declarator_regex(escaped_receiver: &str) -> Regex { .expect("declarator regex") } -/// `normalizeCppTypeName` (`name-matcher.ts:480-498` family): strip pointer/ref -/// markers + surrounding whitespace, keep the last `::` segment's namespace tail -/// as the upstream does (the upstream keeps the full `ns::Type`; we mirror by trimming markers -/// only). Returns `""` for an empty/garbage match. -fn normalize_cpp_type_name(raw: &str) -> String { - raw.trim() - .trim_end_matches(['*', '&', ' ', '\t']) - .trim() - .to_string() +/// [`build_declarator_regex`]'s pattern spelled over ASCII, for a line of +/// ASCII text: there each class and word boundary matches exactly what the +/// Unicode one does, and none builds the UTF-8 automata a Unicode class +/// compiles to (`\w`, `\s`, a negated class), which made the pattern a +/// leveldb index compiled for most member calls cost most of its memory +/// over v0.54.0 at 32 threads (W4d end). +fn build_ascii_declarator_regex(escaped_receiver: &str) -> Regex { + // `\s` is `[\x09-\x0D\x20]` on ASCII, `\w` `[0-9A-Za-z_]`, and + // `[^;=(){}]` every other ASCII character. + Regex::new(&format!( + r"([A-Za-z_][0-9A-Za-z_:]*(?:[\x09-\x0D\x20]*<[\x00-\x27\x2A-\x3A\x3C\x3E-\x7A\x7C\x7E\x7F]+>)?(?:[\x09-\x0D\x20]*[*&]+)?)[\x09-\x0D\x20]*(?-u:\b){escaped_receiver}(?-u:\b)" + )) + .expect("ASCII declarator regex") +} + +/// Whether `line` could match [`build_declarator_regex`]'s pattern for +/// `receiver`; never false where it does. The pattern writes the receiver as +/// a word right after a type, past whitespace, and a type ends in a word +/// character, `:`, `>`, `*` or `&`. +fn may_declare_cpp_receiver(line: &str, receiver: &str) -> bool { + receiver.is_empty() + || word_occurrences(line, receiver).any(|at| { + line[..at] + .trim_end() + .chars() + .next_back() + .is_some_and(|c| is_regex_word_char(c) || matches!(c, ':' | '>' | '*' | '&')) + }) +} + +/// Whether `line` could be one [`CppRebinding::rebinds`] reads for +/// `receiver`; never false where it is. A range-`for` writes `for` and the +/// receiver before a `:`, a structured binding `auto` and a `[`. +fn may_rebind_cpp_receiver(line: &str, receiver: &str) -> bool { + receiver.is_empty() + || (line.contains("for") + && occurrences(line, receiver) + .any(|at| line[at + receiver.len()..].trim_start().starts_with(':'))) + || (line.contains("auto") && line.contains('[')) +} + +/// Does a `>` in a declared C++ type close no `<` (upstream #2443 +/// `cppTypeBeganAbove`)? Then the type began on an earlier line (rocksdb's +/// `std::unique_ptr>>& blob_reqs` under +/// `autovectorb->c`). +fn cpp_type_began_above(type_name: &str) -> bool { + let mut depth = 0usize; + for c in type_name.chars() { + match c { + '<' => depth += 1, + '>' => match depth.checked_sub(1) { + Some(open) => depth = open, + None => return true, + }, + _ => {} + } + } + false +} + +/// A C++ declarator scan's captured type (`std::vector*`, +/// `autovector>&`) as receiver inference reads +/// it (upstream `normalizeCppTypeName`, the shape after #2443): `&` / `*`, +/// cv-qualifiers, `typename` / `class` / `struct` and the template +/// arguments, nested ones with them, dropped (`Table>` → +/// `Table`, never `Table >`, which names no class). `None` when the text +/// names no type: what is left of a type begun on an earlier line names a +/// template argument's type, not the declared one, and `return x` / +/// `delete x` declare nothing. Upstream keeps only the last `::` segment; the +/// namespaces a declaration writes stay here (`port::Mutex`), as they always +/// have, and [`resolve_method_on_type`] matches the qualified tail. A capture +/// ending in `::` is the receiver's own qualifier, not a type: leveldb's +/// `leveldb::g_env = leveldb::Env::Default();` declares nothing, and the scan +/// goes on to `leveldb::Env* g_env` (upstream reads the type `leveldb` there; +/// this scan never has). +fn normalize_cpp_type_name(raw: &str) -> Option { + if cpp_type_began_above(raw) { + return None; + } + let bare = crate::cpp_type_aliases::strip_template_arguments(&raw.replace(['&', '*'], " ")); + let normalized = bare + .split_whitespace() + .filter(|word| { + !matches!( + *word, + "const" | "volatile" | "mutable" | "typename" | "class" | "struct" + ) + }) + .collect::>() + .join(" "); + let last = normalized.rsplit("::").next()?.trim(); + (!last.is_empty() && !CPP_NON_TYPE_TOKENS.contains(&last)).then_some(normalized) +} + +/// A C++ source line as code, for reading declarations (upstream #2413 +/// `cppCodeOf`): `None` for a line of a comment (`// …`, ` * …`), else the +/// line with its comments blanked, columns kept. Receiver inference read +/// leveldb's `// … non-null imm_` as a declaration of `imm_` with the type +/// `null`. +fn cpp_code_of(line: &str) -> Option { + static COMMENT_LINE: OnceLock = OnceLock::new(); + static BLOCK: OnceLock = OnceLock::new(); + let comment_line = + COMMENT_LINE.get_or_init(|| Regex::new(r"^\s*(?://|\*)").expect("comment line")); + if comment_line.is_match(line) { + return None; + } + let block = BLOCK.get_or_init(|| Regex::new(r"/\*.*?\*/").expect("block comment")); + let mut code = block + .replace_all(line, |found: ®ex::Captures<'_>| { + " ".repeat(found[0].len()) + }) + .into_owned(); + // A block comment opening here runs past the line. + if let Some(open) = code.find("/*") { + code.truncate(open); + } + if let Some(comment) = code.find("//") { + code.truncate(comment); + } + Some(code) +} + +/// Patterns for a line that declares a C++ receiver in a form the +/// declarator pattern doesn't read (upstream #2413 `cppRebindsReceiver`): a +/// range-`for` variable (`for (const Foo& x : xs)`) or a structured binding +/// (`auto [x, y] = …`). +struct CppRebinding { + range_variable: Regex, + binding: Regex, +} + +impl CppRebinding { + fn new(escaped_receiver: &str) -> Option { + Some(Self { + range_variable: Regex::new(&format!(r"\b{escaped_receiver}\s*:")).ok()?, + binding: Regex::new(&format!( + r"\bauto\s*&{{0,2}}\s*\[[^\]]*\b{escaped_receiver}\b[^\]]*\]" + )) + .ok()?, + }) + } + + fn rebinds(&self, line: &str) -> bool { + static FOR: OnceLock = OnceLock::new(); + let range_for = FOR.get_or_init(|| Regex::new(r"\bfor\s*\(").expect("range for")); + // `x :` after a `for (`, not `x::`. + let in_range_for = range_for.find(line).is_some_and(|open| { + let rest = &line[open.end()..]; + self.range_variable + .find_iter(rest) + .any(|variable| !rest[variable.end()..].starts_with(':')) + }); + in_range_for || self.binding.is_match(line) + } } /// The char following the receiver word must be a declarator terminator @@ -1963,7 +3961,7 @@ fn resolve_cpp_call_result_type( if recv.contains('.') || recv.contains('(') || recv.contains("::") { return None; } - let recv_type = infer_cpp_receiver_type(recv, reference, context, depth + 1)?; + let recv_type = infer_cpp_receiver_type(recv, reference, context, depth + 1, None)?; return lookup_callee_return_type( &format!("{recv_type}::{method}"), reference, @@ -1983,15 +3981,259 @@ fn resolve_cpp_call_result_type( None } +/// What C++ receiver inference made of a receiver's declared type (upstream +/// #2399 `CppReceiverDeclaration`). +#[derive(Debug, Default)] +struct CppReceiverDeclaration { + /// The declared type is an alias, followed to the type it names or naming + /// no particular class (a template parameter's type, `typename + /// Traits::Field`, a `decltype(…)`). + aliased: bool, + /// The type as written in the declaration, read the way upstream's + /// `normalizeCppTypeName` reads it, without following an alias (`Table`). + written: String, + /// The type is reached through a pointer (`Table* t`, `using Field = + /// const FieldDescriptor*;`). + pointer: bool, + /// The type is one of the project's class templates, whose + /// specializations may add members. + class_template: bool, + /// The declared type as written (`std::vector*`). + raw: String, + /// The declaration is the calling function's own, or a member of its + /// class ([`is_cpp_callers_declaration`]). + callers: bool, + /// On its way back to the declaration the scan passed one of the + /// receiver it could not read a type from (`auto x = Make();`, `for + /// (Foo& x : xs)`, `auto [x, y] = …`, the end of a declaration begun on an + /// earlier line), so the declaration it found may be an earlier + /// variable's. + shadowed: bool, +} + +/// A declared C++ type as receiver inference reads it: as the declaration +/// wrote it, or, when it is an alias the caller's own declaration uses, the +/// type the alias names (`cpp_type_aliases`), `None` when that type can't be +/// known (upstream #2399 `cppDeclaredType`). An alias is never a class, so its +/// own name is never looked up as one: that is how protobuf's `Field f; +/// f->number()` (`using Field = const FieldDescriptor*;`) reached the +/// generated `Field` message's `number`. +fn cpp_declared_type( + raw: &str, + normalized: String, + in_caller_scope: bool, + reference: &RefView, + context: &dyn ResolutionContext, + found: Option<&mut CppReceiverDeclaration>, +) -> Option { + // A declaration read from elsewhere (an earlier function, another class) + // may not be this receiver's, so it is taken as it was before aliases + // were followed. + if !in_caller_scope { + return Some(normalized); + } + let aliased = crate::cpp_type_aliases::resolve_cpp_aliased_type(raw, reference, context); + let followed = match &aliased { + crate::cpp_type_aliases::CppAliased::NotAnAlias => return Some(normalized), + crate::cpp_type_aliases::CppAliased::Unreadable => None, + crate::cpp_type_aliases::CppAliased::Followed(followed) => Some(followed), + }; + if let Some(found) = found { + found.aliased = true; + found.written = cpp_declared_type_name(raw).unwrap_or_default(); + found.pointer = followed.map_or_else( + || crate::cpp_type_aliases::is_cpp_pointer_type(raw), + |followed| followed.pointer, + ); + found.class_template = followed.is_some_and(|followed| followed.class_template); + } + followed.map(crate::cpp_type_aliases::cpp_aliased_type_name) +} + +/// Is line `line` of `file`, which declares a C++ receiver, the calling +/// function's own code, or a member declaration of the caller's class +/// (upstream #2399 `isCppCallersDeclaration`, #2413's shape)? Receiver +/// inference reads back to the top of the file and through the header, so a +/// declaration it finds elsewhere (an earlier function, another class in the +/// header, a class nested in the caller's: rocksdb's +/// `MultiScan::MultiScanIterator` has a `scan_opts_` of its own) may be +/// another variable of the same name. The scan has blanked comments. +fn is_cpp_callers_declaration( + file: &str, + line: i64, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let Some(caller) = context.get_node_by_id_shared(&reference.from_node_id) else { + return false; + }; + if !matches!(caller.kind, NodeKind::Method | NodeKind::Function) { + return false; + } + if file == reference.file_path && line >= caller.start_line && line <= reference.line { + return true; + } + let Some((owner, _)) = caller.qualified_name.rsplit_once("::") else { + return false; + }; + let mut innermost: Option> = None; + for class in context.get_nodes_in_file_shared(file) { + if !matches!( + class.kind, + NodeKind::Class | NodeKind::Struct | NodeKind::Union + ) { + continue; + } + let end = if class.end_line != 0 { + class.end_line + } else { + class.start_line + }; + if line < class.start_line || line > end { + continue; + } + if innermost + .as_ref() + .is_none_or(|inner| class.start_line >= inner.start_line) + { + innermost = Some(class); + } + } + innermost.is_some_and(|class| class.qualified_name == owner) +} + +/// The operator the C++ member call `reference` is written with (upstream +/// #2413 `cppMemberOperator`): `.` for `receiver.method(…)`, `->` through a +/// pointer, iterator or smart pointer, `None` when the source doesn't show +/// it. +fn cpp_member_operator( + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option<&'static str> { + let facts = context.source_facts(&reference.file_path)?; + let index = usize::try_from(reference.line - 1) + .ok() + .filter(|&index| index < facts.line_count())?; + let mut rest = facts + .line(index) + .get(usize::try_from(reference.column).unwrap_or(0)..) + .and_then(|at| at.strip_prefix(receiver))?; + // `symbols_by_parent_` ending its line, `.insert(…)` starting the next. + let mut next = index + 1; + while rest.trim().is_empty() && next < facts.line_count().min(index + 1 + 3) { + rest = facts.line(next); + next += 1; + } + let rest = rest.trim_start(); + if rest.starts_with('.') { + Some(".") + } else if rest.starts_with("->") { + Some("->") + } else { + None + } +} + +/// Is the C++ member call `reference` written `receiver.method(…)`, not +/// through `->` (upstream #2399 `isCppDotCall`)? +fn is_cpp_dot_call(receiver: &str, reference: &RefView, context: &dyn ResolutionContext) -> bool { + cpp_member_operator(receiver, reference, context) == Some(".") +} + +/// The C and C++ types the project declares under `name` (upstream #2413 +/// `cppTypesNamed`). +fn cpp_types_named(name: &str, context: &dyn ResolutionContext) -> Vec> { + context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| { + matches!(node.language, Language::C | Language::Cpp) + && matches!( + node.kind, + NodeKind::Class + | NodeKind::Struct + | NodeKind::Union + | NodeKind::Enum + | NodeKind::Interface + | NodeKind::TypeAlias + ) + }) + .collect() +} + +/// Is a C++ declared type, as written (`std::vector*`, `const +/// absl::flat_hash_set&`), one from outside the project with a +/// lowercase name, as the standard library's and abseil's are (upstream +/// #2413 `isCppLibraryType`)? [`is_undeclared_type_name`] takes the +/// capitalized ones. The name is qualified, by namespaces that are no +/// project type's (`Foo::kMask & key` reads like a declaration of `key`), +/// and the project declares no type of that name: in `std` itself for a +/// `std::` name (googletest's `testing::internal::string` is no +/// `std::string`), anywhere for any other, since a namespace a macro opens +/// (fmt's `FMT_BEGIN_NAMESPACE`) is in no qualified name. An unqualified +/// name is not known to be std's: fmt's `using namespace std;` is inside +/// `namespace adl { … }`, and its `basic_string_view` is fmt's own. Nor is a +/// type whose `>`s outnumber its `<`s, the end of a declaration begun on an +/// earlier line. A `_t` name has no members of its own: a scalar +/// (`std::size_t`, so the declaration is another variable's), a tag, or a +/// trait naming another type (rocksdb's `std::conditional_t dict`). +fn is_cpp_library_type(raw: &str, context: &dyn ResolutionContext) -> bool { + if raw.matches('<').count() != raw.matches('>').count() { + return false; + } + let Some(segments) = crate::cpp_type_aliases::cpp_type_segments(raw) else { + return false; + }; + let Some((name, qualifiers)) = segments.split_last().filter(|(_, rest)| !rest.is_empty()) + else { + return false; + }; + if !name.starts_with(|c: char| c.is_ascii_lowercase()) || name.ends_with("_t") { + return false; + } + if qualifiers + .iter() + .any(|qualifier| !cpp_types_named(qualifier, context).is_empty()) + { + return false; + } + let declared = cpp_types_named(name, context); + if qualifiers[0] != "std" { + return declared.is_empty(); + } + let spelled = segments.join("::"); + let tail = format!("::{spelled}"); + !declared + .iter() + .any(|node| node.qualified_name == spelled || node.qualified_name.ends_with(&tail)) +} + +/// Record the C++ declaration receiver inference settles on (upstream #2413 +/// `noteCppDeclaration`). +fn note_cpp_declaration(found: Option<&mut CppReceiverDeclaration>, raw: &str, callers: bool) { + if let Some(found) = found { + found.raw = raw.to_string(); + found.callers = callers; + found.pointer = crate::cpp_type_aliases::is_cpp_pointer_type(raw); + } +} + /// Infer a C++ receiver's declared type by scanning the source backwards from /// the call line for its declarator (`inferCppReceiverType`, /// `name-matcher.ts:512-567`). Handles `Foo x;`, `auto x = Foo::make();`, and a -/// `.h/.hpp/.hxx` header fallback. +/// `.h/.hpp/.hxx` header fallback. Comments are no declarations (upstream +/// #2413). A declared type the caller's own code spells through an alias is +/// the type the alias names (upstream #2399), and `found` records what the +/// declaration said and whether a re-declaration the scan could not read +/// passed on the way. fn infer_cpp_receiver_type( receiver_name: &str, reference: &RefView, context: &dyn ResolutionContext, depth: u32, + mut found: Option<&mut CppReceiverDeclaration>, ) -> Option { let source = context.read_file(&reference.file_path)?; let lines: Vec<&str> = source @@ -2003,31 +4245,96 @@ fn infer_cpp_receiver_type( } let call_line_index = ((reference.line - 1).max(0) as usize).min(lines.len() - 1); let escaped = regex_escape(receiver_name); - let receiver_pattern = Regex::new(&format!(r"\b{escaped}\b")).ok()?; - let declarator_regex = build_declarator_regex(&escaped); + // The receiver's word is read without a pattern ([`writes_word`]), and + // the declarator and rebinding patterns compile only once a line could + // match them: compiled for every member call, the three made most of a + // leveldb index. + let declarator_regex: OnceCell = OnceCell::new(); + let ascii_declarator_regex: OnceCell = OnceCell::new(); + let declarator = |line: &str| { + if line.is_ascii() { + ascii_declarator_regex.get_or_init(|| build_ascii_declarator_regex(&escaped)) + } else { + declarator_regex.get_or_init(|| build_declarator_regex(&escaped)) + } + }; + let mut rebinding: Option> = None; for i in (0..=call_line_index).rev() { - let line = lines[i]; - if line.is_empty() || !receiver_pattern.is_match(line) { + let text = lines[i]; + if text.is_empty() || !writes_word(text, receiver_name) { continue; } - if let Some(caps) = declarator_regex.captures(line) { - let whole = caps.get(0)?; - if !declarator_terminator_ok(line, whole.end()) { - continue; - } - let normalized = normalize_cpp_type_name(caps.get(1).map_or("", |m| m.as_str())); - if normalized == "auto" { - // `auto x = Foo::instance();` — recover the type from the - // initializer (call return type / construction). - if let Some(init_type) = - infer_cpp_auto_initializer_type(line, receiver_name, reference, context, depth) - { - return Some(init_type); + let Some(line) = cpp_code_of(text) else { + continue; + }; + if !writes_word(&line, receiver_name) { + continue; + } + let declarator = may_declare_cpp_receiver(&line, receiver_name) + .then(|| declarator(&line).captures(&line)) + .flatten() + .filter(|caps| { + caps.get(0) + .is_some_and(|whole| declarator_terminator_ok(&line, whole.end())) + }); + if let Some(caps) = declarator { + let raw = caps.get(1).map_or("", |m| m.as_str()); + match normalize_cpp_type_name(raw) { + Some(normalized) if normalized == "auto" => { + // `auto x = Foo::instance();` — recover the type from the + // initializer (call return type / construction). + if let Some(init_type) = infer_cpp_auto_initializer_type( + &line, + receiver_name, + reference, + context, + depth, + ) { + return Some(init_type); + } + // No usable initializer on this line: keep scanning + // earlier ones, where what is declared is an earlier + // variable. + if let Some(found) = found.as_deref_mut() { + found.shadowed = true; + } + } + Some(normalized) => { + let in_caller_scope = is_cpp_callers_declaration( + &reference.file_path, + i as i64 + 1, + reference, + context, + ); + note_cpp_declaration(found.as_deref_mut(), raw, in_caller_scope); + return cpp_declared_type( + raw, + normalized, + in_caller_scope, + reference, + context, + found, + ); + } + // The end of a declaration begun on an earlier line, maybe the + // receiver's: one found further up may be another variable + // (upstream #2443). + None if cpp_type_began_above(raw) => { + if let Some(found) = found.as_deref_mut() { + found.shadowed = true; + } } - } else if !normalized.is_empty() { - return Some(normalized); + None => {} } + } else if let Some(found) = found.as_deref_mut() + && may_rebind_cpp_receiver(&line, receiver_name) + && rebinding + .get_or_insert_with(|| CppRebinding::new(&escaped)) + .as_ref() + .is_some_and(|rebinding| rebinding.rebinds(&line)) + { + found.shadowed = true; } } @@ -2048,19 +4355,40 @@ fn infer_cpp_receiver_type( let Some(header_source) = context.read_file(&header_path) else { continue; }; - for line in header_source.split('\n').map(|l| l.trim_end_matches('\r')) { - if !receiver_pattern.is_match(line) { + for (i, text) in header_source + .split('\n') + .map(|l| l.trim_end_matches('\r')) + .enumerate() + { + if !writes_word(text, receiver_name) { continue; } - if let Some(caps) = declarator_regex.captures(line) { - let Some(whole) = caps.get(0) else { continue }; - if !declarator_terminator_ok(line, whole.end()) { - continue; - } - let normalized = normalize_cpp_type_name(caps.get(1).map_or("", |m| m.as_str())); - if !normalized.is_empty() && normalized != "auto" { - return Some(normalized); - } + let Some(line) = cpp_code_of(text) else { + continue; + }; + if !may_declare_cpp_receiver(&line, receiver_name) { + continue; + } + let Some(caps) = declarator(&line).captures(&line) else { + continue; + }; + let Some(whole) = caps.get(0) else { continue }; + if !declarator_terminator_ok(&line, whole.end()) { + continue; + } + let raw = caps.get(1).map_or("", |m| m.as_str()); + if let Some(normalized) = normalize_cpp_type_name(raw).filter(|n| n != "auto") { + let in_caller_scope = + is_cpp_callers_declaration(&header_path, i as i64 + 1, reference, context); + note_cpp_declaration(found.as_deref_mut(), raw, in_caller_scope); + return cpp_declared_type( + raw, + normalized, + in_caller_scope, + reference, + context, + found, + ); } } } @@ -2190,57 +4518,168 @@ pub(crate) fn normalize_inferred_type_name(raw: &str) -> Option { /// most-specific first. The type-annotation / typed-parameter forms (#1125) are /// baked into the second pattern per language. Languages without a pattern set /// (the C++ path uses its own dedicated inferrer) return an empty vector. -pub(crate) fn local_receiver_type_patterns(language: Language, r: &str) -> Vec { +pub(crate) fn local_receiver_type_patterns( + language: Language, + r: &str, +) -> Vec<(Regex, PatternCheck)> { local_receiver_type_patterns_tagged(language, r) - .into_iter() - .map(|(re, _)| re) - .collect() } -/// The bool tags the Lua/Luau `receiver: Type` ANNOTATION pattern, which needs a -/// code-side self-match gate (#1124): `lg:Log()` is byte-identical to the Luau -/// annotation `lg: Logger`, and the `regex` crate has no look-ahead to reject the -/// call form, so `infer_local_receiver_type` post-checks the tagged capture. -fn local_receiver_type_patterns_tagged(language: Language, r: &str) -> Vec<(Regex, bool)> { - let build = |src: String| Regex::new(&src).ok().map(|re| (re, false)); +/// What a receiver-type pattern needs checked beyond its regex, which the +/// `regex` crate cannot say: it has no look-around. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatternCheck { + /// Nothing beyond the match. + None, + /// The Lua/Luau `receiver: Type` ANNOTATION: `lg:Log()` is byte-identical + /// to the Luau annotation `lg: Logger`, so the call form is rejected here + /// (#1124). + LuaAnnotation, + /// A Python `name = Cls(…)` is a statement of its own: one whose line goes + /// on to end in a comma is a keyword argument inside a call + /// (`prefix=IPNetwork(…),`, upstream #2169 `(?![^\n]*,\s*$)`). + PythonStatement, +} + +impl PatternCheck { + /// Whether a match of the pattern in `text` (`whole`, with its type + /// `capture`) passes. + pub fn passes(self, text: &str, whole: regex::Match<'_>, capture: regex::Match<'_>) -> bool { + match self { + Self::None => true, + Self::LuaAnnotation => !lua_annotation_is_method_call(text, capture.end()), + Self::PythonStatement => { + let rest = &text[whole.end()..]; + let line_end = rest.find('\n').unwrap_or(rest.len()); + !rest[..line_end] + .rfind(',') + .is_some_and(|comma| rest[comma + 1..].trim().is_empty()) + } + } + } +} + +/// The receiver-type patterns of `language`, each with the check its match +/// needs. +fn local_receiver_type_patterns_tagged(language: Language, r: &str) -> Vec<(Regex, PatternCheck)> { + let build = |src: String| Regex::new(&src).ok().map(|re| (re, PatternCheck::None)); let pats: Vec = match language { Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx => vec![ format!(r"\b{r}\b\s*=\s*new\s+([A-Za-z_$][\w.$]*)"), format!(r"\b{r}\b\s*:\s*([A-Z][\w.$]*)"), ], - Language::Python => vec![ - format!(r"\b{r}\b\s*=\s*([A-Z][\w.]*)\s*\("), - // Quoted forward references are ordinary Python annotations (and - // the form produced by `from __future__ import annotations`). Keep - // this stricter shape before the unquoted annotation pattern so - // `obj: "pkg.Type"` recovers the same receiver type as - // `obj: pkg.Type` (#1684). - format!(r#"\b{r}\b\s*:\s*["']([A-Z][\w.]*)["']"#), - format!(r"\b{r}\b\s*:\s*([A-Z][\w.]*)"), + Language::Python => { + let mut out = Vec::new(); + for (src, check) in [ + // group = VLANGroup.objects.create(…): a Django manager call + // that returns one instance of the model, not `filter` / `all` + // (a QuerySet; upstream #2175). + ( + format!( + r"(?:^|;)\s*{r}\s*=\s*([A-Z]\w*)\.objects\.(?:create|get|first|last|latest|earliest|get_by_natural_key)\s*\(" + ), + PatternCheck::None, + ), + // lg = Logger(...), a statement of its own (upstream #2169). + ( + format!(r"(?:^|;)\s*{r}\s*=\s*([A-Z][\w.]*)\s*\("), + PatternCheck::PythonStatement, + ), + // Quoted forward references are ordinary Python annotations + // (and the form produced by `from __future__ import + // annotations`). Keep this stricter shape before the unquoted + // annotation pattern so `obj: "pkg.Type"` recovers the same + // receiver type as `obj: pkg.Type` (#1684). + ( + format!(r#"\b{r}\b\s*:\s*["']([A-Z][\w.]*)["']"#), + PatternCheck::None, + ), + (format!(r"\b{r}\b\s*:\s*([A-Z][\w.]*)"), PatternCheck::None), + ] { + if let Ok(re) = Regex::new(&src) { + out.push((re, check)); + } + } + return out; + } + // `FMResultSet *rs = …`, `NSString * _Nullable name;`, a block's + // `^(FMResultSet *rs)`, never a function `Foo *rs(void)` (upstream's + // `(?!\s*\()`, read here as the character after the name), and a + // method parameter, `- (void)read:(nullable FMResultSet *)rs` + // (upstream #2150). + Language::ObjC => vec![ + format!( + r"\b([A-Z]\w*)\s*(?:<[^<>;]*>\s*)?\*\s*(?:(?:_Nullable|_Nonnull|__strong|__weak|__unsafe_unretained|const)\s+)*{r}\b(?:\s*$|\s*[^\s(])" + ), + format!( + r"\(\s*(?:(?:nullable|nonnull|__kindof)\s+)*([A-Z]\w*)\s*(?:<[^<>)]*>\s*)?\*[^)]*\)\s*{r}\b" + ), ], + // A generic or array type (`Pair pair`, `String[] args`) and an + // enhanced-for variable (upstream #2171). Language::Java => vec![ format!(r"\b{r}\b\s*=\s*new\s+([A-Za-z_][\w.]*)"), - format!(r"\b([A-Z][\w.]*)\s+{r}\b\s*[=;,)]"), + format!(r"\b([A-Z][\w.]*)\s*{TYPE_ARGS}\s+{r}\b\s*[=;,)]"), + format!(r"\bfor\s*\(\s*(?:final\s+)?([A-Z][\w.]*)\s*{TYPE_ARGS}\s+{r}\s*:"), ], Language::Kotlin => vec![ format!(r"\b{r}\b\s*=\s*([A-Z][\w.]*)\s*\("), format!(r"\b{r}\b\s*:\s*([A-Z][\w.]*)"), ], + // A generic, nullable or array type (`List names`, + // `JProperty? p`) and a `foreach` variable (upstream #2171). Language::CSharp => vec![ format!(r"\b{r}\b\s*=\s*new\s+([A-Za-z_][\w.]*)"), - format!(r"\b([A-Z][\w.]*)\s+{r}\b\s*[=;,)]"), + format!(r"\b([A-Z][\w.]*)\s*{TYPE_ARGS}\??\s+{r}\b\s*[=;,)]"), + format!(r"\bforeach\s*\(\s*([A-Z][\w.]*)\s*{TYPE_ARGS}\??\s+{r}\s+in\b"), ], Language::Swift => vec![ format!(r"\b{r}\b\s*=\s*([A-Z][\w.]*)\s*\("), format!(r"\b{r}\b\s*:\s*([A-Z][\w.]*)"), ], - Language::Rust => vec![ - format!(r"\blet\s+(?:mut\s+)?{r}\b(?:\s*:[^=]+)?\s*=\s*&?(?:mut\s+)?([A-Z]\w*)"), - format!(r"\b{r}\s*:\s*&?(?:mut\s+)?([A-Z]\w*)"), + // CFML (upstream #1155): `svc = new UserService()` / `new + // path.to.UserService()` (the type is the last segment; the path + // tells same-named components apart, `cfml_scope::typed_receiver_call`), + // `createObject("component", "path.to.X")` and the one-argument + // `createObject("path.to.X")`, a typed cfscript parameter + // (`required UserService svc`; CFML's built-in types are lowercase by + // convention), a `` and a component `property` (WireBox's + // `inject` included), either attribute order. The scan reads raw + // lines, so tag markup (``) matches too. + Language::Cfml => vec![ + format!(r"\b{r}\b\s*=\s*new\s+([A-Za-z_][\w.]*)"), + format!( + r#"\b{r}\b\s*=\s*[Cc]reate[Oo]bject\s*\(\s*["']component["']\s*,\s*["']([\w.]+)["']"# + ), + format!(r#"\b{r}\b\s*=\s*[Cc]reate[Oo]bject\s*\(\s*["']([\w.]+)["']\s*\)"#), + format!(r"\b([A-Z][\w.]*)\s+{r}\b\s*[=;,)]"), + format!( + r#"(?i)\bcfargument[^>\n]*\bname\s*=\s*["']{r}["'][^>\n]*\btype\s*=\s*["']([\w.]+)["']"# + ), + format!( + r#"(?i)\bcfargument[^>\n]*\btype\s*=\s*["']([\w.]+)["'][^>\n]*\bname\s*=\s*["']{r}["']"# + ), + format!( + r#"(?i)\b(?:cf)?property\b[^;\n]*\bname\s*=\s*["']{r}["'][^;\n]*\b(?:type|inject)\s*=\s*["']([\w.]+)["']"# + ), + format!( + r#"(?i)\b(?:cf)?property\b[^;\n]*\b(?:type|inject)\s*=\s*["']([\w.]+)["'][^;\n]*\bname\s*=\s*["']{r}["']"# + ), + ], + Language::Rust => vec![ + format!(r"\blet\s+(?:mut\s+)?{r}\b(?:\s*:[^=]+)?\s*=\s*&?(?:mut\s+)?([A-Z]\w*)"), + format!(r"\b{r}\s*:\s*&?(?:mut\s+)?([A-Z]\w*)"), ], Language::Go => vec![ format!(r"\b{r}\b\s*:=\s*&?([A-Za-z_][\w.]*)\s*\{{"), format!(r"\bvar\s+{r}\s+\*?([A-Za-z_][\w.]*)"), + // A method receiver or parameter of an unexported type — `func (s + // *server)`, `func (c *cache[T])`, `func handle(h *handler)`, a + // line of a multi-line parameter list or `var (…)` block: a + // lowercase type only where a parameter list puts it, after `(`, + // `,` or nothing and before `,`, `)` or the line's end (upstream + // #2323). + format!(r"(?:^|[(,])\s*{r}\s+\*?([a-z_]\w*)\s*(?:\[[^\]]*\])?\s*(?:[,)]|$)"), format!(r"\b{r}\s+\*?([A-Z][\w.]*)"), ], Language::Ruby => vec![format!(r"\b{r}\b\s*=\s*([A-Z][\w:]*)\.new\b")], @@ -2257,17 +4696,17 @@ fn local_receiver_type_patterns_tagged(language: Language, r: &str) -> Vec<(Rege format!(r"\b([A-Za-z_\\][\w\\]*)\s+&?\${r}\b"), ], Language::Lua | Language::Luau => { - let mut out: Vec<(Regex, bool)> = Vec::new(); + let mut out: Vec<(Regex, PatternCheck)> = Vec::new(); for src in [ format!(r"\b{r}\b\s*=\s*([A-Z]\w*)\.new\b"), format!(r"\b{r}\b\s*=\s*([A-Z]\w*)\s*\("), ] { - if let Some((re, _)) = build(src) { - out.push((re, false)); + if let Some(pattern) = build(src) { + out.push(pattern); } } if let Ok(re) = Regex::new(&format!(r"\b{r}\b\s*:\s*([A-Z][\w.]*)")) { - out.push((re, true)); + out.push((re, PatternCheck::LuaAnnotation)); } return out; } @@ -2287,12 +4726,26 @@ fn local_receiver_type_patterns_tagged(language: Language, r: &str) -> Vec<(Rege pub(crate) fn enclosing_scope_start_line( reference: &RefView, context: &dyn ResolutionContext, +) -> i64 { + scope_start_line_in( + context + .get_nodes_in_file_shared(&reference.file_path) + .iter() + .map(AsRef::as_ref), + reference.language, + reference.line, + ) +} + +/// [`enclosing_scope_start_line`] of 1-based `line` among a file's `nodes`. +pub(crate) fn scope_start_line_in<'n>( + nodes: impl IntoIterator, + language: Language, + line: i64, ) -> i64 { let mut start = 1i64; - for n in context.get_nodes_in_file_shared(&reference.file_path) { - if !matches!(n.kind, NodeKind::Function | NodeKind::Method) - || n.language != reference.language - { + for n in nodes { + if !matches!(n.kind, NodeKind::Function | NodeKind::Method) || n.language != language { continue; } let end = if n.end_line != 0 { @@ -2300,7 +4753,7 @@ pub(crate) fn enclosing_scope_start_line( } else { n.start_line }; - if n.start_line <= reference.line && end >= reference.line && n.start_line >= start { + if n.start_line <= line && end >= line && n.start_line >= start { start = n.start_line; } } @@ -2313,50 +4766,398 @@ pub(crate) fn enclosing_scope_start_line( /// languages without patterns or when no declaration is found. Bounded to the /// enclosing scope. The caller validates the method via `resolve_method_on_type`, /// so a mis-inference produces no edge. +#[cfg(test)] pub(crate) fn infer_local_receiver_type( receiver_name: &str, reference: &RefView, context: &dyn ResolutionContext, ) -> Option { - let escaped = regex_escape(receiver_name); - let patterns = local_receiver_type_patterns_tagged(reference.language, &escaped); - if patterns.is_empty() { + infer_local_receiver_declaration(receiver_name, reference, context).map(|(name, _)| name) +} + +/// [`infer_local_receiver_type`] with the type as the declaration writes it +/// beside its name (`a.UserService` beside `UserService`): a CFML component +/// path says which of several same-named components it is (W4-CF-02; +/// upstream keeps it as `decl.raw`, for Go). +pub(crate) fn infer_local_receiver_declaration( + receiver_name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option<(String, String)> { + // CFML scopes (upstream #1155): `variables.svc` / `this.svc` name a + // component field whose assignment or `property` usually lives outside + // the calling function (the init pseudo-constructor, WireBox injection), + // and `local.svc` / `arguments.svc` a function local or argument. The + // scope is stripped so the declarations match the bare name, and a + // component field is looked for in the whole file: the nearest + // declaration above the call still wins, then the rest of the file below. + let (scan_receiver, component_scoped) = match cfml_scoped_receiver(receiver_name, reference) { + Some((scope, member)) => ( + member, + matches!(scope, CfmlScope::Variables | CfmlScope::This), + ), + None => (receiver_name, false), + }; + // A language with no receiver patterns has no declaration to find. + if !has_receiver_patterns(reference.language) { return None; } - let source = context.read_file(&reference.file_path)?; - let lines: Vec<&str> = source - .split('\n') - .map(|l| l.trim_end_matches('\r')) - .collect(); - if lines.is_empty() { + let escaped = regex_escape(scan_receiver); + // The scan compiles its patterns only once a line could match one + // ([`line_may_declare`]): compiled for every member call, CFML's eight + // made re-resolving a file of a few hundred calls take seconds, a PHP + // pair costs a millisecond and a megabyte, and C#'s and Java's, with + // their type arguments, three quarters of a Newtonsoft index. + let compiled: OnceCell>> = OnceCell::new(); + let patterns = || { + compiled.get_or_init(|| { + context.compiled_patterns( + &format!("receiver:{}:{escaped}", reference.language.as_str()), + &|| local_receiver_type_patterns_tagged(reference.language, &escaped), + ) + }) + }; + // The file's lines, `split('\n')` with each `\r` trimmed, from its + // cached facts rather than a copy split per call (W5-08). + let facts = context.source_facts(&reference.file_path)?; + let line_count = facts.lines().raw_line_count(); + let line = |index: usize| { + facts + .raw_line(index) + .unwrap_or_default() + .trim_end_matches('\r') + }; + if line_count == 0 { return None; } - let call_idx = ((reference.line - 1).max(0) as usize).min(lines.len() - 1); - let start_idx = (enclosing_scope_start_line(reference, context) - 1).max(0) as usize; - - for i in (start_idx..=call_idx).rev() { - let line = lines[i]; - // A generated/minified line is not where a human declaration lives, and - // regexing multi-KB text per ref is pure waste — skip it. + let call_idx = ((reference.line - 1).max(0) as usize).min(line_count - 1); + let start_idx = if component_scoped { + 0 + } else { + (enclosing_scope_start_line(reference, context) - 1).max(0) as usize + }; + let match_line = |line: &str| -> Option<(String, String)> { + // A generated/minified line is not where a human declaration lives, + // and regexing multi-KB text per ref is pure waste — skip it. if line.len() > 10_000 { - continue; + return None; + } + if !line_may_declare(reference.language, line, scan_receiver) { + return None; } - for (re, is_lua_annotation) in &patterns { + for (re, check) in patterns().iter() { if let Some(caps) = re.captures(line) { - if let Some(m) = caps.get(1) { - if *is_lua_annotation && lua_annotation_is_method_call(line, m.end()) { + if let (Some(whole), Some(m)) = (caps.get(0), caps.get(1)) { + if !check.passes(line, whole, m) { continue; } if let Some(ty) = normalize_inferred_type_name(m.as_str()) { - return Some(ty); + return Some((ty, m.as_str().to_string())); } } } } + None + }; + for i in (start_idx..=call_idx).rev() { + if let Some(declared) = match_line(line(i)) { + return Some(declared); + } + } + if component_scoped { + for i in call_idx + 1..line_count { + if let Some(declared) = match_line(line(i)) { + return Some(declared); + } + } } None } +/// Whether `line` could match a lazily compiled receiver pattern of +/// `language` for `receiver`; never false where one does. +fn line_may_declare(language: Language, line: &str, receiver: &str) -> bool { + match language { + Language::Cfml => cfml_line_may_declare(line, receiver), + // Both Objective-C forms write a `*` and the receiver. + Language::ObjC => line.contains('*') && line.contains(receiver), + // `$x = new T` writes the receiver, then past whitespace `=` and + // `new`; `T $x` writes `$` and the receiver after whitespace (an `&` + // between). + Language::Php => { + writes_assignment_of(line, receiver, "new") || writes_typed_variable(line, receiver) + } + // `x = Foo(` and `Foo x;` both write the receiver followed, past + // whitespace, by `=`, `;`, `,` or `)`. + Language::Dart => writes_before_terminator(line, receiver), + // The same, and C#'s `foreach (T x in` the receiver, whitespace and + // `in`. + Language::CSharp => { + writes_before_terminator(line, receiver) || writes_before_word(line, receiver, "in") + } + // The same, and Java's enhanced-for variable before `:`. + Language::Java => { + writes_before_terminator(line, receiver) + || occurrences(line, receiver) + .any(|at| line[at + receiver.len()..].trim_start().starts_with(':')) + } + // `let conn = …` and `let conn: = …` (the annotation is + // `[^=]+`, so `crate::db::Connection` too), and `conn: &mut T`: the + // receiver followed, past whitespace, by `:` or `=`. + Language::Rust => occurrences(line, receiver).any(|at| { + line[at + receiver.len()..] + .trim_start() + .starts_with([':', '=']) + }), + // `svc = new T` and `svc: T`: the receiver followed, past + // whitespace, by `=` and `new`, or by `:` and an uppercase letter. + Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx => { + writes_assignment_of(line, receiver, "new") + || occurrences(line, receiver).any(|at| { + line[at + receiver.len()..] + .trim_start() + .strip_prefix(':') + .is_some_and(|annotation| { + annotation + .trim_start() + .starts_with(|c: char| c.is_ascii_uppercase()) + }) + }) + } + // Every other language's patterns write the receiver followed by + // whitespace (Go's `r T`) or by `=`, `;`, `,`, `)`, `<` (R's `<-`) or + // a `:` that a type annotation or `:=` follows: past whitespace an + // uppercase letter, a quote, `&`, `mut` or `=` (so Lua's `svc:call()` + // and an object literal's `svc: value` are no declaration). + _ => occurrences(line, receiver).any(|at| { + let rest = &line[at + receiver.len()..]; + if rest.starts_with(char::is_whitespace) { + return true; + } + match rest.chars().next() { + Some('=' | ';' | ',' | ')' | '<') => true, + Some(':') => { + let annotation = rest[1..].trim_start(); + annotation.starts_with(|c: char| c.is_ascii_uppercase()) + || annotation.starts_with(['"', '\'', '&', '=']) + || annotation.starts_with("mut") + } + _ => false, + } + }), + } +} + +/// Whether [`local_receiver_type_patterns_tagged`] has patterns for +/// `language` (`receiver_patterns_cover_every_language_with_patterns` keeps +/// the two in step). +fn has_receiver_patterns(language: Language) -> bool { + matches!( + language, + Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx + | Language::Python + | Language::ObjC + | Language::Java + | Language::Kotlin + | Language::CSharp + | Language::Swift + | Language::Cfml + | Language::Rust + | Language::Go + | Language::Ruby + | Language::Scala + | Language::Dart + | Language::Php + | Language::Lua + | Language::Luau + | Language::R + | Language::Pascal + ) +} + +/// Whether `line` writes `name`, whitespace and then the word `word`: what +/// `{name}\s+{word}\b` needs (never false where that matches). +fn writes_before_word(line: &str, name: &str, word: &str) -> bool { + occurrences(line, name).any(|at| { + let rest = &line[at + name.len()..]; + rest.starts_with(char::is_whitespace) && rest.trim_start().starts_with(word) + }) +} + +/// Whether `line` writes `name` followed, past whitespace, by `=`, `;`, `,` +/// or `)`: what `{name}\b\s*[=;,)]` needs (never false where that matches). +fn writes_before_terminator(line: &str, name: &str) -> bool { + occurrences(line, name).any(|at| { + line[at + name.len()..] + .trim_start() + .starts_with(['=', ';', ',', ')']) + }) +} + +/// The byte offsets where `needle` starts in `text`, overlapping ones +/// included (`aa` twice in `aaa`). +fn occurrences<'t>(text: &'t str, needle: &'t str) -> impl Iterator + 't { + let step = needle.chars().next().map_or(1, char::len_utf8); + let mut from = 0; + std::iter::from_fn(move || { + if needle.is_empty() || from > text.len() { + return None; + } + let at = from + text[from..].find(needle)?; + from = at + step; + Some(at) + }) +} + +/// Whether `c` is a word character as the `regex` crate's Unicode `\w` and +/// `\b` read one. +fn is_regex_word_char(c: char) -> bool { + if c.is_ascii() { + return c.is_ascii_alphanumeric() || c == '_'; + } + static WORD: OnceLock = OnceLock::new(); + let word = WORD.get_or_init(|| Regex::new(r"\A\w\z").expect("word character")); + word.is_match(c.encode_utf8(&mut [0; 4])) +} + +/// The byte offsets where `text` writes `word` with a word boundary, as the +/// `regex` crate's Unicode `\b` reads one, at both ends. +fn word_occurrences<'t>(text: &'t str, word: &'t str) -> impl Iterator + 't { + let boundary = move |at: usize| { + text[..at] + .chars() + .next_back() + .is_some_and(is_regex_word_char) + != text[at..].chars().next().is_some_and(is_regex_word_char) + }; + occurrences(text, word).filter(move |&at| boundary(at) && boundary(at + word.len())) +} + +/// Whether `text` matches `\b{word}\b` for a literal `word`, read without +/// compiling the pattern. +fn writes_word(text: &str, word: &str) -> bool { + if word.is_empty() { + // `\b\b` is any boundary, which a word character makes. + return text.chars().any(is_regex_word_char); + } + word_occurrences(text, word).next().is_some() +} + +/// Whether `text` writes `name` followed, past whitespace, by `=` and then, +/// past whitespace, by `next`: what `{name}\b\s*=\s*{next}` needs (never +/// false where that matches). +fn writes_assignment_of(text: &str, name: &str, next: &str) -> bool { + occurrences(text, name).any(|at| { + text[at + name.len()..] + .trim_start() + .strip_prefix('=') + .is_some_and(|rest| rest.trim_start().starts_with(next)) + }) +} + +/// Whether `text` writes `$name` after a word and whitespace, an optional +/// `&` between: what `[\w\\]\s+&?\$name` needs (never false where that +/// matches; any non-ASCII character counts as a word's). +fn writes_typed_variable(text: &str, name: &str) -> bool { + let variable = format!("${name}"); + occurrences(text, &variable).any(|at| { + let before = &text[..at]; + let before = before.strip_suffix('&').unwrap_or(before); + before.ends_with(char::is_whitespace) + && before + .trim_end() + .chars() + .next_back() + .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '\\' || !c.is_ascii()) + }) +} + +/// Whether `line` could match a CFML receiver pattern for `receiver` +/// ([`local_receiver_type_patterns_tagged`]); never false where one does. +/// The `new`, `createObject` and typed-parameter forms all write the +/// receiver followed, past whitespace, by `=`, `;`, `,` or `)`; the +/// `` and `property` forms, which read the receiver in any case, +/// an `=` on a line naming `cfargument` or `property` (no letter of either +/// folds to a non-ASCII one, so ASCII lowercasing finds every case). +fn cfml_line_may_declare(line: &str, receiver: &str) -> bool { + if line.contains('=') { + let lower = line.to_ascii_lowercase(); + if lower.contains("cfargument") || lower.contains("property") { + return true; + } + } + receiver.is_empty() || writes_before_terminator(line, receiver) +} + +/// The CFML scope a receiver is written through. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CfmlScope { + Variables, + This, + Local, + Arguments, +} + +/// A CFML receiver written through a scope (`variables.svc`, +/// `arguments.svc`; upstream's `/^(variables|this|local|arguments)\.(.+)$/i`): +/// the scope and the rest. +fn cfml_scoped_receiver<'r>( + receiver: &'r str, + reference: &RefView, +) -> Option<(CfmlScope, &'r str)> { + if reference.language != Language::Cfml { + return None; + } + let (scope, rest) = receiver.split_once('.')?; + if rest.is_empty() { + return None; + } + let scope = match scope.to_ascii_lowercase().as_str() { + "variables" => CfmlScope::Variables, + "this" => CfmlScope::This, + "local" => CfmlScope::Local, + "arguments" => CfmlScope::Arguments, + _ => return None, + }; + Some((scope, rest)) +} + +/// The C# keyword type (`string`, `int`, …) a local or parameter named +/// `receiver_name` is declared with between the enclosing scope's start and +/// the call, as [`infer_local_receiver_type`] scans (KEEP-RUST, W3-05c). +fn csharp_keyword_local_type( + receiver_name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let source = context.read_file(&reference.file_path)?; + let lines: Vec<&str> = source + .split('\n') + .map(|l| l.trim_end_matches('\r')) + .collect(); + if lines.is_empty() { + return None; + } + let call_idx = ((reference.line - 1).max(0) as usize).min(lines.len() - 1); + let start_idx = (enclosing_scope_start_line(reference, context) - 1).max(0) as usize; + // The pattern writes the receiver followed, past whitespace, by `=`, `;`, + // `,` or `)`: only such a line can match, and most calls have none. + let mut candidates = (start_idx..=call_idx) + .rev() + .filter(|&i| lines[i].len() <= 10_000 && writes_before_terminator(lines[i], receiver_name)) + .peekable(); + candidates.peek()?; + let pattern = Regex::new(&format!( + r"(?-u:\b)(string|object|int|long|short|byte|bool|char|double|float|decimal|uint|ulong|ushort|sbyte)\??\s+{}(?-u:\b)\s*[=;,)]", + regex_escape(receiver_name) + )) + .ok()?; + candidates.find_map(|i| pattern.captures(lines[i]).map(|caps| caps[1].to_string())) +} + /// Span end of `node`, falling back to its start when `end_line` was not /// recorded — the same convention [`enclosing_scope_start_line`] applies. fn span_end_line(node: &Node) -> i64 { @@ -2396,11 +5197,6 @@ fn infer_class_field_receiver_type( if !is_esm_language(reference.language) { return None; } - let escaped = regex_escape(receiver_name); - let patterns = local_receiver_type_patterns_tagged(reference.language, &escaped); - if patterns.is_empty() { - return None; - } let in_file = context.get_nodes_in_file_shared(&reference.file_path); let enclosing = in_file @@ -2422,6 +5218,13 @@ fn infer_class_field_receiver_type( && n.qualified_name == want && n.language == reference.language })?; + // Compiled only for a field the call's class declares: the answer is + // the same, and most receivers are none (W5-08). + let escaped = regex_escape(receiver_name); + let patterns = local_receiver_type_patterns_tagged(reference.language, &escaped); + if patterns.is_empty() { + return None; + } let source = context.read_file(&reference.file_path)?; let line = source @@ -2431,10 +5234,10 @@ fn infer_class_field_receiver_type( if line.len() > 10_000 { return None; } - for (re, is_lua_annotation) in &patterns { + for (re, check) in &patterns { if let Some(caps) = re.captures(line) { - if let Some(m) = caps.get(1) { - if *is_lua_annotation && lua_annotation_is_method_call(line, m.end()) { + if let (Some(whole), Some(m)) = (caps.get(0), caps.get(1)) { + if !check.passes(line, whole, m) { continue; } if let Some(ty) = normalize_inferred_type_name(m.as_str()) { @@ -2460,7 +5263,10 @@ fn match_ts_this_field_call( if !is_ts_field_name(field) { return None; } - let caller = context.get_node_by_id_shared(&reference.from_node_id)?; + let written = context.get_node_by_id_shared(&reference.from_node_id)?; + // An arrow member of an object literal written in a method has that + // method's `this` (upstream #2363). + let caller = crate::object_owners::this_scope_caller(written, context); let (owner_qualified, _) = caller.qualified_name.rsplit_once("::")?; let owner_name = owner_qualified.rsplit("::").next()?; let owners = context @@ -2793,17 +5599,32 @@ fn php_property_type_patterns(escaped_prop: &str) -> Vec { /// parameter itself), so a same-named variable in another method can never type /// the property. Ports `inferPhpAssignedPropertyType` (`name-matcher.ts:1396-1445`). fn infer_php_assigned_property_type( + prop: &str, escaped_prop: &str, lines: &[&str], call_idx: usize, + context: &dyn ResolutionContext, ) -> Option { - let assign_re = Regex::new(&format!(r"\$this->{escaped_prop}\b\s*=\s*\$(\w+)\b")).ok()?; + // Compiled only once a line writes `$this->prop =` and then a `$`. + let compiled: OnceCell>> = OnceCell::new(); + let property = format!("this->{prop}"); let assign_at = |i: usize| -> Option { let line = lines.get(i)?; - if line.len() > 10_000 { + if line.len() > 10_000 || !writes_assignment_of(line, &property, "$") { return None; } - assign_re + compiled + .get_or_init(|| { + context.compiled_patterns(&format!("php-assignment:{escaped_prop}"), &|| { + Regex::new(&format!(r"\$this->{escaped_prop}\b\s*=\s*\$(\w+)\b")) + .ok() + .map(|re| (re, PatternCheck::None)) + .into_iter() + .collect() + }) + }) + .first()? + .0 .captures(line) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()) @@ -2838,7 +5659,7 @@ fn infer_php_assigned_property_type( for i in (0..=assign_idx).rev() { let line = lines[i]; if line.len() <= 10_000 { - for re in &var_patterns { + for (re, _) in &var_patterns { if let Some(caps) = re.captures(line) { if let Some(m) = caps.get(1) { if let Some(ty) = normalize_inferred_type_name(m.as_str()) { @@ -2872,7 +5693,13 @@ fn infer_php_property_receiver_type( ) -> Option { let prop = receiver_with_prefix.strip_prefix("this->")?; let escaped = regex_escape(prop); - let patterns = php_property_type_patterns(&escaped); + // Compiled only once a line could match one: `… ?Foo $prop` writes the + // property after whitespace, `$this->prop = new Foo` its assignment. + let compiled: OnceCell>> = OnceCell::new(); + let property = format!("this->{prop}"); + let may_declare = |line: &str| { + writes_typed_variable(line, prop) || writes_assignment_of(line, &property, "new") + }; let source = context.read_file(&reference.file_path)?; let lines: Vec<&str> = source .split('\n') @@ -2886,10 +5713,18 @@ fn infer_php_property_receiver_type( for line in &lines { // A generated/minified line is not where a human declaration lives, and // regexing multi-KB text per ref is pure waste — skip it. - if line.len() > 10_000 { + if line.len() > 10_000 || !may_declare(line) { continue; } - for re in &patterns { + let patterns = compiled.get_or_init(|| { + context.compiled_patterns(&format!("php-property:{escaped}"), &|| { + php_property_type_patterns(&escaped) + .into_iter() + .map(|re| (re, PatternCheck::None)) + .collect() + }) + }); + for (re, _) in patterns.iter() { if let Some(caps) = re.captures(line) { if let Some(m) = caps.get(1) { if let Some(ty) = normalize_inferred_type_name(m.as_str()) { @@ -2901,7 +5736,7 @@ fn infer_php_property_receiver_type( } // Classic pre-7.4 untyped-property-assigned-in-constructor second chance. - infer_php_assigned_property_type(&escaped, &lines, call_idx) + infer_php_assigned_property_type(prop, &escaped, &lines, call_idx, context) } /// Try to resolve by method name on a class/object (`matchMethodCall`, @@ -2948,7 +5783,7 @@ pub fn match_method_call( // `this.#items.add()`, emitted as `this.#items.add` (#1987) — resolves // exactly like `this.` below (#1496). `#` is outside the dotted // receiver grammar, so the shape is matched here. - if is_js_family(reference.language) + if is_js_ts(reference.language) && let Some((field, method)) = split_private_this_field_call(&reference.reference_name) { return match_ts_this_field_call(field, method, reference, context); @@ -2957,6 +5792,24 @@ pub fn match_method_call( let parsed = parse_method_call(&reference.reference_name, reference.language)?; let (object_or_class, method_name) = parsed; + // A Go call through a two-hop field chain, `s.db.CreateItem()`, recorded + // by its last receiver segment (`db.CreateItem`, KEEP-RUST #1496): it + // resolves through the field's declared type, or not at all (upstream + // #1276, #2361). + if let Some((base, field)) = + crate::go_packages::go_call_chain(reference, &object_or_class, &method_name, context) + { + return crate::go_packages::go_field_chain_call( + &base, + &field, + &method_name, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + ); + } + // Rust call through one field of the enclosing type — emitted as // `self..`. This branch is exclusive: a missing, external, // generic, container, or ambiguous field type must stay unresolved rather @@ -2970,7 +5823,7 @@ pub fn match_method_call( } } - if is_js_family(reference.language) { + if is_js_ts(reference.language) { if let Some(field) = object_or_class.strip_prefix("this.") { return match_ts_this_field_call(field, &method_name, reference, context); } @@ -3013,19 +5866,150 @@ pub fn match_method_call( } } + // A Dart call through a type's name is to that type's own member, or to + // nothing in the project (upstream #2380): the static constant a call + // through the type invokes (#2367) among them. + if reference.language == Language::Dart + && is_dot_call(&reference.reference_name) + && let Some(own) = crate::dart_members::match_dart_type_member_call( + &object_or_class, + &method_name, + reference, + context, + ) + { + return own; + } + // Receiver-type inference (#1108/#1125): only for a simple `obj.method` / // `obj:method` (Lua) / `obj$method` (R) shape — a `Class::method` colon-call // is not an instance call. C++ uses its dedicated inferrer; every other // language uses the shared source-based inferrer. if is_inferable_receiver_call(&reference.reference_name, reference.language) { - let inferred = if reference.language == Language::Cpp { - infer_cpp_receiver_type(&object_or_class, reference, context, 0) + let mut cpp_declaration = CppReceiverDeclaration::default(); + // A CFML type as its declaration writes it: the component path tells + // same-named components apart (W4-CF-02); a Go one, the package + // declaring it (W4-GO-01). + let mut cfml_written: Option = None; + let mut go_written: Option = None; + let mut inferred = if reference.language == Language::Cpp { + infer_cpp_receiver_type( + &object_or_class, + reference, + context, + 0, + Some(&mut cpp_declaration), + ) } else { // The field lookup runs ONLY where the function-scope scan declined, // so it can add an inference but never change one (#1566, ESM only). - infer_local_receiver_type(&object_or_class, reference, context) + infer_local_receiver_declaration(&object_or_class, reference, context) + .map(|(name, written)| { + if reference.language == Language::Cfml { + cfml_written = Some(written); + } else if reference.language == Language::Go { + go_written = Some(written); + } + name + }) .or_else(|| infer_class_field_receiver_type(&object_or_class, reference, context)) + // A pytest test's parameter is what its fixture returns: + // flaskbb's `cli_runner.invoke(…)` is click's `CliRunner`, not + // the project's one `invoke` (upstream #2228). + .or_else(|| { + (reference.language == Language::Python) + .then(|| { + crate::python_values::python_fixture_return_type( + &object_or_class, + reference, + context, + ) + }) + .flatten() + }) }; + // A Go parameter named like an import is declared through a package + // as often as not (`logger logger.Interface`), which the patterns + // above don't read; the scope reader kept its type. Not a field's: in + // `metadata.Type.String()` that is the type of `metadata` (upstream + // #2448). + if inferred.is_none() + && reference.language == Language::Go + && is_dot_call(&reference.reference_name) + && !object_or_class.contains('.') + && let Some(declared) = crate::go_packages::go_ref_declared_type(reference, context) + { + inferred = normalize_inferred_type_name(&declared); + go_written = Some(declared); + } + // A field or property of the class around the call, or of one it + // inherits, is its declared type (upstream #2171): Newtonsoft's + // `_innerWriter.WriteValue(…)` is JsonWriter's. + if inferred.is_none() + && crate::member_types::is_member_typed_language(reference.language) + && is_dot_call(&reference.reference_name) + { + inferred = crate::member_types::infer_member_receiver_type( + &object_or_class, + reference, + context, + ); + // A member of a built-in type (`string _name`, `int count`) has + // no project method; a C# one keeps the project's extension + // method for it (KEEP-RUST, W3-05c), unless the name is one the + // keyword type declares itself. + if let Some(keyword) = inferred + .as_deref() + .filter(|t| t.starts_with(|c: char| c.is_ascii_lowercase())) + { + if reference.language != Language::CSharp + || is_std_method_name(Language::CSharp, &method_name) + { + return None; + } + return crate::csharp_extensions::extension_method_target( + keyword, + &method_name, + reference, + context, + ); + } + } + // A C# local or parameter of a keyword type (`this string format`) is + // an outside type as well: only the project's extension method for it + // (KEEP-RUST, W3-05c; the scorer below no longer takes the method's + // own words, upstream #2171). + if inferred.is_none() + && reference.language == Language::CSharp + && is_dot_call(&reference.reference_name) + && let Some(keyword) = csharp_keyword_local_type(&object_or_class, reference, context) + { + // `s.StartsWith(".")` is `string.StartsWith`, never a project + // extension of that name (upstream #2171's standard names). + if is_std_method_name(Language::CSharp, &method_name) { + return None; + } + return crate::csharp_extensions::extension_method_target( + &keyword, + &method_name, + reference, + context, + ); + } + // A type parameter is its bound; with none, only `Object`'s methods. + if let Some(type_name) = inferred.clone() + && crate::member_types::is_member_typed_language(reference.language) + && !context + .get_nodes_by_name_shared(&type_name) + .iter() + .any(|n| crate::kotlin_scope::is_method_owner_kind(n)) + { + match crate::member_types::type_parameter_bound(&type_name, reference, context) { + crate::member_types::TypeParameterBound::Unbounded => return None, + crate::member_types::TypeParameterBound::Bound(bound) => inferred = Some(bound), + crate::member_types::TypeParameterBound::NotParameter => {} + } + } if let Some(inferred) = inferred { // #1566 — an inferred type the call site cannot SEE is not this // project's type: a built-in `Map` must never resolve onto a @@ -3038,30 +6022,96 @@ pub fn match_method_call( return None; } // Java/Kotlin: the file's import pins WHICH same-named class (#314); - // other languages disambiguate by call-site file in resolve_method_on_type. + // Go: the package declaring the type (#2323); other languages + // disambiguate by call-site file in resolve_method_on_type. let imported_fqn = if matches!(reference.language, Language::Java | Language::Kotlin) { imported_fqn_of(&inferred, reference, context) + } else if reference.language == Language::Go { + crate::go_packages::go_declared_type_package( + go_written.as_deref(), + &reference.file_path, + context, + ) } else { None }; - if let Some(typed) = resolve_method_on_type( - &inferred, - &method_name, - reference, - context, - 0.9, - ResolvedBy::InstanceMethod, - imported_fqn.as_deref(), - 0, - ) { + let typed = if reference.language == Language::Cfml { + match crate::cfml_scope::typed_receiver_call( + cfml_written.as_deref().unwrap_or(&inferred), + &method_name, + reference, + context, + ) { + crate::cfml_scope::TypedReceiverCall::Method(method) => Some(ResolvedRef { + original: reference.clone(), + target_node_id: method.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::InstanceMethod, + }), + // The receiver is one of the namesakes and nothing says + // which: a method picked by name below is a guess. + crate::cfml_scope::TypedReceiverCall::Ambiguous => return None, + crate::cfml_scope::TypedReceiverCall::Missing => None, + } + } else { + resolve_method_on_type( + &inferred, + &method_name, + reference, + context, + 0.9, + ResolvedBy::InstanceMethod, + imported_fqn.as_deref(), + 0, + ) + }; + if let Some(typed) = typed { + // `medium and 0xff` on an Int: the standard library's + // member, not a project `Int.and(Long)` (upstream #2174). + if reference.language == Language::Kotlin + && context + .get_node_by_id_shared(&typed.target_node_id) + .is_some_and(|target| { + crate::kotlin_scope::is_kotlin_number_bitwise(&target, reference) + }) + { + return None; + } return Some(typed); } - } else { - match crate::awaited::infer_awaited_receiver(&object_or_class, reference, context) { - crate::awaited::AwaitedInference::NotApplicable => {} - crate::awaited::AwaitedInference::Unresolved => return None, - crate::awaited::AwaitedInference::Inferred { - type_name, + // The receiver's declared type is one the project doesn't declare + // (`List list`, `String s`, `path = Path("x")`), so the + // method is that outside type's, never a project method guessed + // by name (upstream #2169). Only a type name: the last segment of + // `java.util.List`, not of a call chain. A C++ declared type is + // read the way upstream's `normalizeCppTypeName` reads it, which + // the C++ inferrer here does not: `SkipList` is + // the project's `SkipList`. + let type_name = if reference.language == Language::Cpp { + cpp_declared_type_name(&inferred) + } else { + Some(inferred.rsplit('.').next().unwrap_or(&inferred).to_string()) + }; + if let Some(type_name) = type_name + && is_undeclared_type_name(&type_name, reference, context) + { + // KEEP-RUST (owner 2026-10-11, W3-05c): a C# call on an + // outside type still reaches the one project extension method + // in scope that takes it as `this` (`objectType.IsEnum()` on a + // `System.Type`); anything else stops here. + return crate::csharp_extensions::extension_method_target( + &type_name, + &method_name, + reference, + context, + ); + } + } else { + match crate::awaited::infer_awaited_receiver(&object_or_class, reference, context) { + crate::awaited::AwaitedInference::NotApplicable => {} + crate::awaited::AwaitedInference::Unresolved => return None, + crate::awaited::AwaitedInference::Inferred { + type_name, file_path, } => { // Resolve on the awaited type from its own file, and accept @@ -3091,6 +6141,48 @@ pub fn match_method_call( } } } + if cpp_declaration.aliased { + // A C++ receiver the calling function or its class declares + // through an alias has the type the alias names. When that type + // and its supertypes lack the method (`files_.clear()` through + // `using Files = std::vector;`), or the alias names a template + // parameter's type, a method picked by the receiver's name below + // would be some other type's (upstream #2399). + if !cpp_declaration.class_template + && (cpp_declaration.pointer + || is_cpp_dot_call(&object_or_class, reference, context)) + { + return None; + } + // Not so for `it->m()` on an iterator or smart pointer, whose `->` + // reaches an element type the alias doesn't name, nor for one of + // the project's class templates, whose specializations may declare + // members the template itself doesn't (rocksdb's + // `omt_node_templated` adds `get_marked`): that call goes + // on as it did before aliases were followed. + if is_undeclared_type_name(&cpp_declaration.written, reference, context) { + return None; + } + } + // A C++ receiver the calling function or its class declares as a + // type from outside the project with a lowercase name calls that + // type's own member, through `.` or a raw pointer's `->` (upstream + // #2413): leveldb's `std::string data_; data_.data()` went to + // `Slice::data`, protobuf's `std::string proto; proto.append(…)` to + // `LeftoverBuffer::append`. Not so for `->` on such a value (an + // iterator, smart pointer or optional hands the call to its element + // type), nor for a call that doesn't fit the declaration (`.` on a + // pointer), which then is another variable's. + if reference.language == Language::Cpp + && cpp_declaration.callers + && !cpp_declaration.aliased + && !cpp_declaration.shadowed + && cpp_member_operator(&object_or_class, reference, context) + == Some(if cpp_declaration.pointer { "->" } else { "." }) + && is_cpp_library_type(&cpp_declaration.raw, context) + { + return None; + } } // Java/Kotlin field-receiver inference (name-matcher.ts:975-996): the @@ -3117,40 +6209,50 @@ pub fn match_method_call( } } - // Same-file object-literal namespace: `const api = { call() {} }` followed - // by `api.call()`. The callable has a bare qualified name, so resolve it by - // direct source-range containment before class-shaped strategies. - if is_object_literal_language(reference.language) && !object_or_class.contains('.') { - let holders = prefer_call_site_file( - context.get_nodes_by_name_shared(&object_or_class), - &reference.file_path, - ); - for holder in holders.into_iter().filter(|node| { - node.file_path == reference.file_path - && matches!(node.kind, NodeKind::Constant | NodeKind::Variable) - }) { - if let Some(member) = resolve_object_literal_member( - &holder, - &method_name, - reference, - context, - 0.85, - ResolvedBy::InstanceMethod, - ) - .or_else(|| resolve_object_literal_binding(&holder, &method_name, reference, context)) - { - return Some(member); - } - } + // Object-literal namespace receiver (#1573): `api.call()` where `api` is a + // `const api = { call() {…}, get: () => {…} }`. Its members are functions + // inside the constant's extent, found by containment before the + // class-shaped strategies. The holder is the one the call can see: in its + // own file, the nearest enclosing declaration (an IIFE's `const App` + // before the file's); otherwise a global, `window.App = {…}` or a classic + // script's top-level `App`, when the file neither imports nor binds the + // name (upstream #2363). An imported holder reaches the same member + // through the import path. + if is_object_literal_language(reference.language) + && !object_or_class.contains('.') + && let Some(Some(member)) = crate::object_owners::resolve_object_path_member( + &object_or_class, + &method_name, + reference, + context, + None, + ) + { + return Some(member); } // Strategy 1: direct class name match (name-matcher.ts:825-850). When the // receiver names a class in several files, try the call site's own file - // first (#1079). - for class_node in prefer_call_site_file( + // first (#1079), and a C# class the call's namespaces can see before one + // they can't: serilog's `Some.InformationEvent()` in Serilog.Tests is its + // own Support namespace's `Some` (upstream #2220). + let mut class_candidates = prefer_call_site_file( context.get_nodes_by_name_shared(&object_or_class), &reference.file_path, - ) { + ); + if reference.language == Language::CSharp && class_candidates.len() > 1 { + let type_ref = RefView { + reference_name: object_or_class.clone(), + ..reference.clone() + }; + let (visible, hidden): (Vec>, Vec>) = + class_candidates.into_iter().partition(|c| { + c.language != Language::CSharp + || crate::csharp_types::is_csharp_type_visible(c, &type_ref, context) + }); + class_candidates = visible.into_iter().chain(hidden).collect(); + } + for class_node in class_candidates { // A Scala `object` owns methods callable as `Obj.method()` (#1824). if matches!( class_node.kind, @@ -3160,7 +6262,9 @@ pub fn match_method_call( if class_node.language != reference.language { continue; } - if let Some(method_node) = find_method_in_class(&class_node, &method_name, context) { + if let Some(method_node) = + find_method_in_class(&class_node, &method_name, context, &|_| true) + { return Some(ResolvedRef { original: reference.clone(), target_node_id: method_node.id, @@ -3170,6 +6274,54 @@ pub fn match_method_call( } } } + // A class method the named class inherits (Horse's `THorse.Get(…)` is + // THorseCore's, three `class(…)` heads up) before any guess by name + // (upstream #2180). The answer depends only on the language and the two + // names, so a pass computes it once (W5-08). + let tables = context.name_matcher_tables(); + let inherited = + tables.inherited_method(reference.language, &object_or_class, &method_name, || { + // The walk finds a method of the name or nothing: with no method + // of the name in the project, nothing (W5-08). + if tables + .method_owners(&method_name, || { + context + .get_nodes_by_name_shared(&method_name) + .iter() + .filter(|node| node.kind == NodeKind::Method) + .map(|node| member_owner_name(&node.qualified_name).to_string()) + .collect() + }) + .is_empty() + { + return None; + } + let walk = tables.supertype_walk(reference.language, &object_or_class, || { + context + .get_nodes_by_name_shared(&object_or_class) + .into_iter() + .filter(|class_node| { + class_node.language == reference.language + && (matches!( + class_node.kind, + NodeKind::Class + | NodeKind::Struct + | NodeKind::Union + | NodeKind::Interface + ) || crate::types::is_scala_singleton(class_node)) + }) + .collect() + }); + inherited_class_method(&walk, &method_name, context) + }); + if let Some(inherited) = inherited { + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: inherited.id.clone(), + confidence: 0.8, + resolved_by: ResolvedBy::QualifiedName, + }); + } // Built-in method names need a validated receiver (#1987). Typed, imported, // object-literal and direct class receivers have had their chance above; @@ -3183,6 +6335,100 @@ pub fn match_method_call( return None; } + // A receiver the file imports is past guessing. A namespace import is the + // module object, whose members are its exports, never some class's method; + // a binding from a package outside the repository names nothing in it. The + // import resolver placed what it could, and a method picked by name alone + // is wrong (upstream #2120: zod's `z.string()` bound a test helper's + // `string` getter). + if reference.reference_kind == EdgeKind::Calls + && is_js_family(reference.language) + && is_imported_module_receiver(&object_or_class, reference, context) + { + return None; + } + + // A receiver written as a type name the project doesn't declare + // (`Exception.Create(…)` in Delphi, `Collections.sort(…)`, + // `Object.assign(…)`) is a type from outside it: a same-named method of + // some project type is a guess (upstream #2166). + // A C# using alias names its type: `using Assert = + // Newtonsoft.Json.Tests.XUnitAssert;` (upstream #2171). + if reference.language == Language::CSharp + && let Some(aliased) = + crate::csharp_types::csharp_using_alias(&object_or_class, reference, context) + { + return resolve_method_on_type( + &aliased, + &method_name, + reference, + context, + 0.9, + ResolvedBy::InstanceMethod, + None, + 0, + ); + } + // In C#, Java and Rust a capitalized receiver is a type: a project + // property of that name elsewhere (a test object's `DateTime`) does not + // make `DateTime.Parse(…)` the project's (upstream #2171, #2177). + let types_only = matches!( + reference.language, + Language::CSharp | Language::Java | Language::Rust + ); + if names_external_type(&object_or_class, reference.language) + && !context + .get_nodes_by_name_shared(&object_or_class) + .iter() + .any(|n| { + same_language_family(n.language, reference.language) + && (!types_only + || crate::kotlin_scope::is_method_owner_kind(n) + || matches!( + n.kind, + NodeKind::Enum + | NodeKind::Namespace + | NodeKind::Module + | NodeKind::Trait + | NodeKind::TypeAlias + )) + }) + { + return None; + } + // Rust `task::spawn(…)` / `io::stdout()`: a function through a module + // path — never a method some type of that name owns (upstream #2177). + if reference.language == Language::Rust + && match_dot_call(&reference.reference_name).is_none() + && match_colon_call(&reference.reference_name).is_some() + && is_rust_module_path(&object_or_class) + { + return None; + } + // `string.Equals(…)`, `object.ReferenceEquals(…)`: a C# keyword type. + if reference.language == Language::CSharp + && matches!( + object_or_class.as_str(), + "string" + | "object" + | "int" + | "long" + | "short" + | "byte" + | "bool" + | "char" + | "double" + | "float" + | "decimal" + | "uint" + | "ulong" + | "ushort" + | "sbyte" + ) + { + return None; + } + // Strategy 2: instance-variable receiver → capitalized class // (name-matcher.ts:852-880), call-site file first (#1079). let capitalized = capitalize(&object_or_class); @@ -3199,7 +6445,10 @@ pub fn match_method_call( if class_node.language != reference.language { continue; } - if let Some(method_node) = find_method_in_class(&class_node, &method_name, context) + if let Some(method_node) = + find_method_in_class(&class_node, &method_name, context, &|method| { + callable_on_a_value(method, reference, context) + }) { return Some(ResolvedRef { original: reference.clone(), @@ -3222,18 +6471,73 @@ pub fn match_method_call( } let methods: Vec> = method_candidates .into_iter() - .filter(|n| n.kind == NodeKind::Method && n.name == method_name) + .filter(|n| { + n.kind == NodeKind::Method + && n.name == method_name + && callable_on_a_value(n, reference, context) + }) .collect(); let same_language: Vec> = methods .iter() .filter(|m| m.language == reference.language) .cloned() .collect(); - let target_methods = if !same_language.is_empty() { + let mut target_methods = if !same_language.is_empty() { same_language } else { methods }; + // A receiver the file imports is another module's value: never a method + // declared in the calling file (upstream #2134: expo-camera's + // `CameraManager.isAvailableAsync()` reached `CameraView`'s own static + // method, the one making the call). Ruling the caller's file out may + // reject a guess; it must never manufacture one, so the one method left + // is then no likelier than before. + let mut narrowed = false; + if is_js_family(reference.language) && is_import_binding(&object_or_class, reference, context) { + let before = target_methods.len(); + target_methods.retain(|method| method.file_path != reference.file_path); + narrowed = target_methods.len() != before; + } + // A handler the web framework dispatches to (a view's `post`, a + // controller's `update`) is never called through an instance by name, nor + // is a project's bitwise extension on a number type (`x and 0xff` is the + // standard library's), and ruling those out must not leave a lone other + // one to guess at (upstream #2175, #2174). + { + let before = target_methods.len(); + target_methods.retain(|method| { + !is_dispatched_handler(method) + && !crate::kotlin_scope::is_kotlin_number_bitwise(method, reference) + }); + narrowed |= target_methods.len() != before; + } + // A Vue component's own method is reached as `this.m()` inside it, never + // as `e.preventDefault()` on an event nor `this.editor.setValue()` on + // something the component holds (upstream #2140). A template ref + // (`this.$refs.form.validate()`) names a child this cannot tell apart. + // Production code never calls into a test suite (upstream #2171): its + // methods were never in the running, so ruling them out may leave one. + if !codegraph_core::file_class::is_test_path(&reference.file_path) { + target_methods + .retain(|method| !codegraph_core::file_class::is_test_path(&method.file_path)); + } + if is_js_family(reference.language) { + let before = target_methods.len(); + target_methods.retain(|method| { + !is_vue_component_method(method) + || (object_or_class == "this" && method.file_path == reference.file_path) + }); + narrowed |= target_methods.len() != before; + } + // What the receiver is named after is its last link (a constant's type): + // `builder.tokeniser` is a Tokeniser, `table.Columns` no kind of table. A + // standard-library method name on it (`reader.Value.ToString()`, + // `sym.map(…)`) is the library type's unless the receiver names the + // owner (upstream #2171). + let link = receiver_link(&object_or_class); + let std = is_std_method_name(reference.language, &method_name) + && !matches!(object_or_class.as_str(), "self" | "Self" | "this" | "base"); // A SINGLE same-named candidate is not on its own evidence that the receiver // is that candidate's type. Without the overlap check this branch bound every @@ -3248,9 +6552,46 @@ pub fn match_method_call( // redundant, and do NOT drop the language guard — the block below only // *bonuses* same language, it does not require it, and `Calls` edges get no // cross-family protection from `gate_language`. + // Where nothing types a receiver, the receiver-word rule (#226) also + // takes what upstream's owner-word rule does: the receiver's last link + // spelling the owner's name (`cbsecurity` -> CBSecurity), or a word of + // it with a trailing number dropped (`executor1` -> Executor; upstream + // #2148). + let untyped = crate::ruby_scope::is_untyped_receiver_language(reference.language); + // An Objective-C receiver also names the owner when it names a class, or + // ends in a property declared with a type, whose hierarchy has it + // (upstream #2150): `[managed.anyDataObj containsObject:]` is RLMSet's. + let objc_reaches = OnceCell::new(); + // A PHP one, when it names a class whose ancestry has it (upstream + // #2152): `$page->save()` is Entity's, Page extending it. + let php_reaches = OnceCell::new(); + let names_owner = |method: &Node| { + crate::kotlin_scope::shares_receiver_word(&object_or_class, method) + || (reference.language == Language::ObjC + && *objc_reaches.get_or_init(|| { + crate::objc_scope::objc_receiver_reaches(&object_or_class, method, context) + })) + || (reference.language == Language::Php + && *php_reaches.get_or_init(|| { + crate::php_scope::php_receiver_reaches(&object_or_class, method, context) + })) + }; if target_methods.len() == 1 + && !narrowed && target_methods[0].language == reference.language - && receiver_shares_word(&object_or_class, &target_methods[0].qualified_name) + && (receiver_shares_word(&object_or_class, &target_methods[0].qualified_name) + || (untyped && names_owner(&target_methods[0]))) + // A test double is only what a test names (upstream #2197). + && !is_unnamed_test_double(&target_methods[0], &object_or_class, reference, context) + && !(std && !receiver_names_owner(link, &target_methods[0], context)) + // Where nothing types a receiver, the one method of that name must + // also belong to something the receiver is named after + // (`web_push_request.legacy_encrypt` -> WebPushRequest; rubocop's + // `node.loc` went to the project's one `loc` 1,201 times; upstream + // #2147). + && !(untyped + && !crate::ruby_scope::is_self_receiver(&object_or_class) + && !names_owner(&target_methods[0])) { return Some(ResolvedRef { original: reference.clone(), @@ -3264,10 +6605,41 @@ pub fn match_method_call( // Same-file candidates first, so a score tie (`score > best_score` keeps // the first seen) resolves to the call site's own file (#1079). let ordered = prefer_call_site_file(target_methods, &reference.file_path); + let receiver_words = split_camel_case(link); + let head = receiver_words.last().map(|w| w.to_ascii_lowercase()); let mut best_match: Option<&Node> = None; let mut best_score = 0i64; for method in &ordered { - let mut score = receiver_word_overlap(&object_or_class, &method.qualified_name) as i64; + if std && !receiver_names_owner(link, method, context) { + continue; + } + // A test double is only what a test constructs or names, never a + // guess from `response.json()` in a file that never mentions it + // (upstream #2175). + if is_unnamed_test_double(method, &object_or_class, reference, context) { + continue; + } + // The owner type's own name, not its namespace (`eShop.ClientApp…` + // shares `Client` with every `httpClient`) nor the method's + // (upstream #2171). + let class_words = if method.qualified_name.contains("::") { + split_camel_case(member_owner_name(&method.qualified_name)) + } else { + Vec::new() + }; + let mut score = receiver_words + .iter() + .filter(|w| class_words.iter().any(|cw| cw.eq_ignore_ascii_case(w))) + .count() as i64; + // The receiver's head noun naming the owner's: `bookPage` is a + // Page before it is anything of a Book's. + if head.as_ref().is_some_and(|head| { + class_words + .last() + .is_some_and(|cw| cw.eq_ignore_ascii_case(head)) + }) { + score += 1; + } if method.language == reference.language { score += 1; } @@ -3276,8 +6648,12 @@ pub fn match_method_call( best_match = Some(method); } } + // A wrapper handing its call on (`FileStorage::delete` doing + // `$storage->delete($path)`) names the caller's own class only by a + // shared word: a guess that is the caller itself is no guess + // (upstream #2221). if let Some(best) = best_match { - if best_score >= 2 { + if best_score >= 2 && best.id != reference.from_node_id { return Some(ResolvedRef { original: reference.clone(), target_node_id: best.id.clone(), @@ -3754,7 +7130,16 @@ fn find_method_in_class( class_node: &Node, method_name: &str, context: &dyn ResolutionContext, + accept: &dyn Fn(&Node) -> bool, ) -> Option { + // A file holding no method of the name holds none in the class: its + // nodes are not read (W5-08). + if context + .method_files(method_name) + .is_some_and(|files| !files.contains(class_node.file_path.as_str())) + { + return None; + } context .get_nodes_in_file_shared(&class_node.file_path) .into_iter() @@ -3762,10 +7147,41 @@ fn find_method_in_class( n.kind == NodeKind::Method && n.name == method_name && n.qualified_name.contains(&class_node.name) + && accept(n) }) .map(|node| node.as_ref().clone()) } +/// The files holding a method named `name`, once per pass (W5-08): what a +/// production context's [`ResolutionContext::method_files`] answers. +pub(crate) fn method_files_of( + name: &str, + context: &C, +) -> Arc> { + context.name_matcher_tables().method_files(name, || { + context + .get_nodes_by_name_shared(name) + .iter() + .filter(|node| node.kind == NodeKind::Method) + .map(|node| node.file_path.clone()) + .collect() + }) +} + +/// Whether `method` can be what a call through a value means: a Dart named +/// constructor is reached through its class only (KEEP-RUST, W4d end; the +/// type path is [`crate::dart_members::match_dart_type_member_call`]), so the +/// value strategies never offer one. +fn callable_on_a_value( + method: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + !(reference.language == Language::Dart + && method.language == Language::Dart + && crate::dart_members::is_dart_constructor(method, context)) +} + fn capitalize(s: &str) -> String { let mut chars = s.chars(); match chars.next() { @@ -3797,7 +7213,7 @@ fn receiver_shares_word(receiver: &str, qualified_name: &str) -> bool { /// Split a camelCase/PascalCase string into words (`splitCamelCase`, /// `name-matcher.ts:941-946`). -fn split_camel_case(s: &str) -> Vec { +pub(crate) fn split_camel_case(s: &str) -> Vec { // Insert spaces at lower→upper and ACRONYM→Word boundaries, then split on // separators, then drop single-char words. let spaced = insert_camel_spaces(s); @@ -3839,7 +7255,7 @@ fn insert_camel_spaces(s: &str) -> String { /// Compute directory proximity between two file paths (`computePathProximity`, /// `name-matcher.ts:953-968`). -fn compute_path_proximity(file_path1: &str, file_path2: &str) -> f64 { +pub(crate) fn compute_path_proximity(file_path1: &str, file_path2: &str) -> f64 { let dir1: Vec<&str> = drop_last(file_path1); let dir2: Vec<&str> = drop_last(file_path2); let mut shared = 0usize; @@ -3864,7 +7280,7 @@ fn drop_last(path: &str) -> Vec<&str> { /// Find the best matching node among multiple candidates (`findBestMatch`, /// `name-matcher.ts:973-1055`). -fn find_best_match<'a, T: Borrow>( +pub(crate) fn find_best_match<'a, T: Borrow>( reference: &RefView, candidates: &'a [T], ) -> Option<&'a Node> { @@ -3954,10 +7370,175 @@ pub fn match_fuzzy(reference: &RefView, context: &dyn ResolutionContext) -> Opti } let callable_kinds = [NodeKind::Function, NodeKind::Method, NodeKind::Class]; + let case_insensitive = is_case_insensitive_language(reference.language); + let python_shape = crate::python_values::python_call_shape(reference, context); + let objc_shape = objc_call_shape(reference, context); + let objc_c_call = objc_shape == Some(ObjcCallShape::CCall); + let objc_send = objc_send_target(objc_shape); + let objc_hierarchy = OnceCell::new(); + let java_bare = crate::java_scope::is_java_bare_call(reference); + let java_call_scope = OnceCell::new(); + let ruby_bare = crate::ruby_scope::is_ruby_bare_call(reference); + let cfml_bare = crate::cfml_scope::is_cfml_bare_call(reference); + let kotlin_call = crate::kotlin_scope::is_kotlin_call(reference); + let kotlin_bare = + kotlin_call && crate::kotlin_scope::is_receiver_less_kotlin_call(reference, context); + let kotlin_site = crate::kotlin_scope::KotlinSite::new(reference); + let kotlin_std_chain = crate::kotlin_scope::std_chain_receiver(reference, context); + let scala_bare = crate::scala_scope::is_scala_bare_call(reference); + let csharp_type_ref = crate::csharp_types::is_csharp_type_ref(reference, context); + // A bare C# name whose line shows no receiver (upstream #2151). + let csharp_bare = crate::csharp_types::is_csharp_bare_name(reference) + && has_no_receiver_on_line(reference, context); + let swift_shape = crate::swift_scope::swift_call_shape(reference, context); + let go_shape = crate::go_calls::go_call_shape(reference, context); + let rust_scoped = crate::rust_scope::is_rust_bare_identifier(reference); + let php_self = crate::php_scope::php_self_scope(reference, context); + let bare_r = crate::r_scope::is_bare_r_call(reference, context); + let dart_bare = crate::dart_scope::is_dart_bare_call(reference, context); + let dart_type_ref = crate::dart_libraries::is_dart_type_ref(reference); + let dart_prefix = if dart_type_ref { + crate::dart_libraries::dart_import_prefix_of(reference, context) + } else { + None + }; + let solidity_bare = crate::solidity_scope::is_receiver_less_solidity_call(reference, context); let callable_candidates = apply_language_gate( candidates .into_iter() .filter(|n| callable_kinds.contains(&n.kind)) + // A plain R call is no object's method (upstream #2177). + .filter(|n| !(bare_r && n.kind == NodeKind::Method)) + // A bare Dart call stays in its class hierarchy (upstream #2145). + .filter(|n| { + !(dart_bare + && crate::dart_scope::is_dart_type_member(n, context) + && !crate::dart_scope::is_dart_method_in_scope(n, reference, context)) + }) + // A Dart constructor, only through its class (upstream #2380). + .filter(|n| { + !(reference.language == Language::Dart + && crate::dart_members::is_dart_constructor(n, context)) + }) + // A receiver-less Dart name, only what its library sees (upstream + // #2386). + .filter(|n| { + !((dart_bare || dart_type_ref) + && !crate::dart_libraries::is_dart_top_level_visible( + n, + reference, + context, + dart_prefix.as_deref(), + )) + }) + // C call syntax in Objective-C is no message, and a message to + // `self` or `super` stays in the sender's hierarchy (upstream + // #2150, #2179). + .filter(|n| !(objc_c_call && crate::objc_scope::is_objc_member_kind(n.kind))) + .filter(|n| { + objc_send.is_none_or(|to_super| { + crate::objc_scope::is_objc_self_send_target( + n, + reference, + objc_hierarchy + .get_or_init(|| { + crate::objc_scope::objc_hierarchy_at(reference, context) + }) + .as_deref(), + to_super, + ) + }) + }) + // A bare Java call reaches a method in scope (upstream #2126). + .filter(|n| { + !(java_bare + && n.kind == NodeKind::Method + && !java_call_scope + .get_or_init(|| crate::java_scope::JavaCallScope::of(reference, context)) + .reaches(n, reference)) + }) + // A bare Ruby call in a class, only its ancestry (upstream #2147). + .filter(|n| { + !(ruby_bare + && n.kind == NodeKind::Method + && !crate::ruby_scope::is_ruby_method_in_scope(n, reference, context)) + }) + // A bare CFML call in a component, only its chain (upstream #2148). + .filter(|n| { + !(cfml_bare + && n.kind == NodeKind::Method + && !crate::cfml_scope::is_cfml_method_in_scope(n, reference, context)) + }) + // A bare Solidity call, only its contract's `is` chain (upstream + // #2178). + .filter(|n| { + !(solidity_bare + && !crate::solidity_scope::is_solidity_member_in_scope(n, reference, context)) + }) + // Kotlin's visibility, member reach, number bitwise extensions + // and standard chain names, as in exact matching (upstream + // #2146, #2172, #2174, #2168). + .filter(|n| { + !kotlin_call + || crate::kotlin_scope::is_kotlin_top_level_visible(n, reference, context) + }) + .filter(|n| !kotlin_bare || kotlin_site.reaches(n, context)) + .filter(|n| !crate::kotlin_scope::is_kotlin_number_bitwise(n, reference)) + .filter(|n| { + kotlin_std_chain + .as_deref() + .is_none_or(|receiver| crate::kotlin_scope::is_std_chain_target(n, receiver)) + }) + // A bare Scala name, only a member in reach (upstream #2160). + .filter(|n| { + !scala_bare || crate::scala_scope::is_scala_member_in_scope(n, reference, context) + }) + // A C# type position, only a type (upstream #2121). + .filter(|n| !csharp_type_ref || crate::csharp_types::can_name_in_type_position(n)) + // A bare C# name, only a member in scope (upstream #2151). + .filter(|n| { + !csharp_bare + || crate::csharp_types::is_csharp_member_in_scope(n, reference, context) + }) + .filter(|n| { + python_shape.as_ref().is_none_or(|shape| { + crate::python_values::fits_python_call_shape(n, shape, reference, context) + }) + }) + // A Swift call's shape, as in exact matching (upstream #2170). + .filter(|n| { + swift_shape.as_ref().is_none_or(|shape| { + crate::swift_scope::is_swift_call_target(n, shape, reference, context) + }) + }) + // A Go call's shape, as in exact matching (upstream #2167). + .filter(|n| { + go_shape + .as_ref() + .is_none_or(|shape| crate::go_calls::is_go_call_target(n, shape)) + }) + // A bare Rust name, only what is in scope (upstream #2124, #2214). + .filter(|n| { + !rust_scoped || crate::rust_scope::is_rust_name_in_scope(n, reference, context) + }) + // A PHP call through `$this` / `self` / `parent`, only the class + // hierarchy's method (upstream #2142). + .filter(|n| { + php_self + .as_ref() + .is_none_or(|scope| n.kind == NodeKind::Method && scope.admits(n, context)) + }) + // `new …MockData()` makes an instance of a type; a method is never + // what it names (upstream #2141). + .filter(|n| { + !(reference.reference_kind == EdgeKind::Instantiates && n.kind == NodeKind::Method) + }) + // Names are case-sensitive in every language but a handful: a + // Java `@Test` is not a `test()` method, TS `Mock()` not `mock`, + // Python `Path(…)` not `path`. Only those whose identifiers + // resolve regardless of case use this fallback's lowercase index + // as such (upstream #2139). + .filter(|n| case_insensitive || n.name == reference.reference_name) .collect(), reference, ); @@ -3967,6 +7548,11 @@ pub fn match_fuzzy(reference: &RefView, context: &dyn ResolutionContext) -> Opti .filter(|n| n.language == reference.language) .cloned() .collect(); + // Upstream #2386 also prefers a Dart library's own declaration here. The + // port leaves it out, as it can never apply: exact matching keeps every + // own declaration this strategy keeps, so it has resolved the name + // already, and where exact matching declines past the ambiguity ceiling, + // this strategy declines first, on the unfiltered set. let final_candidates = if !same_language.is_empty() { same_language } else { @@ -3983,16 +7569,26 @@ pub fn match_fuzzy(reference: &RefView, context: &dyn ResolutionContext) -> Opti return None; }; let bare_js_rejects = is_bare_js_call(reference, context) - && (candidate.kind == NodeKind::Method + && (is_type_member_kind(candidate.kind) || (candidate.file_path != reference.file_path && is_locally_bound_js_name( &reference.reference_name, &reference.file_path, context, ))); + // A name the file binds from outside the repository names nothing in it, + // for every reference kind (upstream #2120, #2134). + let out_of_repo = is_js_family(reference.language) + && is_out_of_repo_binding(&reference.reference_name, reference, context); if bare_js_rejects + || out_of_repo || (candidate.kind == NodeKind::Method && (is_bare_go_call(reference, context) || rust_shape == Some(RustCallShape::Bare))) + // A bare PHP call is a function call (case-insensitive, so fuzzy may + // find one): never the class `View` for `view(…)`, never a method + // (upstream #2103). + || (candidate.kind != NodeKind::Function + && crate::php_scope::is_bare_php_call(reference, context)) // A builtin method call (`res.text()`) whose only same-named project // symbol is some function's closure must decline (#1708). || !is_lexically_reachable(candidate, reference, context) @@ -4194,77 +7790,838 @@ fn lookup_callee_return_type( .and_then(|n| n.return_type.clone()) } -/// Match all strategies in order of confidence (`matchReference`, -/// `name-matcher.ts:1093-1157`). -pub fn match_reference( +/// How a call the extractor recorded by its bare name is written in the +/// source (upstream #2223 `bareCallReceiver`). +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum BareCallReceiver { + /// `this.m()`, `self.m()`, `super.m()`, `super().m()`. + OwnObject, + /// Any other receiver: `window.$events.m()`, `make().m()`, `self.0.m()`. + /// `receiver` is the end of the receiver's text, its last 40 characters; + /// `links` are the member names between `this` and the method, for a + /// `this.a.b.m()` chain. + Other { + receiver: String, + links: Vec, + }, +} + +/// An ASCII word character or `$` (JS `[\w$]`). +fn is_js_word_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$' +} + +/// Where the receiver of a bare-recorded call is written, read from up to +/// eight source lines from the reference's column: the first `name` not +/// preceded by a word character and followed (past `<…>` or Scala `[…]` type +/// arguments) by `(` or `{`, whose preceding text ends in `.` or `?.`. +/// `None` for a call written bare, or not found. +pub(crate) fn bare_call_receiver( reference: &RefView, context: &dyn ResolutionContext, -) -> Option { - // 0. File path match. - if let Some(result) = match_by_file_path(reference, context) { - return Some(result); +) -> Option { + let name = reference.reference_name.as_str(); + if reference.reference_kind != EdgeKind::Calls || !is_simple_js_identifier(name) { + return None; } - - // 1. Qualified name match. - if let Some(result) = match_by_qualified_name(reference, context) { - return Some(result); + let facts = context.source_facts(&reference.file_path)?; + let start = usize::try_from(reference.line).ok()?.checked_sub(1)?; + let end = (start + 8).min(facts.line_count()); + if start >= end { + return None; } + let window = facts.join_lines(start, end, "\n"); + let column = usize::try_from(reference.column).unwrap_or(0); + let text = window.get(column..)?; + let tail = Regex::new(&format!( + r"^{}\s*(?:<[^<>()]*>|\[(?:[^\[\]]|\[[^\[\]]*\])*\])?\s*[({{]", + regex::escape(name) + )) + .ok()?; + let at = text.match_indices(name).map(|(at, _)| at).find(|&at| { + !text[..at].bytes().next_back().is_some_and(is_js_word_byte) && tail.is_match(&text[at..]) + })?; + let before = text[..at].trim_end(); + let head = before.strip_suffix('.')?; + let head = head.strip_suffix('?').unwrap_or(head).trim_end(); + static OWN_OBJECT: OnceLock = OnceLock::new(); + static SUPER_CALL: OnceLock = OnceLock::new(); + static THIS_CHAIN: OnceLock = OnceLock::new(); + let own_object = OWN_OBJECT.get_or_init(|| { + Regex::new(r"(?:^|[^A-Za-z0-9_$.])(?:this|self|super|Self)$").expect("own object") + }); + let super_call = SUPER_CALL + .get_or_init(|| Regex::new(r"(?:^|[^A-Za-z0-9_$.])super\s*\([^()]*\)$").expect("super()")); + if own_object.is_match(head) || super_call.is_match(head) { + return Some(BareCallReceiver::OwnObject); + } + let this_chain = THIS_CHAIN.get_or_init(|| { + Regex::new(r"(?:^|[^A-Za-z0-9_$.#])((?:this|super)(?:\s*\??\.\s*#?[A-Za-z0-9_$]+)+)$") + .expect("this chain") + }); + let links = this_chain + .captures(head) + .map(|chain| { + chain[1] + .split('.') + .skip(1) + .map(|link| { + link.chars() + .filter(|c| !c.is_whitespace() && *c != '?') + .collect() + }) + .collect() + }) + .unwrap_or_default(); + let skip = head.chars().count().saturating_sub(40); + Some(BareCallReceiver::Other { + receiver: head.chars().skip(skip).collect(), + links, + }) +} - // Erlang local call/fun refs (`f/1`) resolve only to the definition of the - // written arity. Language semantics make the call site's own module the - // strongest signal; a missing arity stays unresolved rather than linking a - // same-name sibling. - if reference.language == Language::Erlang - && !reference.reference_name.contains("::") - && matches!( - reference.reference_kind, - EdgeKind::Calls | EdgeKind::References - ) - && let Some((name, arity)) = split_erlang_arity(&reference.reference_name) - { - let arity_tail = format!("/{arity}"); - let candidates: Vec> = context - .get_nodes_by_name_shared(name) - .into_iter() - .filter(|candidate| { - candidate.language == Language::Erlang - && candidate.kind == NodeKind::Function - && candidate.qualified_name.ends_with(&arity_tail) - }) - .collect(); - if let Some(same_file) = candidates - .iter() - .find(|candidate| candidate.file_path == reference.file_path) - { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: same_file.id.clone(), - confidence: 0.95, - resolved_by: ResolvedBy::ExactMatch, - }); - } - if candidates.len() == 1 { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: candidates[0].id.clone(), - confidence: 0.8, - resolved_by: ResolvedBy::ExactMatch, - }); - } - if let Some(best) = find_best_match(reference, &candidates) { - let proximity = compute_path_proximity(&reference.file_path, &best.file_path); - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: best.id.clone(), - confidence: if proximity >= 30.0 { 0.7 } else { 0.4 }, - resolved_by: ResolvedBy::ExactMatch, - }); +/// Whether a call recorded by its bare name is written on something other +/// than the caller's own object (upstream #2221, #2223 +/// `isCollapsedNonRecursion`). +fn is_collapsed_non_recursion(reference: &RefView, context: &dyn ResolutionContext) -> bool { + match bare_call_receiver(reference, context) { + None | Some(BareCallReceiver::OwnObject) => false, + Some(BareCallReceiver::Other { links, .. }) => { + !(is_js_family(reference.language) + && is_collapsed_self_recursion(&links, reference, context)) } - return None; } +} - // 1c. `::`-scoped factory chain (PHP / Rust) (name-matcher.ts:1116-1123). - if matches!(reference.language, Language::Php | Language::Rust) { +/// Whether a collapsed `this..m()` inside `m` can be `m` itself: only +/// when the class declares the field as its own type, a tree node's +/// `this.left.insert(v)` (upstream #2221 `isCollapsedSelfRecursion`). +fn is_collapsed_self_recursion( + links: &[String], + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let [link] = links else { + return false; + }; + if link.contains('(') { + return false; + } + let field = link.strip_prefix('#').unwrap_or(link); + let Some(caller) = context.get_node_by_id_shared(&reference.from_node_id) else { + return false; + }; + let Some(cut) = caller.qualified_name.rfind("::").filter(|&cut| cut > 0) else { + return false; + }; + let owner = caller.qualified_name[..cut] + .rsplit("::") + .next() + .unwrap_or_default(); + let Some(class) = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .find(|node| { + node.kind == NodeKind::Class + && node.name == owner + && node.start_line <= reference.line + && node.end_line >= reference.line + }) + else { + return false; + }; + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let first = usize::try_from(class.start_line) + .unwrap_or(1) + .saturating_sub(1); + let last = usize::try_from(class.end_line) + .unwrap_or(0) + .min(facts.line_count()); + if first >= last { + return false; + } + let body = facts.join_lines(first, last, "\n"); + let field = regex::escape(field); + let annotated = Regex::new(&format!( + r"(?m)(?:^|[\s(,])#?{field}\s*[?!]?\s*:\s*([A-Za-z_$][A-Za-z0-9_$]*)" + )); + let constructed = Regex::new(&format!( + r"(?-u:\b)this\.{field}\s*=\s*new\s+([A-Za-z_$][A-Za-z0-9_$]*)" + )); + let declared = annotated + .ok() + .and_then(|re| re.captures(&body).map(|c| c[1].to_string())) + .or_else(|| { + constructed + .ok() + .and_then(|re| re.captures(&body).map(|c| c[1].to_string())) + }); + declared.as_deref() == Some(owner) +} + +/// A declaration's dotted owner path: its file's `package` clauses, then its +/// enclosing types (`cats.kernel.instances`, `cats.Functor`; upstream #2229 +/// `ownerPathOf`). +fn owner_path_of(node: &Node, context: &dyn ResolutionContext) -> String { + static PACKAGE: OnceLock = OnceLock::new(); + let package = PACKAGE.get_or_init(|| { + Regex::new(r"(?m)^\s*package\s+([A-Za-z0-9_.]+)\s*;?\s*$").expect("package clause") + }); + let text = context.read_file(&node.file_path).unwrap_or_default(); + let pkg = package + .captures_iter(&text) + .map(|c| c[1].to_string()) + .collect::>() + .join("."); + let owners = node + .qualified_name + .rfind("::") + .filter(|&cut| cut > 0) + .map(|cut| node.qualified_name[..cut].replace("::", ".")) + .unwrap_or_default(); + [pkg.as_str(), owners.as_str()] + .into_iter() + .filter(|part| { + !part.is_empty() && !(!pkg.is_empty() && *part == owners && owners.starts_with(&pkg)) + }) + .collect::>() + .join(".") +} + +/// The supertype an inheritance ref names when the name is the declaring +/// type's own: the other type of that name the written qualifier +/// (`cats.kernel.instances.`, `Functor.`) leads to by its owner path, or +/// `None` unless exactly one fits (upstream #2229 `otherSupertypeNamed`). +fn other_supertype_named( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let name = reference + .reference_name + .rsplit("::") + .next() + .and_then(|tail| tail.rsplit('.').next()) + .unwrap_or_default(); + if name.is_empty() { + return None; + } + let line = usize::try_from(reference.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .and_then(|index| { + context + .source_facts(&reference.file_path) + .and_then(|facts| facts.raw_line(index).map(str::to_string)) + }) + .unwrap_or_default(); + let column = usize::try_from(reference.column).unwrap_or(0); + let rest = line.get(column..).unwrap_or_default(); + let qualifier = Regex::new(&format!( + r"^\s*((?:[A-Za-z0-9_$]+\.)+){}(?-u:\b)", + regex::escape(name) + )) + .ok() + .and_then(|re| { + re.captures(rest) + .map(|c| c[1].trim_end_matches('.').to_string()) + }) + .unwrap_or_default(); + let candidates: Vec> = context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| { + node.id != reference.from_node_id + && node_is_eligible_target(reference.reference_kind, node) + && same_language_family(node.language, reference.language) + && (qualifier.is_empty() || { + let path = owner_path_of(node, context); + path == qualifier || path.ends_with(&format!(".{qualifier}")) + }) + }) + .collect(); + // Among several, the declaring file's own, then for `implements` a + // protocol, interface or trait (upstream #2242): SDWebImage's + // `@interface SDWebImageCacheKeyFilter : NSObject `. + let mut pool = candidates; + if pool.len() > 1 && pool.iter().any(|n| n.file_path == reference.file_path) { + pool.retain(|n| n.file_path == reference.file_path); + } + let conformable = |n: &Arc| { + matches!( + n.kind, + NodeKind::Protocol | NodeKind::Interface | NodeKind::Trait + ) + }; + if pool.len() > 1 + && reference.reference_kind == EdgeKind::Implements + && pool.iter().any(conformable) + { + pool.retain(conformable); + } + let [candidate] = pool.as_slice() else { + return None; + }; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: candidate.id.clone(), + confidence: 0.8, + resolved_by: ResolvedBy::QualifiedName, + }) +} + +/// Reference kinds a bare JS/TS local can be: a call, a value (function refs +/// included), a construction (upstream `JS_LOCAL_REF_KINDS`). +fn is_js_local_ref_kind(kind: EdgeKind) -> bool { + matches!( + kind, + EdgeKind::Calls | EdgeKind::References | EdgeKind::Instantiates + ) +} + +/// A name the calling JS/TS function binds itself shadows the file's own +/// (upstream #2226, in `matchReference`): lodash's `mixin(object, …)` calling +/// `object(this.__wrapped__)` is its parameter, whichever strategy (fuzzy +/// included) found a `function object`. A target of another name is what the +/// local was followed to (`const selected = useStore(s => s.reset); +/// selected()` is the store's `reset`) and stays. +pub fn veto_js_local_shadow( + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let result = result?; + // Only a JS/TS function binds a local here ([`js_function_local_scope`] + // answers `None` for any other language), so nothing else is read for + // another language's reference. + if !is_js_family(reference.language) || !is_js_local_ref_kind(reference.reference_kind) { + return Some(result); + } + let Some(target) = context.get_node_by_id_shared(&result.target_node_id) else { + return Some(result); + }; + if target.name == reference.reference_name && is_outside_js_local(&target, reference, context) { + return None; + } + Some(result) +} + +/// Whether `node` lies outside the function that binds the reference's name +/// itself ([`js_function_local_scope`]; upstream `isOutsideJsLocal`). +fn is_outside_js_local(node: &Node, reference: &RefView, context: &dyn ResolutionContext) -> bool { + // Only a function that binds the name itself has a local of it; most do + // not, so that is read first and the receiver (a regex per call) only + // for one that does. + let Some((start, end)) = js_function_local_scope(&reference.reference_name, reference, context) + else { + return false; + }; + // `const indexName = this.dataSource.namingStrategy.indexName(…)`: a + // member, whatever the local's name. + if reference.reference_kind == EdgeKind::Calls + && bare_call_receiver(reference, context).is_some() + { + return false; + } + !(node.file_path == reference.file_path && start <= node.start_line && node.start_line <= end) +} + +/// The lines of the JS/TS function a reference sits in when that function +/// binds the name itself, a parameter or a `var` / `let` / `const` above the +/// reference (upstream #2226 `jsFunctionLocalScope`). Such a name is the +/// local, never a same-named function declared elsewhere: every lodash +/// helper lives inside `runInContext`, so `baseHas(object, key)`'s `object` +/// reached a `function object() {}` an IIFE declares there. `None` when the +/// function does not bind it. +pub(crate) fn js_function_local_scope( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option<(i64, i64)> { + if !is_js_family(reference.language) || !is_simple_js_identifier(name) { + return None; + } + let function = context.get_node_by_id_shared(&reference.from_node_id)?; + if !matches!(function.kind, NodeKind::Function | NodeKind::Method) + || function.start_line > reference.line + || function.end_line < reference.line + { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + // Every function that starts on the same line has the same code through + // the reference's line, so they share the answer (upstream #2334). + let key = format!("{}\0{}\0{name}", function.start_line, reference.line); + facts + .node_decision("js_code_binds", &key, |facts| { + js_code_binds_name(name, &function, reference.line, facts) + }) + .then_some((function.start_line, function.end_line)) +} + +/// Whether `function`'s code from its first line through `line` declares +/// `name` with `var` / `let` / `const` or names it in a parameter list +/// (upstream `jsCodeBindsName`), comments and strings blanked. +fn js_code_binds_name(name: &str, function: &Node, line: i64, facts: &SourceFacts) -> bool { + let Some(start) = usize::try_from(function.start_line) + .ok() + .and_then(|line| line.checked_sub(1)) + else { + return false; + }; + let through = usize::try_from(line.min(function.end_line)) + .unwrap_or(0) + .min(facts.line_count()); + if start >= through { + return false; + } + let code = crate::strip_comments::strip_comments_for_regex( + &facts.join_lines(start, through, "\n"), + crate::strip_comments::CommentLang::JavaScript, + ); + if js_declares_name(&code, name) { + return true; + } + // A parameter list, never a control-flow head (`if (openMarkerClose) {`): + // tried at each `(` not preceded by `if` / `while` / `for` / `switch` / + // `with` (upstream's `(?=\()(?> = OnceCell::new(); + code.match_indices('(').any(|(at, _)| { + may_open_js_parameters(&code, at, name) + && !follows_control_keyword(&code[..at]) + && parameter + .get_or_init(|| js_parameter_pattern(name)) + .as_ref() + .is_some_and(|pattern| pattern.is_match(&code[at..])) + }) +} + +/// Whether `code` declares `name` with `var` / `let` / `const`: a match of +/// `(?-u:\b)(?:const|let|var)\s+{name}(?-u:\b)`, found left to right without +/// overlap, that no `,`, `]` or `}` follows past whitespace. Destructuring +/// re-binds what a call returns under the same name (`const { t } = +/// useI18n()`), which is the same-named function more often than not, so +/// `const a, …` / `[a]` / `{ a }` forms (`(?!\s*[,\]}])` upstream) do not +/// count. `name` is a simple JS identifier. +fn js_declares_name(code: &str, name: &str) -> bool { + let bytes = code.as_bytes(); + let is_word = |at: usize| { + bytes + .get(at) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') + }; + let name_ends_in_word = name + .bytes() + .next_back() + .is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + // Where the last match ended: the next one starts at or after it. + let mut searched = 0; + for at in 0..bytes.len() { + if at < searched || (at > 0 && is_word(at - 1)) { + continue; + } + let Some(keyword) = ["const", "let", "var"] + .into_iter() + .find(|keyword| bytes[at..].starts_with(keyword.as_bytes())) + else { + continue; + }; + let after = &code[at + keyword.len()..]; + let spaced = after.trim_start(); + if spaced.len() == after.len() || !spaced.starts_with(name) { + continue; + } + let end = code.len() - spaced.len() + name.len(); + if name_ends_in_word == is_word(end) { + continue; + } + if !code[end..].trim_start().starts_with([',', ']', '}']) { + return true; + } + searched = end; + } + false +} + +/// Whether the `(` at byte `at` of `code` could open a parameter list +/// [`js_parameter_pattern`] matches; never false where it does. The pattern +/// reads no parenthesis inside the list, so the list ends at the first one +/// after `at`, a `)`, holds `name`, and is followed, past whitespace, by +/// `=>`, `{` or a return type's `:`. +fn may_open_js_parameters(code: &str, at: usize, name: &str) -> bool { + let list = &code[at + 1..]; + let Some(close) = list.find(['(', ')']) else { + return false; + }; + if list.as_bytes()[close] != b')' || !list[..close].contains(name) { + return false; + } + let tail = list[close + 1..].trim_start(); + tail.starts_with("=>") || tail.starts_with(['{', ':']) +} + +/// Whether `before` ends, past trailing whitespace, with `if`, `while`, +/// `for`, `switch` or `with` as a whole word. +fn follows_control_keyword(before: &str) -> bool { + let before = before.trim_end(); + ["if", "while", "for", "switch", "with"] + .iter() + .any(|keyword| { + before.strip_suffix(keyword).is_some_and(|head| { + !head + .bytes() + .next_back() + .is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }) + }) +} + +/// The parameter form of [`js_code_binds_name`] for one plain identifier: +/// [`LocalBindingPatterns`]' parameter form, its return type kept to one +/// line (`(?::[^=;{}()\n]*)?`), so a ternary's `: data.slice()` below +/// `filter(canRowExpand)` is no return type. +fn js_parameter_pattern(name: &str) -> Option { + let n = regex::escape(name); + Regex::new(&format!( + r"^\(\s*(?:(?:\.\.\.)?[0-9A-Za-z_$]+(?:\s*(?:\?\s*)?:[^,()]+|\s*=[^,()]+|\s*),\s*)*{n}(?-u:\b)(?:\s*\??\s*:[^,()]*)?(?:\s*=[^,()]*)?(?:\s*,\s*[^()]*)?\)\s*(?::[^=;{{}}()\n]*)?(?:=>|\{{)" + )) + .ok() +} + +/// C has no methods, and C code cannot call a C++ one (upstream #2234, in +/// `matchReference`): hiredis' function pointer `c->funcs->read(c, buf, …)` +/// and redis' POSIX `read(fd, buf, n)` are no Qt adapter's `read`. +pub fn veto_c_method_target( + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let result = result?; + if reference.language == Language::C + && reference.reference_kind == EdgeKind::Calls + && context + .get_node_by_id_shared(&result.target_node_id) + .is_some_and(|target| target.kind == NodeKind::Method) + { + return None; + } + Some(result) +} + +/// How a bare Objective-C name is written at its site (upstream #2150 +/// `objcCallShape`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ObjcCallShape { + /// C call syntax (`completionBlock()`): a function, a block or a function + /// pointer, never a method or property. + CCall, + /// A message to `self` or `[self class]`, whose receiver the extractor + /// drops. + SelfSend, + /// A message to `super`. + SuperSend, +} + +/// Whether a shape is a message to `self` (`Some(false)`) or to `super` +/// (`Some(true)`), which reach only the sender's hierarchy. +fn objc_send_target(shape: Option) -> Option { + match shape { + Some(ObjcCallShape::SelfSend) => Some(false), + Some(ObjcCallShape::SuperSend) => Some(true), + _ => None, + } +} + +/// The shape of the Objective-C call `reference` (a bare name or selector) on +/// its line, `None` when the line shows neither. +fn objc_call_shape(reference: &RefView, context: &dyn ResolutionContext) -> Option { + static SELECTOR: OnceLock = OnceLock::new(); + static MESSAGE: OnceLock = OnceLock::new(); + static SUPER: OnceLock = OnceLock::new(); + static SELF: OnceLock = OnceLock::new(); + if reference.language != Language::ObjC + || reference.reference_kind != EdgeKind::Calls + || !SELECTOR + .get_or_init(|| { + Regex::new(r"^[A-Za-z_][0-9A-Za-z_]*:*(?:[0-9A-Za-z_]+:)*$").expect("selector") + }) + .is_match(&reference.reference_name) + { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + let index = usize::try_from(reference.line - 1) + .ok() + .filter(|&index| index < facts.line_count())?; + let line = facts.line(index); + let name = reference.reference_name.split(':').next()?; + if name.is_empty() { + return None; + } + let word = Regex::new(&format!(r"\b{}\b", regex_escape(name))).ok()?; + let message = MESSAGE.get_or_init(|| Regex::new(r"\[\s*[\w.]+\s+$").expect("message")); + let to_super = SUPER.get_or_init(|| Regex::new(r"\[\s*super\s+$").expect("super")); + let to_self = + SELF.get_or_init(|| Regex::new(r"\[\s*(?:self|\[\s*self\s+class\s*\])\s+$").expect("self")); + let mut shape = None; + for found in word.find_iter(line) { + // Not a member written after `.` (`object.name`), nor the tail of a + // longer word. + if line[..found.start()] + .chars() + .next_back() + .is_some_and(|c| c == '.' || c == '_' || c.is_alphanumeric()) + { + continue; + } + let before = &line[..found.start()]; + let after = &line[found.end()..]; + if after.trim_start().starts_with('(') && !message.is_match(before) { + shape.get_or_insert(ObjcCallShape::CCall); + } else if to_super.is_match(before) { + return Some(ObjcCallShape::SuperSend); + } else if to_self.is_match(before) { + return Some(ObjcCallShape::SelfSend); + } + } + shape +} + +/// The name matcher's self vetoes (upstream `matchReference`): a result that +/// lands on the reference's own node is the caller only when nothing rules it +/// out. A call written on another object is not (#2221, #2223); a type never +/// inherits from itself, so the other type of its name the qualifier leads to +/// is chosen instead (#2229); and a value's initializer never calls the value +/// (#2223). +pub fn veto_self_target( + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let result = result?; + if result.target_node_id != reference.from_node_id { + return Some(result); + } + if is_collapsed_non_recursion(reference, context) { + return None; + } + if crate::types::is_inheritance_ref(reference.reference_kind) { + return other_supertype_named(reference, context); + } + if reference.reference_kind == EdgeKind::Calls + && context + .get_node_by_id_shared(&reference.from_node_id) + .is_some_and(|from| { + matches!( + from.kind, + NodeKind::Variable | NodeKind::Constant | NodeKind::Field | NodeKind::Property + ) + }) + { + return None; + } + Some(result) +} + +/// Match all strategies in order of confidence (`matchReference`, +/// `name-matcher.ts:1093-1157`). +pub fn match_reference( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + // `import java.lang.reflect.Field;`: the file's `Field` is the JDK's, + // never a project class of that name, by any strategy (upstream #2195: + // gson's production code bound it to a test's nested + // `ParameterizedTypesTest.Field`). + if crate::java_scope::binds_an_outside_import(reference, context) { + return None; + } + + // A Swift call through a type path (`API.PackageController.GetRoute.query`) + // resolves on the type the path names, or not at all: the strategies below + // would bind it by the member's name alone (upstream #2118). + if crate::swift_type_visibility::is_swift_type_path_call(reference) { + return crate::swift_type_visibility::resolve_swift_type_path_call(reference, context); + } + + // A Dart call through an import prefix (`http.get(…)`) is to what the + // prefixed import exports, or to nothing: the strategies below would take + // the prefix for a receiver and guess a method by its name (upstream + // #2389). + if let Some(prefixed) = crate::dart_prefixes::match_dart_prefixed_call(reference, context) { + return prefixed; + } + + // A symbolic name in a Scala type is a type (`F ~> G`) or a + // kind-projector placeholder (`Either[A, *]`), never an operator method, + // by any strategy (upstream #2217). + if let Some(result) = crate::scala_scope::match_scala_symbolic_type(reference, context) { + return result; + } + + // A bare Lua call through a `local` alias reaches what the alias names + // (upstream #2188); `ipairs(t)` is Lua's, unless the file defines its own + // (upstream #2202: telescope's 122 `for _, v in ipairs(…)` went to a + // linked list's `ipairs` method). + if crate::lua_scope::is_lua_bare_call(reference) { + if let Some(aliased) = crate::lua_scope::lua_alias_target(reference, context) { + return aliased; + } + if crate::lua_scope::LUA_GLOBAL_FUNCTIONS.contains(&reference.reference_name.as_str()) + && !context + .get_nodes_in_file_shared(&reference.file_path) + .iter() + .any(|node| { + node.name == reference.reference_name && node.kind == NodeKind::Function + }) + { + return None; + } + } + + // A Scala chain link the extractor records with its last receiver + // segment, `b.c` for `a.b.c()` (KEEP-RUST G14), is the bare `c` upstream + // records: what the receiver strategies below choose must pass + // upstream's chain-link rule for it, and when they choose nothing the + // bare name matches as upstream matches it (upstream #2160, #2199). + let scala_link = + crate::scala_scope::scala_nested_chain_link(reference, context).map(|bare| RefView { + reference_name: bare, + ..reference.clone() + }); + // So is a Swift one (`entries.removeAll` for `axis.entries.removeAll()`, + // KEEP-RUST #1496): upstream's chained-call rule for the bare name + // (#2170). + let swift_link = + crate::swift_scope::swift_nested_chain_link(reference, context).map(|bare| RefView { + reference_name: bare, + ..reference.clone() + }); + let in_link_scope = |result: ResolvedRef| -> Option { + if let Some(bare) = scala_link.as_ref() { + let target = context.get_node_by_id_shared(&result.target_node_id)?; + return crate::scala_scope::is_scala_member_in_scope(&target, bare, context) + .then_some(result); + } + if let Some(bare) = swift_link.as_ref() { + let target = context.get_node_by_id_shared(&result.target_node_id)?; + let shape = crate::swift_scope::swift_call_shape(bare, context); + return shape + .as_ref() + .is_none_or(|shape| { + crate::swift_scope::is_swift_call_target(&target, shape, bare, context) + }) + .then_some(result); + } + Some(result) + }; + + // `window.App.init()` / a sibling's `this.init()`, recorded by the bare + // name: the object they are written on comes first (upstream #2363). + if let Some(result) = crate::object_owners::match_collapsed_object_call(reference, context) { + return Some(result); + } + + // 0. File path match. + if let Some(result) = match_by_file_path(reference, context) { + return Some(result); + } + + // 1. Qualified name match. + if let Some(result) = match_by_qualified_name(reference, context) { + return Some(result); + } + + // Erlang local call/fun refs (`f/1`) resolve only to the definition of the + // written arity. Language semantics make the call site's own module the + // strongest signal; a missing arity stays unresolved rather than linking a + // same-name sibling. + if reference.language == Language::Erlang + && !reference.reference_name.contains("::") + && matches!( + reference.reference_kind, + EdgeKind::Calls | EdgeKind::References + ) + && let Some((name, arity)) = split_erlang_arity(&reference.reference_name) + { + let arity_tail = format!("/{arity}"); + let candidates: Vec> = context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|candidate| { + candidate.language == Language::Erlang + && candidate.kind == NodeKind::Function + && candidate.qualified_name.ends_with(&arity_tail) + }) + .collect(); + if let Some(same_file) = candidates + .iter() + .find(|candidate| candidate.file_path == reference.file_path) + { + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: same_file.id.clone(), + confidence: 0.95, + resolved_by: ResolvedBy::ExactMatch, + }); + } + // Another module's function is called bare only through `-import(Mod, + // [f/N])`, or from a `.hrl` a module includes (upstream #2178): + // cowboy's `-import(req_SUITE, [do_get/3])` went to compress_SUITE's + // `do_get/3`. + let imports = context + .source_facts(&reference.file_path) + .map(|facts| facts.erlang_imports()) + .unwrap_or_default(); + if let Some(module) = crate::erlang_imports::imported_module(&imports, name, arity) { + let prefix = format!("{module}::"); + return candidates + .iter() + .find(|candidate| candidate.qualified_name.starts_with(&prefix)) + .map(|chosen| ResolvedRef { + original: reference.clone(), + target_node_id: chosen.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::ExactMatch, + }); + } + let candidates: Vec> = + if crate::erlang_imports::is_erlang_header(&reference.file_path) { + candidates + } else { + candidates + .into_iter() + .filter(|candidate| { + crate::erlang_imports::is_erlang_header(&candidate.file_path) + }) + .collect() + }; + if candidates.len() == 1 { + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: candidates[0].id.clone(), + confidence: 0.8, + resolved_by: ResolvedBy::ExactMatch, + }); + } + if let Some(best) = find_best_match(reference, &candidates) { + let proximity = compute_path_proximity(&reference.file_path, &best.file_path); + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: best.id.clone(), + confidence: if proximity >= 30.0 { 0.7 } else { 0.4 }, + resolved_by: ResolvedBy::ExactMatch, + }); + } + return None; + } + + // 1c. `::`-scoped factory chain (PHP / Rust) (name-matcher.ts:1116-1123). + if matches!(reference.language, Language::Php | Language::Rust) { if let Some(result) = match_scoped_call_chain(reference, context) { return Some(result); } @@ -4282,7 +8639,7 @@ pub fn match_reference( | Language::Scala | Language::Dart ) { - if let Some(result) = match_dotted_call_chain(reference, context) { + if let Some(result) = match_dotted_call_chain(reference, context).and_then(&in_link_scope) { return Some(result); } } @@ -4293,16 +8650,24 @@ pub fn match_reference( // `run` (#1683). The one exception is a store accessor, whose store is // identified (`useStore.getState().reset`, #1862). if reference.reference_kind == EdgeKind::Calls - && (is_js_family(reference.language) || reference.language == Language::Python) + && (is_js_ts(reference.language) || reference.language == Language::Python) && reference.reference_name.contains("().") { return crate::js_store::match_store_accessor_chain(reference, context); } // 2. Method call pattern. - if let Some(result) = match_method_call(reference, context) { + if let Some(result) = match_method_call(reference, context).and_then(&in_link_scope) { return Some(result); } + if let Some(bare) = scala_link.as_ref().or(swift_link.as_ref()) { + return match_by_exact_name(bare, context) + .or_else(|| match_fuzzy(bare, context)) + .map(|result| ResolvedRef { + original: reference.clone(), + ..result + }); + } // 3. Exact name match. if let Some(result) = match_by_exact_name(reference, context) { @@ -4310,7 +8675,19 @@ pub fn match_reference( } // 4. Fuzzy match. - match_fuzzy(reference, context) + if let Some(result) = match_fuzzy(reference, context) { + return Some(result); + } + + // 5. A C / C++ name qualified by a namespace the project opens with a + // macro (`fmt::format`, `FMT_BEGIN_NAMESPACE`), which the index cannot + // see (upstream #2176). + if matches!(reference.language, Language::C | Language::Cpp) + && reference.reference_name.contains("::") + { + return crate::cpp_namespaces::match_cpp_macro_namespaced(reference, context); + } + None } fn split_erlang_arity(name: &str) -> Option<(&str, &str)> { @@ -4425,7 +8802,7 @@ pub fn match_function_ref( }); } - let mut candidates: Vec> = context + let named: Vec> = context .get_nodes_by_name_shared(&reference.reference_name) .into_iter() .filter(|n| { @@ -4436,85 +8813,416 @@ pub fn match_function_ref( && n.id != reference.from_node_id }) .collect(); + // A function declared inside another is in scope only in there: httpx's + // `self._build_auth(auth)` passes its own parameter, not the `auth` a test + // defines inside `test_custom_auth`. Those still count against a lone + // cross-file guess below: a name several functions use for themselves is + // as likely a local's (upstream #2219). + let mut candidates: Vec> = named + .iter() + .filter(|n| is_lexically_reachable(n, reference, context)) + // A Dart constructor is torn off through its class, never by a bare + // name (upstream #2380). + .filter(|n| !crate::dart_members::is_dart_constructor(n, context)) + .cloned() + .collect(); if candidates.is_empty() { return None; } + // A Python name the function around it binds (a parameter, an + // assignment) is that local's value: httpx's `auth_flow(self, request)` + // handing `request` on is not the package's `request()` function. A + // pytest fixture is what a test's parameter of its name receives. + if reference.language == Language::Python + && !candidates + .iter() + .any(|n| crate::python_values::is_fixture_in_reach(n, &reference.file_path, context)) + && crate::python_values::is_python_locally_bound( + &reference.reference_name, + reference, + context, + ) + { + return None; + } + // Likewise a JS/TS parameter or local: lodash's `baseHas(object, key)` + // passes its own `object` (upstream #2226). + if let Some((start, end)) = + js_function_local_scope(&reference.reference_name, reference, context) + { + candidates.retain(|n| { + n.file_path == reference.file_path && start <= n.start_line && n.start_line <= end + }); + if candidates.is_empty() { + return None; + } + } + + // Swift implicit-self: a bare identifier names a METHOD only of the + // ENCLOSING type; a same-named method on another class is a parameter + // collision (name-matcher.ts:250-270). + if reference.language == Language::Swift + && candidates.iter().any(|n| n.kind == NodeKind::Method) + { + let class_prefix = context + .get_node_by_id_shared(&reference.from_node_id) + .and_then(|f| { + let sep = f.qualified_name.rfind("::")?; + if sep > 0 { + Some(f.qualified_name[..sep].to_string()) + } else { + None + } + }); + candidates.retain(|n| { + if n.kind != NodeKind::Method { + return true; + } + let Some(class_prefix) = class_prefix.as_deref() else { + return false; + }; + let Some(m_sep) = n.qualified_name.rfind("::") else { + return false; + }; + if m_sep == 0 { + return false; + } + let method_prefix = &n.qualified_name[..m_sep]; + method_prefix == class_prefix + || method_prefix.ends_with(&format!("::{class_prefix}")) + || class_prefix.ends_with(&format!("::{method_prefix}")) + }); + if candidates.is_empty() { + return None; + } + } + + let same_file: Vec<&Node> = candidates + .iter() + .map(Arc::as_ref) + .filter(|n| n.file_path == reference.file_path) + .collect(); + if !same_file.is_empty() { + // Swift: several same-named methods in one file is an overload family + // and a bare id hitting it is almost always a parameter collision — + // refuse (name-matcher.ts:282-288). + if reference.language == Language::Swift + && same_file.len() > 1 + && same_file.iter().all(|n| n.kind == NodeKind::Method) + { + return None; + } + let target = same_file + .iter() + .copied() + .reduce(|a, b| if a.start_line <= b.start_line { a } else { b })?; + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: target.id.clone(), + confidence: if same_file.len() == 1 { 0.95 } else { 0.9 }, + resolved_by: ResolvedBy::FunctionRef, + }); + } + + // Cross-file: only an unambiguous match resolves, or, in Python, the one + // in reach of a name the file imports (netbox's `sender=CustomField` + // beside a test's own nested `CustomField`). + if candidates.len() == 1 + && (named.len() == 1 + || (reference.language == Language::Python + && crate::python_values::python_imports_name( + &reference.file_path, + &reference.reference_name, + context, + ))) + { + return Some(ResolvedRef { + original: reference.clone(), + target_node_id: candidates[0].id.clone(), + confidence: 0.8, + resolved_by: ResolvedBy::FunctionRef, + }); + } + None +} + +#[cfg(test)] +mod js_module_tests { + use std::sync::Arc; + + use super::{follows_control_keyword, read_module_exports}; + use crate::source_facts::SourceFacts; + + fn exports(source: &str) -> Option> { + read_module_exports(&SourceFacts::new(Arc::from(source))).map(|names| { + let mut names: Vec = names.into_iter().collect(); + names.sort(); + names + }) + } + + #[test] + fn an_es_modules_export_lists_name_its_exported_bindings() { + let source = "import { a } from './a.js';\nfunction one() {}\nfunction two() {}\nexport { one, two as second, type Shape };\nexport default three;\nexport default { four, five: six, 'seven': eight };\n"; + assert_eq!( + exports(source).unwrap(), + ["Shape", "eight", "four", "one", "six", "three", "two"] + ); + } + + #[test] + fn classic_scripts_commonjs_and_ambient_globals_are_no_es_modules() { + assert_eq!(exports("function legacy() {}\n"), None); + assert_eq!( + exports("import x from 'x';\nmodule.exports = { x };\n"), + None + ); + assert_eq!( + exports("export {};\ndeclare global { interface Window { a: 1 } }\n"), + None + ); + assert_eq!(exports("export const a = 1;\n"), Some(Vec::new())); + } + + #[test] + fn control_flow_heads_are_no_parameter_lists() { + assert!(follows_control_keyword(" if ")); + assert!(follows_control_keyword("x = 1; while")); + assert!(follows_control_keyword("for")); + assert!(!follows_control_keyword("elif")); + assert!(!follows_control_keyword("function handler")); + assert!(!follows_control_keyword("")); + } +} + +#[cfg(test)] +mod cpp_declared_type_tests { + use super::{cpp_declared_type_name, normalize_cpp_type_name}; + + #[test] + fn a_capture_naming_no_type_lets_the_declarator_scan_go_on() { + let is_cpp_non_type_capture = |capture: &str| normalize_cpp_type_name(capture).is_none(); + for capture in [ + "return", + "return *", + "throw", + "delete", + "sizeof", + "", + "&", + "leveldb::", + ] { + assert!(is_cpp_non_type_capture(capture), "{capture:?}"); + } + for capture in [ + "Table", + "const Table&", + "SymbolsByParentSet", + "leveldb::Table*", + "SkipList", + "auto", + ] { + assert!(!is_cpp_non_type_capture(capture), "{capture:?}"); + } + } + + #[test] + fn a_declared_cpp_type_is_read_as_upstream_reads_it() { + let read = |declared: &str| cpp_declared_type_name(declared); + assert_eq!( + read("SkipList").as_deref(), + Some("SkipList") + ); + assert_eq!(read("const ScopedHandle").as_deref(), Some("ScopedHandle")); + assert_eq!(read("Table>&").as_deref(), Some("Table")); + assert_eq!( + read("leveldb::Table::Iterator*").as_deref(), + Some("Iterator") + ); + assert_eq!(read("std::vector").as_deref(), Some("vector")); + assert_eq!( + read("typename T::value_type").as_deref(), + Some("value_type") + ); + // A type begun on an earlier line, an expression, or no type at all. + assert_eq!(read("Foo>>&"), None); + assert_eq!(read("BlobContents>>>&"), None); + assert_eq!(read("return"), None); + assert_eq!(read("sizeof"), None); + assert_eq!(read(""), None); + assert_eq!(read("&"), None); + } + + /// Upstream #2443: a receiver's declared type keeps no debris of nested + /// template arguments, and the end of a declaration begun on an earlier + /// line names no type; the namespaces it writes stay. + #[test] + fn a_receiver_type_drops_nested_template_arguments() { + let read = |raw: &str| super::normalize_cpp_type_name(raw); + assert_eq!( + read("autovector>").as_deref(), + Some("autovector") + ); + assert_eq!( + read("Striped>").as_deref(), + Some("Striped") + ); + assert_eq!(read("std::vector*").as_deref(), Some("std::vector")); + assert_eq!(read("port::Mutex&").as_deref(), Some("port::Mutex")); + assert_eq!(read("auto&").as_deref(), Some("auto")); + assert_eq!(read("std::unique_ptr>>&"), None); + assert_eq!(read("CachableEntry>>&"), None); + assert!(super::cpp_type_began_above("Blob>>>&")); + assert!(!super::cpp_type_began_above("a>")); + } + + /// Upstream #2413: the declaration scan reads code, not comments. + #[test] + fn a_line_as_code_blanks_its_comments_and_keeps_columns() { + let code = |line: &str| super::cpp_code_of(line); + assert_eq!(code(" // a non-null imm_"), None); + assert_eq!(code(" * imm_ is never null"), None); + assert_eq!( + code("/* static */ bool Status::Check(const std::string& msg) {").as_deref(), + Some(" bool Status::Check(const std::string& msg) {") + ); + assert_eq!( + code(" MemTable* imm_; // the one being compacted").as_deref(), + Some(" MemTable* imm_; ") + ); + assert_eq!(code(" int x; /* runs on").as_deref(), Some(" int x; ")); + } + + /// Upstream #2413: a range-`for` variable or a structured binding + /// re-declares the receiver in a form the declarator pattern doesn't read. + #[test] + fn a_range_for_variable_or_a_structured_binding_rebinds_the_receiver() { + let rebinding = super::CppRebinding::new("key").expect("patterns"); + assert!(rebinding.rebinds(" for (const Slice& key : slices) {")); + assert!(rebinding.rebinds(" for (auto& [key, value] : map) {")); + assert!(rebinding.rebinds(" auto [key, value] = Split(line);")); + assert!(rebinding.rebinds(" const auto& [first, key] = pair;")); + assert!(!rebinding.rebinds(" for (int i = 0; i < key::kMax; ++i) {")); + assert!(!rebinding.rebinds(" std::string key;")); + assert!(!rebinding.rebinds(" key.clear();")); + } +} + +#[cfg(test)] +mod self_veto_tests { + use super::*; + use crate::types::ImportMapping; + + /// A context whose only node is the reference's own. + struct OneNode(Node); + + impl ResolutionContext for OneNode { + fn get_nodes_in_file(&self, _file_path: &str) -> Vec { + vec![self.0.clone()] + } + fn get_nodes_by_name(&self, _name: &str) -> Vec { + vec![self.0.clone()] + } + fn get_nodes_by_qualified_name(&self, _qualified_name: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_kind(&self, _kind: NodeKind) -> Vec { + Vec::new() + } + fn file_exists(&self, _file_path: &str) -> bool { + false + } + fn read_file(&self, _file_path: &str) -> Option { + None + } + fn get_project_root(&self) -> &str { + "" + } + fn get_all_files(&self) -> Vec { + Vec::new() + } + fn get_nodes_by_lower_name(&self, _lower_name: &str) -> Vec { + Vec::new() + } + fn get_node_by_id(&self, id: &str) -> Option { + (id == self.0.id).then(|| self.0.clone()) + } + fn get_import_mappings(&self, _file_path: &str, _language: Language) -> Vec { + Vec::new() + } + } - // Swift implicit-self: a bare identifier names a METHOD only of the - // ENCLOSING type; a same-named method on another class is a parameter - // collision (name-matcher.ts:250-270). - if reference.language == Language::Swift - && candidates.iter().any(|n| n.kind == NodeKind::Method) - { - let class_prefix = context - .get_node_by_id_shared(&reference.from_node_id) - .and_then(|f| { - let sep = f.qualified_name.rfind("::")?; - if sep > 0 { - Some(f.qualified_name[..sep].to_string()) - } else { - None - } - }); - candidates.retain(|n| { - if n.kind != NodeKind::Method { - return true; - } - let Some(class_prefix) = class_prefix.as_deref() else { - return false; - }; - let Some(m_sep) = n.qualified_name.rfind("::") else { - return false; - }; - if m_sep == 0 { - return false; - } - let method_prefix = &n.qualified_name[..m_sep]; - method_prefix == class_prefix - || method_prefix.ends_with(&format!("::{class_prefix}")) - || class_prefix.ends_with(&format!("::{method_prefix}")) - }); - if candidates.is_empty() { - return None; + fn own_node(kind: NodeKind) -> Node { + Node { + id: format!("{}:00000000000000000000000000000001", kind.as_str()), + kind, + name: "response".to_string(), + qualified_name: "CurlTest::response".to_string(), + file_path: "src/Curl.scala".to_string(), + language: Language::Scala, + start_line: 2, + end_line: 4, + start_column: 0, + end_column: 0, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, } } - let same_file: Vec<&Node> = candidates - .iter() - .map(Arc::as_ref) - .filter(|n| n.file_path == reference.file_path) - .collect(); - if !same_file.is_empty() { - // Swift: several same-named methods in one file is an overload family - // and a bare id hitting it is almost always a parameter collision — - // refuse (name-matcher.ts:282-288). - if reference.language == Language::Swift - && same_file.len() > 1 - && same_file.iter().all(|n| n.kind == NodeKind::Method) - { - return None; - } - let target = same_file - .iter() - .copied() - .reduce(|a, b| if a.start_line <= b.start_line { a } else { b })?; - return Some(ResolvedRef { + fn self_result(node: &Node) -> (RefView, Option) { + let reference = RefView { + row_id: None, + from_node_id: node.id.clone(), + reference_name: "response".to_string(), + reference_kind: EdgeKind::Calls, + line: 4, + column: 4, + file_path: node.file_path.clone(), + language: Language::Scala, + is_function_ref: false, + reference_subkind: None, + }; + let result = ResolvedRef { original: reference.clone(), - target_node_id: target.id.clone(), - confidence: if same_file.len() == 1 { 0.95 } else { 0.9 }, - resolved_by: ResolvedBy::FunctionRef, - }); + target_node_id: node.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::ExactMatch, + }; + (reference, Some(result)) } - if candidates.len() == 1 { - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: candidates[0].id.clone(), - confidence: 0.8, - resolved_by: ResolvedBy::FunctionRef, - }); + /// Upstream #2223: sttp's `val response = basicRequest.get(…).response(…)` + /// is a request's `response`, never the value calling itself; a method's + /// call of its own name (recursion) keeps its edge. + #[test] + fn a_value_initializer_never_calls_the_value() { + for kind in [ + NodeKind::Variable, + NodeKind::Constant, + NodeKind::Field, + NodeKind::Property, + ] { + let node = own_node(kind); + let (reference, result) = self_result(&node); + assert_eq!( + veto_self_target(result, &reference, &OneNode(node.clone())), + None, + "{kind:?}" + ); + } + let method = own_node(NodeKind::Method); + let (reference, result) = self_result(&method); + assert!(veto_self_target(result, &reference, &OneNode(method.clone())).is_some()); } - None } #[cfg(test)] @@ -4609,6 +9317,58 @@ mod ceiling_tests { (0..count).map(node).collect() } + /// Upstream #2139: the fuzzy fallback's lowercase index matches across + /// case only in a case-insensitive language (PHP `formatprice` reaches + /// `FormatPrice`); a TypeScript `Mock()` never reaches `mock`. + #[test] + fn fuzzy_ignores_case_only_in_a_case_insensitive_language() { + let mut php = node(1); + php.name = "FormatPrice".to_string(); + php.qualified_name = "FormatPrice".to_string(); + php.language = Language::Php; + php.file_path = "src/helpers.php".to_string(); + let ctx = FakeContext { + by_name: Vec::new(), + by_lower: vec![php.clone()], + }; + let mut call = reference(); + call.reference_name = "formatprice".to_string(); + call.language = Language::Php; + call.file_path = "src/view.php".to_string(); + assert_eq!( + match_fuzzy(&call, &ctx).map(|m| m.target_node_id), + Some(php.id.clone()) + ); + + let mut ts = node(2); + ts.name = "mock".to_string(); + ts.qualified_name = "mock".to_string(); + let ctx = FakeContext { + by_name: Vec::new(), + by_lower: vec![ts], + }; + let mut call = reference(); + call.reference_name = "Mock".to_string(); + assert_eq!(match_fuzzy(&call, &ctx), None); + } + + /// Upstream #2141 (c): `new MockData()` never instantiates a method. + #[test] + fn a_fuzzy_instantiation_never_names_a_method() { + let mut method = node(3); + method.kind = NodeKind::Method; + method.name = "MockData".to_string(); + method.qualified_name = "Store::MockData".to_string(); + let ctx = FakeContext { + by_name: Vec::new(), + by_lower: vec![method], + }; + let mut new_call = reference(); + new_call.reference_name = "MockData".to_string(); + new_call.reference_kind = EdgeKind::Instantiates; + assert_eq!(match_fuzzy(&new_call, &ctx), None); + } + #[test] fn exact_match_declines_above_ceiling_but_resolves_at_it() { // Given a candidate set just over the default ceiling, the multi-candidate @@ -6303,8 +11063,8 @@ mod tests { let n = mk( "function:x", NodeKind::Function, - "Run", - "Run", + "run", + "run", "src/a.ts", Language::TypeScript, ); @@ -6321,8 +11081,8 @@ mod tests { let n = mk( "function:x", NodeKind::Function, - "Run", - "Run", + "run", + "run", "src/a.jsx", Language::Jsx, ); @@ -7126,8 +11886,8 @@ mod tests { let n = mk( "function:x", NodeKind::Function, - "Run", - "Run", + "run", + "run", "src/a.ts", Language::TypeScript, ); @@ -9431,41 +14191,590 @@ mod tests { } #[test] - fn local_receiver_type_patterns_empty_for_unpatterned_language() { - // C/ObjC have no shared patterns (C++ uses its own inferrer) → empty. - assert!(local_receiver_type_patterns(Language::C, "lg").is_empty()); - assert!(local_receiver_type_patterns(Language::ObjC, "lg").is_empty()); - assert!(!local_receiver_type_patterns(Language::TypeScript, "lg").is_empty()); + fn local_receiver_type_patterns_empty_for_unpatterned_language() { + // C has no shared patterns (C++ uses its own inferrer) → empty; + // Objective-C has its declaration and parameter forms (upstream + // #2150). + assert!(local_receiver_type_patterns(Language::C, "lg").is_empty()); + assert_eq!(local_receiver_type_patterns(Language::ObjC, "lg").len(), 2); + assert!(!local_receiver_type_patterns(Language::TypeScript, "lg").is_empty()); + } + + #[test] + fn infer_local_receiver_type_none_when_no_patterns() { + // C has no patterns → infer returns None regardless of source. + let ctx = Ctx::default().file("a.c", "int f() { Logger lg; lg.log(); }\n"); + let r = refv("lg.log", EdgeKind::Calls, "a.c", Language::C, 1); + assert!(infer_local_receiver_type("lg", &r, &ctx).is_none()); + } + + #[test] + fn infer_local_receiver_type_none_when_file_unreadable() { + // TS has patterns but the file can't be read → None. + let ctx = Ctx::default(); + let r = refv( + "lg.log", + EdgeKind::Calls, + "missing.ts", + Language::TypeScript, + 1, + ); + assert!(infer_local_receiver_type("lg", &r, &ctx).is_none()); + } + + #[test] + fn infer_local_receiver_type_none_when_no_declaration() { + // Readable source but no matching declaration for `lg` → None. + let ctx = Ctx::default().file("a.ts", "function f() {\n lg.log();\n}\n"); + let r = refv("lg.log", EdgeKind::Calls, "a.ts", Language::TypeScript, 2); + assert!(infer_local_receiver_type("lg", &r, &ctx).is_none()); + } + + #[test] + fn cfml_line_prefilter_admits_every_line_a_receiver_pattern_matches() { + let lines = [ + " var svc = new UserService();", + "", + " svc = createObject(\"component\", \"svc.UserService\");", + " svc=CreateObject('svc.OrderService')", + " function process(required UserService svc) {", + " function process(UserService svc, any other) {", + " UserService svc;", + " ", + " ", + " property name=\"svc\" inject=\"UserService\";", + " ", + " return svc.save(1);", + " svc.save(1);", + " n50.save(svc);", + " var other = svc;", + ]; + let patterns = local_receiver_type_patterns_tagged(Language::Cfml, "svc"); + let mut declaring = 0; + for line in lines { + let matched = patterns.iter().any(|(re, check)| { + re.captures(line).is_some_and(|caps| { + matches!((caps.get(0), caps.get(1)), (Some(whole), Some(m)) if check.passes(line, whole, m)) + }) + }); + if matched { + declaring += 1; + assert!(cfml_line_may_declare(line, "svc"), "{line}"); + } + } + assert_eq!(declaring, 11); + // A plain member call declares nothing, so no pattern is compiled + // for it. + assert!(!cfml_line_may_declare(" svc.save(1);", "svc")); + assert!(!cfml_line_may_declare(" n50.save(50);", "n5")); + assert!(cfml_line_may_declare(" aaa = new X();", "aa")); + } + + #[test] + fn php_line_prefilters_admit_every_line_a_pattern_matches() { + let lines = [ + " $repo = new UserRepository();", + " $repo=new \\App\\Repo;", + " repo = new Repo();", + " public function handle(UserRepository $repo) {", + " public function handle(Repo &$repo, $other) {", + " private readonly ?Repo $repo;", + " public function __construct(protected Repo $repo) {}", + " $this->repo = new Repo();", + " $this->repo = $repo;", + " $this->repo->save();", + " $repo->save();", + " if ($repo == new Repo()) {}", + " $repos = new Repo();", + ]; + let receiver = local_receiver_type_patterns_tagged(Language::Php, "repo"); + let property = php_property_type_patterns("repo"); + let assignment = Regex::new(r"\$this->repo\b\s*=\s*\$(\w+)\b").unwrap(); + let (mut by_receiver, mut by_property, mut by_assignment) = (0, 0, 0); + for line in lines { + if receiver.iter().any(|(re, _)| re.is_match(line)) { + by_receiver += 1; + assert!(line_may_declare(Language::Php, line, "repo"), "{line}"); + } + if property.iter().any(|re| re.is_match(line)) { + by_property += 1; + assert!( + writes_typed_variable(line, "repo") + || writes_assignment_of(line, "this->repo", "new"), + "{line}" + ); + } + if assignment.is_match(line) { + by_assignment += 1; + assert!(writes_assignment_of(line, "this->repo", "$"), "{line}"); + } + } + assert_eq!((by_receiver, by_property, by_assignment), (8, 3, 1)); + // A plain member call declares nothing, so no pattern is compiled + // for it. + assert!(!line_may_declare( + Language::Php, + " $repo->save();", + "repo" + )); + assert!(!writes_assignment_of( + " $this->repo->save();", + "this->repo", + "new" + )); + assert!(!writes_typed_variable( + " $this->repo->save();", + "repo" + )); + assert!(writes_assignment_of(" aaa = new X();", "aa", "new")); + } + + #[test] + fn dart_line_prefilter_admits_every_line_a_pattern_matches() { + let lines = [ + " final repo = Repo();", + " var repo=Repo.named();", + " Repo repo;", + " Repo repo = makeRepo();", + " void go(Repo repo, int n) {", + " void go(Repo repo) {", + " repo\u{a0}= Repo();", + " repo.save();", + " if (repo == Repo()) {}", + " final repos = Repo();", + " expect(repo, isNotNull);", + ]; + let patterns = local_receiver_type_patterns_tagged(Language::Dart, "repo"); + assert_eq!(patterns.len(), 2); + let mut matched = 0; + for line in lines { + if patterns.iter().any(|(re, _)| re.is_match(line)) { + matched += 1; + assert!(line_may_declare(Language::Dart, line, "repo"), "{line}"); + } + } + assert_eq!(matched, 7); + // A plain member call declares nothing, so no pattern is compiled + // for it. + assert!(!line_may_declare( + Language::Dart, + " repo.save();", + "repo" + )); + assert!(writes_before_terminator(" aaa = X();", "aa")); + } + + /// The receiver scan compiles its patterns only once a line could match + /// one (W4d end, the A/B's Newtonsoft and gson regressions): for every + /// language with patterns, a line a pattern matches is admitted, and a + /// plain member call is not. + #[test] + fn every_line_prefilter_admits_every_line_a_receiver_pattern_matches() { + let cases: &[(Language, &str, &[&str], usize)] = &[ + ( + Language::CSharp, + "writer", + &[ + " JsonTextWriter writer = new JsonTextWriter(sw);", + " var writer = new JsonTextWriter(sw);", + " List writer;", + " void Go(JsonWriter writer, int n) {", + " void Go(JsonWriter? writer) {", + " Dictionary> writer = Make();", + " foreach (JProperty writer in props) {", + " foreach (JProperty writer\tin props) {", + " writer.WriteEndObject();", + " if (writer == null) {}", + " writers = new X();", + ], + 8, + ), + ( + Language::Java, + "list", + &[ + " List list = new ArrayList<>();", + " ArrayList list;", + " String[] list = args;", + " void go(Map list, int n) {", + " for (final Item list : items) {", + " for (Item list: items) {", + " list = new ArrayList();", + " list.add(1);", + " lists = new ArrayList();", + ], + 7, + ), + ( + Language::TypeScript, + "svc", + &[ + " const svc = new UserService();", + " let svc: UserService;", + " function f(svc: Svc) {}", + " svc.save();", + ], + 3, + ), + ( + Language::Python, + "lg", + &[ + " lg = Logger()", + " lg: Logger = make()", + " lg: \"Logger\" = make()", + " lg = Group.objects.create(name=n)", + " lg.info(1)", + ], + 4, + ), + ( + Language::Kotlin, + "svc", + &[ + " val svc = Service()", + " fun go(svc: Service) {", + " svc.call()", + ], + 2, + ), + ( + Language::Swift, + "svc", + &[ + " let svc = Service()", + " var svc: Service", + " svc.call()", + ], + 2, + ), + ( + Language::Rust, + "conn", + &[ + " let mut conn = Connection::open();", + " let conn: &Pool = pool;", + " let conn: crate::db::Connection = Connection::open();", + " let conn:std::sync::Arc = Pool::new();", + " fn go(conn: &mut Connection) {", + " conn.execute();", + ], + 5, + ), + ( + Language::Go, + "s", + &[ + "\ts := &server{", + "\tvar s *Server", + "func (s *server) Run() {", + "\ts Server", + "\ts.Run()", + ], + 4, + ), + ( + Language::Ruby, + "svc", + &[" svc = Service.new", " svc.call"], + 1, + ), + ( + Language::Scala, + "svc", + &[ + " val svc = new Service()", + " def go(svc: Service) = 1", + " svc.call()", + ], + 2, + ), + ( + Language::Lua, + "svc", + &[ + "local svc = Service.new()", + "local svc = Service()", + "svc:call()", + ], + 2, + ), + ( + Language::R, + "svc", + &["svc <- Service$new()", "svc = Service$new()", "svc$call()"], + 2, + ), + ( + Language::Pascal, + "svc", + &[ + " svc: TService;", + " svc := TService.Create;", + " svc.Run;", + ], + 2, + ), + ]; + for (language, receiver, lines, expected) in cases { + let patterns = local_receiver_type_patterns_tagged(*language, ®ex_escape(receiver)); + assert!(!patterns.is_empty(), "{language:?}"); + let mut matched = 0; + for line in *lines { + if patterns.iter().any(|(re, _)| re.is_match(line)) { + matched += 1; + assert!( + line_may_declare(*language, line, receiver), + "{language:?}: {line}" + ); + } + } + assert_eq!(matched, *expected, "{language:?}"); + // The last line is a plain member call: nothing is compiled for it. + let call = lines.last().unwrap(); + if *language != Language::CSharp && *language != Language::Java { + assert!( + !line_may_declare(*language, call, receiver), + "{language:?}: {call}" + ); + } + } + assert!(!line_may_declare( + Language::CSharp, + " writer.WriteEndObject();", + "writer" + )); + assert!(!line_may_declare( + Language::Java, + " list.add(1);", + "list" + )); + } + + /// The JS-family arm admits only what its two patterns write, a `new` + /// assignment and an uppercase annotation (W4d end, typescript-large): + /// the other shapes the generic rule admits compile nothing here. + #[test] + fn js_receiver_prefilter_admits_new_assignments_and_annotations_only() { + for line in [ + " const svc = new UserService();", + " svc =new Svc()", + " let svc: UserService;", + " function f(svc : Svc) {}", + ] { + assert!( + line_may_declare(Language::TypeScript, line, "svc"), + "{line}" + ); + } + for line in [ + " use(svc, 1);", + " return svc;", + " if (svc) {", + " svc = other;", + " svc ()", + " const x = { svc: value };", + " svc.save();", + ] { + assert!( + !line_may_declare(Language::TypeScript, line, "svc"), + "{line}" + ); + assert!( + !line_may_declare(Language::JavaScript, line, "svc"), + "{line}" + ); + } } + /// [`js_declares_name`] finds what the declaration pattern found, and + /// [`may_open_js_parameters`] admits every `(` the parameter pattern + /// matches at (W4d end, typescript-large). #[test] - fn infer_local_receiver_type_none_when_no_patterns() { - // C has no patterns → infer returns None regardless of source. - let ctx = Ctx::default().file("a.c", "int f() { Logger lg; lg.log(); }\n"); - let r = refv("lg.log", EdgeKind::Calls, "a.c", Language::C, 1); - assert!(infer_local_receiver_type("lg", &r, &ctx).is_none()); + fn js_binding_readers_agree_with_the_patterns_they_replace() { + let codes = [ + "const x = 1;", + "let x\n= 2", + "var x, y", + "const { x } = f()", + "const [x] = g()", + "constx = 1", + "myconst x = 1", + "_let x = 1", + "const xy = 1", + "const x$ = 1", + "const $x = 1", + "let x$y = 1", + "var a$var a$var", + "var a$var, a$var", + "const\tx\u{85}", + "const é x", + "éconst x", + "let\u{a0}x = 3", + " return x;", + "function f(x) { return x; }", + "const f = (a, x: number) => a;", + "const f = (x = 1, ...rest) => x", + "f(x);", + "if (x) {", + "(x): Foo => x", + "(a: Map, x) => 1", + "(\n x,\n y\n) {", + "( x ) =>", + "(a = (x), b) => 1", + ]; + for name in ["x", "x$", "$x", "a$var", "xy"] { + let n = regex::escape(name); + let declared = + Regex::new(&format!(r"(?-u:\b)(?:const|let|var)\s+{n}(?-u:\b)")).unwrap(); + let parameter = js_parameter_pattern(name).unwrap(); + for code in codes { + let expected = declared.find_iter(code).any(|found| { + !code[found.end()..] + .trim_start() + .starts_with([',', ']', '}']) + }); + assert_eq!( + js_declares_name(code, name), + expected, + "{name:?} in {code:?}" + ); + for (at, _) in code.match_indices('(') { + if parameter.is_match(&code[at..]) { + assert!( + may_open_js_parameters(code, at, name), + "{name:?} at {at} in {code:?}" + ); + } + } + } + } + // A call's parentheses, or ones another `(` interrupts, open no list. + assert!(!may_open_js_parameters("f(x);", 1, "x")); + assert!(!may_open_js_parameters("g(f(x)) => 1", 1, "x")); + assert!(may_open_js_parameters("(x): Foo => x", 0, "x")); } + /// [`writes_word`] reads `\b{word}\b` as the `regex` crate does, Unicode + /// word characters on either side included, without compiling it. #[test] - fn infer_local_receiver_type_none_when_file_unreadable() { - // TS has patterns but the file can't be read → None. - let ctx = Ctx::default(); - let r = refv( - "lg.log", - EdgeKind::Calls, - "missing.ts", - Language::TypeScript, - 1, - ); - assert!(infer_local_receiver_type("lg", &r, &ctx).is_none()); + fn writes_word_reads_a_word_as_the_regex_crate_does() { + let texts = [ + "db->Get(k);", + " auto db = Open();", + "dbs.size()", + "my_db.size()", + "éa.foo", + "a.fooé", + "x(a.foo)", + "a.foo", + "día = 1;", + "el día.x()", + "_día", + "a.b.c", + "", + " ", + "αβ γ", + ]; + for word in ["db", "a.foo", "día", "a.b", "foo", "γ", ""] { + let pattern = Regex::new(&format!(r"\b{}\b", regex_escape(word))).unwrap(); + for text in texts { + assert_eq!( + writes_word(text, word), + pattern.is_match(text), + "{word:?} in {text:?}" + ); + } + } } + /// The C++ receiver scan compiles its declarator and rebinding patterns + /// only once a line could match them (W4d end, leveldb): every line + /// either pattern matches is admitted, and the call lines are not. #[test] - fn infer_local_receiver_type_none_when_no_declaration() { - // Readable source but no matching declaration for `lg` → None. - let ctx = Ctx::default().file("a.ts", "function f() {\n lg.log();\n}\n"); - let r = refv("lg.log", EdgeKind::Calls, "a.ts", Language::TypeScript, 2); - assert!(infer_local_receiver_type("lg", &r, &ctx).is_none()); + fn cpp_receiver_prefilters_admit_every_line_their_patterns_match() { + let receiver = "db"; + let escaped = regex_escape(receiver); + let declarator = build_declarator_regex(&escaped); + let rebinding = CppRebinding::new(&escaped).unwrap(); + let calls = [ + " db->Get(k);", + " if (db != nullptr) {", + " x = db.size();", + " Use(db, 1);", + ]; + let lines = [ + " DB* db = nullptr;", + " DB *db;", + " std::unique_ptr db(Open());", + " const Options& db = opts;", + " ns::Db db;", + " auto db = DB::Open(o);", + "Status Open(const Options& o, DB** dbptr, DB* db) {", + " for (const auto& db : dbs) {", + " for (DB* db: all) {", + " auto& [db, ok] = Open();", + " auto [ok, db] = pair;", + " Foo::db", + " return db;", + " Größe db;", + ]; + let (mut declared, mut rebound) = (0, 0); + for line in lines.iter().chain(&calls) { + if declarator.is_match(line) { + declared += 1; + assert!(may_declare_cpp_receiver(line, receiver), "{line}"); + } + if rebinding.rebinds(line) { + rebound += 1; + assert!(may_rebind_cpp_receiver(line, receiver), "{line}"); + } + } + assert_eq!((declared, rebound), (12, 4)); + // On an ASCII line the ASCII-spelled pattern captures what the Unicode + // one captures. + let ascii = build_ascii_declarator_regex(&escaped); + let templates = [ + " std::map> db = make();", + " Foo db;", + " Foo db;", + " Foo db;", + " Foo db;", + " const char* const db = \"x\";", + "\tDB\t*\tdb;", + " Foo<\x01~|\x7F> db;", + " Foo\x0B\x0C&db", + " a::b:: db", + ]; + for line in lines + .iter() + .chain(&calls) + .chain(&templates) + .filter(|line| line.is_ascii()) + { + let capture = |re: &Regex| { + re.captures(line) + .map(|caps| (caps.get(0).unwrap().range(), caps[1].to_string())) + }; + assert_eq!(capture(&ascii), capture(&declarator), "{line}"); + } + for call in calls { + assert!(!may_declare_cpp_receiver(call, receiver), "{call}"); + assert!(!may_rebind_cpp_receiver(call, receiver), "{call}"); + } + } + + /// [`has_receiver_patterns`] names exactly the languages + /// [`local_receiver_type_patterns_tagged`] has patterns for. + #[test] + fn receiver_patterns_cover_every_language_with_patterns() { + for language in Language::ALL { + assert_eq!( + has_receiver_patterns(language), + !local_receiver_type_patterns_tagged(language, "x").is_empty(), + "{language:?}" + ); + } } #[test] @@ -10468,6 +15777,40 @@ mod tests { reference } + #[test] + fn no_receiver_on_line_reads_the_name_or_its_first_call() { + let source = " AssertValid();\n this.Check();\n Easing.Linear\n .Select(x => x)\n var t = TestContext.Current;\n Foo.\tCall(token);\n x = $call(1); Call(2);\n"; + let ctx = Ctx::default().file("A.cs", source); + let none = |name: &str, line: i64, column: i64| { + has_no_receiver_on_line( + &at_column( + name, + EdgeKind::Calls, + "A.cs", + Language::CSharp, + line, + column, + ), + &ctx, + ) + }; + assert!(none("AssertValid", 1, 8)); + assert!(!none("Check", 2, 13), "`this.` is a receiver"); + assert!(none("Easing", 3, 8)); + assert!(none("easing", 3, 8), "read case-insensitively"); + assert!(!none("Select", 4, 0), "the name is on the line after a `.`"); + assert!(none("TestContext", 5, 27), "a column at the name's end"); + assert!( + !none("Call", 6, 0), + "the first `Call(` follows a `.` and blanks" + ); + assert!(none("Call", 7, 0), "`$call(` is no call of `Call`"); + assert!( + !none("Missing", 1, 0), + "a name off its line is a chain link" + ); + } + #[test] fn receiver_less_call_reads_the_call_site_line() { let source = "serialize(x);\nthis.serialize(x);\n return helper();\nobj.helper();\nx = helper ();\nfinish\n ();\nnew Foo();\nxreturn helper();\n"; @@ -10612,7 +15955,9 @@ mod tests { #[test] fn rust_receiver_gates_narrow_exact_name_candidates() { - let source = "fn f() {\n root().join(x);\n store().nodes_by_ids(x);\n helper();\n root().build();\n Err(1);\n}\n"; + // The trailing `use` brings `util.rs`'s `helper` into scope: another + // file's function is reached only through one (upstream #2214). + let source = "fn f() {\n root().join(x);\n store().nodes_by_ids(x);\n helper();\n root().build();\n Err(1);\n}\nuse crate::util::helper;\n"; let method = |name: &str, qualified: &str| { mk( &format!("method:{name}"), @@ -10671,6 +16016,12 @@ mod tests { .file("src/a.rs", source) .name("helper", vec![method("helper", "Store::helper")]); assert_eq!(target(&at("helper", 4), &ctx), None); + // Without that `use`, `util.rs`'s `helper` is out of scope (#2214). + let unimported = source.replace("use crate::util::helper;\n", ""); + let ctx = Ctx::default() + .file("src/a.rs", &unimported) + .name("helper", vec![function("helper")]); + assert_eq!(target(&at("helper", 4), &ctx), None); // R4: a bare `Err` is the prelude's unless a `use` in scope imports one. let variant = mk( "enum_member:Err", @@ -10699,9 +16050,11 @@ mod tests { #[test] fn rust_receiver_gates_close_the_fuzzy_fallback() { - // Fuzzy matching is case-insensitive, so `Join` / `Helper` are its only - // candidates for `join` / `helper`; the shapes still gate them. - let source = "fn f() {\n root().join(x);\n helper(x);\n}\n"; + // Only the lowercase index holds `join` / `helper`, so the fuzzy + // fallback is the one strategy that finds them; the shapes still gate + // them. (Fuzzy matching is exact-case in Rust, upstream #2139.) The + // trailing `use` brings `x.rs`'s `helper` into scope (upstream #2214). + let source = "fn f() {\n root().join(x);\n helper(x);\n}\nuse crate::x::helper;\n"; let lone = |name: &str, kind: NodeKind| { vec![mk( &format!("{kind:?}:{name}"), @@ -10720,25 +16073,109 @@ mod tests { }; let ctx = Ctx::default() .file("src/a.rs", source) - .lower("join", lone("Join", NodeKind::Method)); + .lower("join", lone("join", NodeKind::Method)); assert!( fuzzy("join", 2, &ctx).is_none(), "a dropped receiver takes no fuzzy guess" ); let ctx = Ctx::default() .file("src/a.rs", source) - .lower("helper", lone("Helper", NodeKind::Method)); + .lower("helper", lone("helper", NodeKind::Method)); assert!( fuzzy("helper", 3, &ctx).is_none(), "a bare call never reaches a method" ); let ctx = Ctx::default() .file("src/a.rs", source) - .lower("helper", lone("Helper", NodeKind::Function)); + .lower("helper", lone("helper", NodeKind::Function)); assert!( fuzzy("helper", 3, &ctx).is_some(), "a bare call may still reach a function" ); + let ctx = Ctx::default() + .file("src/a.rs", &source.replace("use crate::x::helper;\n", "")) + .lower("helper", lone("helper", NodeKind::Function)); + assert!( + fuzzy("helper", 3, &ctx).is_none(), + "another file's function only through a `use`" + ); + } + + #[test] + fn rust_scope_gate_declines_any_strategys_out_of_scope_target() { + // Upstream #2214 `gateRustScope`: whichever strategy chose another + // file's item for a bare Rust name, it stands only when in scope. + let helper = mk( + "function:helper", + NodeKind::Function, + "helper", + "helper", + "src/util.rs", + Language::Rust, + ); + let chosen = |reference: &RefView| ResolvedRef { + original: reference.clone(), + target_node_id: "function:helper".to_string(), + confidence: 0.9, + resolved_by: ResolvedBy::QualifiedName, + }; + let gate = |source: &str, reference: &RefView| { + let ctx = Ctx::default() + .file(&reference.file_path, source) + .node_by_id(helper.clone()); + crate::rust_scope::gate_rust_scope(Some(chosen(reference)), reference, &ctx) + .map(|r| r.target_node_id) + }; + let source = "fn f() {\n helper();\n}\n"; + let bare = at_column("helper", EdgeKind::Calls, "src/a.rs", Language::Rust, 2, 4); + assert_eq!(gate(source, &bare), None, "no `use` brings it in"); + let imported = format!("{source}use crate::util::helper;\n"); + assert_eq!( + gate(&imported, &bare).as_deref(), + Some("function:helper"), + "a `use` binds it" + ); + let globbed = format!("{source}use crate::util::*;\n"); + assert_eq!( + gate(&globbed, &bare).as_deref(), + Some("function:helper"), + "a glob over its module brings it in" + ); + // Only bare Rust names are judged. + let dotted = at_column( + "x.helper", + EdgeKind::Calls, + "src/a.rs", + Language::Rust, + 2, + 4, + ); + assert_eq!(gate(source, &dotted).as_deref(), Some("function:helper")); + let other = at_column( + "helper", + EdgeKind::Calls, + "src/a.ts", + Language::TypeScript, + 2, + 4, + ); + assert_eq!(gate(source, &other).as_deref(), Some("function:helper")); + } + + #[test] + fn a_rust_outside_type_or_module_path_receiver_is_read_by_its_case() { + // Upstream #2177: a CamelCase receiver is a type, `Self` the impl's + // own and a SCREAMING_CASE one a static; a lowercase path a module. + assert!(names_external_type("Vec", Language::Rust)); + assert!(names_external_type("String", Language::Rust)); + assert!(!names_external_type("Self", Language::Rust)); + assert!(!names_external_type("REQ_ID", Language::Rust)); + assert!(!names_external_type("task", Language::Rust)); + assert!(is_rust_module_path("task")); + assert!(is_rust_module_path("std::io")); + assert!(is_rust_module_path("_private::fooBar")); + assert!(!is_rust_module_path("io::Stdout")); + assert!(!is_rust_module_path("Vec")); } #[test] @@ -10943,6 +16380,376 @@ mod tests { ); } + /// W5-08: the per-file index finds the container the qualified-name + /// lookup finds, the first in that lookup's order (start line, start + /// column, id) of the same file's functions and methods of the name + /// whose lines hold the declaration: same-named functions that both + /// hold it, an overload signature that holds nothing, a method, a + /// class (no scope) and another file's namesake. + #[test] + fn function_scopes_find_the_container_the_qualified_name_lookup_finds() { + let at = |id: &str, kind, qualified: &str, file: &str, lines: (i64, i64), column| { + let name = qualified.rsplit("::").next().unwrap_or(qualified); + let mut node = mk(id, kind, name, qualified, file, Language::TypeScript); + (node.start_line, node.end_line) = lines; + node.start_column = column; + Arc::new(node) + }; + let nodes = vec![ + at("f-sig", NodeKind::Function, "f", "a.ts", (1, 1), 0), + at("f-b", NodeKind::Function, "f", "a.ts", (3, 20), 4), + at("f-a", NodeKind::Function, "f", "a.ts", (3, 20), 0), + at("f-outer", NodeKind::Function, "f", "a.ts", (2, 30), 0), + at("m", NodeKind::Method, "C::m", "a.ts", (5, 9), 2), + at("c", NodeKind::Class, "C", "a.ts", (4, 10), 0), + at("f-other", NodeKind::Function, "f", "b.ts", (1, 100), 0), + ]; + let in_file: Vec> = nodes + .iter() + .filter(|node| node.file_path == "a.ts") + .cloned() + .collect(); + let scopes = FileCallables::of(&in_file); + let mut ordered = nodes.clone(); + crate::context::order_candidates(&mut ordered); + for (qualified, start, end) in [ + ("f", 6, 7), + ("f", 3, 20), + ("f", 25, 26), + ("f", 1, 1), + ("f", 31, 31), + ("C::m", 6, 6), + ("C", 6, 6), + ("g", 1, 1), + ] { + let lookup = ordered + .iter() + .find(|p| { + p.file_path == "a.ts" + && matches!(p.kind, NodeKind::Function | NodeKind::Method) + && p.qualified_name == qualified + && p.start_line <= start + && p.end_line >= end + }) + .map(|p| (p.start_line, p.end_line)); + assert_eq!( + scopes.first_enclosing(qualified, start, end), + lookup, + "{qualified} {start}..={end}" + ); + } + assert_eq!(scopes.first_enclosing("f", 6, 7), Some((2, 30))); + } + + /// The walk of upstream #2180 as it was written before W5-08, the + /// reference the sequence must reproduce. + fn reference_inherited_class_method( + classes: &[Arc], + name: &str, + context: &dyn ResolutionContext, + ) -> Option> { + let mut seen: std::collections::HashSet = + classes.iter().map(|class| class.id.clone()).collect(); + let mut frontier: Vec> = classes.to_vec(); + for _ in 0..5 { + if frontier.is_empty() { + break; + } + let mut next = Vec::new(); + for class in &frontier { + for supertype in crate::class_heads::declared_supertypes(class, context) { + for decl in context.get_nodes_by_name_shared(&supertype) { + if decl.language != class.language + || !is_method_owner(&decl) + || !seen.insert(decl.id.clone()) + { + continue; + } + if let Some(method) = context + .get_nodes_in_file_shared(&decl.file_path) + .into_iter() + .find(|n| { + n.kind == NodeKind::Method + && n.name == name + && member_owner_name(&n.qualified_name) == decl.name + }) + { + return Some(method); + } + next.push(decl); + } + } + } + frontier = next; + } + None + } + + /// W5-08: the supertype sequence, read once per class name, finds for + /// every method name what the walk found: the nearest level first, a + /// level's declarations in the walk's order, each declaration once + /// (a diamond), a method of another owner in the same file skipped, + /// and nothing past the fifth level. + #[test] + fn the_supertype_sequence_finds_what_the_inherited_walk_found() { + let class = |id: &str, name: &str, file: &str| { + let mut node = mk(id, NodeKind::Class, name, name, file, Language::Python); + node.start_line = 1; + node.end_line = 4; + node + }; + let method = |id: &str, owner: &str, name: &str, file: &str| { + let mut node = mk( + id, + NodeKind::Method, + name, + &format!("{owner}::{name}"), + file, + Language::Python, + ); + node.start_line = 2; + node.end_line = 3; + node + }; + let heads = [ + ("a1.py", "A", "class A(B):\n pass\n"), + ("a2.py", "A", "class A(D):\n pass\n"), + ("b.py", "B", "class B(C):\n def other(self): pass\n"), + ("c.py", "C", "class C(E1):\n def m(self): pass\n"), + ("d.py", "D", "class D(C):\n def n(self): pass\n"), + ("e1.py", "E1", "class E1(E2):\n pass\n"), + ("e2.py", "E2", "class E2(E3):\n pass\n"), + ("e3.py", "E3", "class E3(E4):\n def deep(self): pass\n"), + ("e4.py", "E4", "class E4:\n def deeper(self): pass\n"), + ]; + let mut ctx = Ctx::default(); + let mut by_name: HashMap> = HashMap::new(); + for (file, name, text) in heads { + ctx = ctx.file(file, text); + by_name.entry(name.to_string()).or_default().push(class( + &format!("class:{file}"), + name, + file, + )); + } + let methods = [ + ("b.py", method("method:b-other", "B", "other", "b.py")), + ("b.py", method("method:b-both", "B", "both", "b.py")), + ("d.py", method("method:d-both", "D", "both", "d.py")), + ("c.py", method("method:c-m", "C", "m", "c.py")), + ("d.py", method("method:d-n", "D", "n", "d.py")), + ("d.py", method("method:d-x", "Other", "x", "d.py")), + ("e3.py", method("method:e3-deep", "E3", "deep", "e3.py")), + ("e4.py", method("method:e4-deeper", "E4", "deeper", "e4.py")), + ]; + for (file, _, _) in heads { + let mut in_file: Vec = by_name + .values() + .flatten() + .filter(|node| node.file_path == file) + .cloned() + .collect(); + in_file.extend( + methods + .iter() + .filter(|(f, _)| *f == file) + .map(|(_, node)| node.clone()), + ); + ctx = ctx.nodes_in_file(file, in_file); + } + // The methods by name too, as a project's lookup lists them. + for (_, node) in &methods { + by_name + .entry(node.name.clone()) + .or_default() + .push(node.clone()); + } + for (name, nodes) in &by_name { + ctx = ctx.name(name, nodes.clone()); + } + let classes: Vec> = by_name["A"].iter().cloned().map(Arc::new).collect(); + // One walk serves every name, whichever order the searches come in: + // a near method first, then far ones and back. + let sequence = Mutex::new(SupertypeWalk::new(classes.clone())); + for name in [ + "n", "deep", "m", "other", "both", "x", "deeper", "missing", "n", + ] { + assert_eq!( + inherited_class_method(&sequence, name, &ctx).map(|n| n.id.clone()), + reference_inherited_class_method(&classes, name, &ctx).map(|n| n.id.clone()), + "method {name}" + ); + } + // And a fresh walk per name agrees too. + for name in ["m", "both", "deep", "deeper"] { + let fresh = Mutex::new(SupertypeWalk::new(classes.clone())); + assert_eq!( + inherited_class_method(&fresh, name, &ctx).map(|n| n.id.clone()), + reference_inherited_class_method(&classes, name, &ctx).map(|n| n.id.clone()), + "fresh walk, method {name}" + ); + } + // B and D, then C (once), E1, E2 and E3: the fifth level is the + // last one read. + assert_eq!( + inherited_class_method(&sequence, "deep", &ctx).map(|n| n.id.clone()), + Some("method:e3-deep".to_string()), + "five levels up" + ); + assert!( + inherited_class_method(&sequence, "deeper", &ctx).is_none(), + "not six" + ); + assert_eq!( + inherited_class_method(&sequence, "m", &ctx).map(|n| n.id.clone()), + Some("method:c-m".to_string()) + ); + // A level's declarations in the walk's order: B before D. + assert_eq!( + inherited_class_method(&sequence, "both", &ctx).map(|n| n.id.clone()), + Some("method:b-both".to_string()) + ); + } + + /// The local scope as computed before W5-08, the reference. + fn reference_local_scope(candidate: &Node, nodes: &[Node]) -> Option<(i64, i64)> { + let mut scope: Option<(i64, i64)> = None; + for n in nodes { + if !matches!(n.kind, NodeKind::Function | NodeKind::Method) || n.id == candidate.id { + continue; + } + if n.start_line > candidate.start_line + || n.end_line < candidate.start_line + || n.start_line == n.end_line + { + continue; + } + if n.start_line == candidate.start_line && n.start_column >= candidate.start_column { + continue; + } + if scope.is_none_or(|(start, end)| n.end_line - n.start_line < end - start) { + scope = Some((n.start_line, n.end_line)); + } + } + scope + } + + /// W5-08: a declaration's local scope, read from the per-file index and + /// kept for the pass, is the innermost function holding its start as + /// before: a same-line function that starts at or after it, a one-line + /// function and the declaration itself skipped, the first of equal spans + /// kept, whichever lines each holds. + #[test] + fn a_local_scope_is_the_innermost_function_as_before() { + let at = |id: &str, kind, name: &str, lines: (i64, i64), column| { + let mut node = mk(id, kind, name, name, "f.py", Language::Python); + (node.start_line, node.end_line) = lines; + node.start_column = column; + node + }; + let nodes = vec![ + at("function:outer", NodeKind::Function, "outer", (1, 20), 0), + at("function:twin-a", NodeKind::Function, "twin_a", (3, 10), 4), + at("function:twin-b", NodeKind::Function, "twin_b", (3, 10), 8), + at("function:twin-c", NodeKind::Function, "twin_c", (4, 11), 4), + at("function:one-line", NodeKind::Function, "one", (5, 5), 4), + at("function:self", NodeKind::Function, "itself", (12, 14), 4), + at("method:late", NodeKind::Method, "late", (16, 18), 12), + ]; + let candidates = vec![ + at("variable:in-twins", NodeKind::Variable, "x", (6, 6), 8), + at("variable:twin-line", NodeKind::Variable, "y", (3, 3), 6), + at("variable:one-line", NodeKind::Variable, "z", (5, 5), 10), + at("variable:outer", NodeKind::Variable, "w", (15, 15), 4), + at("variable:late-line", NodeKind::Variable, "v", (16, 16), 4), + at("variable:outside", NodeKind::Variable, "u", (25, 25), 0), + // The same name elsewhere: the pass keeps a scope per id. + at("variable:x-again", NodeKind::Variable, "x", (15, 15), 8), + nodes[5].clone(), + ]; + let mut in_file = nodes.clone(); + in_file.extend(candidates.iter().cloned()); + let ctx = Ctx::default() + .file("f.py", "\n".repeat(30).as_str()) + .nodes_in_file("f.py", in_file.clone()); + for candidate in &candidates { + let expected = reference_local_scope(candidate, &in_file); + assert_eq!( + local_declaration_scope(candidate, &ctx), + expected, + "{}", + candidate.id + ); + } + assert_eq!( + local_declaration_scope(&candidates[0], &ctx), + Some((3, 10)), + "the first of equal spans, not the later (4, 11)" + ); + assert_eq!( + local_declaration_scope(&candidates[6], &ctx), + Some((1, 20)), + "a namesake has its own scope" + ); + } + + /// W5-08: the inherited-method memo is keyed by the reference's + /// language as well as the two names: a same-named class of another + /// language computes its own answer, the same key reuses it. + #[test] + fn the_inherited_method_memo_keeps_languages_apart() { + let tables = NameMatcherTables::new(); + let python = Arc::new(mk( + "method:py", + NodeKind::Method, + "create", + "Base::create", + "base.py", + Language::Python, + )); + let ruby = Arc::new(mk( + "method:rb", + NodeKind::Method, + "create", + "Base::create", + "base.rb", + Language::Ruby, + )); + let computed = std::cell::Cell::new(0); + let python_hit = tables.inherited_method(Language::Python, "User", "create", || { + computed.set(computed.get() + 1); + Some(Arc::clone(&python)) + }); + let ruby_hit = tables.inherited_method(Language::Ruby, "User", "create", || { + computed.set(computed.get() + 1); + Some(Arc::clone(&ruby)) + }); + let python_again = tables.inherited_method(Language::Python, "User", "create", || { + computed.set(computed.get() + 1); + None + }); + assert_eq!( + python_hit.map(|n| n.id.clone()).as_deref(), + Some("method:py") + ); + assert_eq!(ruby_hit.map(|n| n.id.clone()).as_deref(), Some("method:rb")); + assert_eq!( + python_again.map(|n| n.id.clone()).as_deref(), + Some("method:py") + ); + assert_eq!(computed.get(), 2, "the same key is computed once"); + let none = tables.inherited_method(Language::Python, "User", "save", || None); + assert!(none.is_none()); + assert!( + tables + .inherited_method(Language::Python, "User", "save", || Some(Arc::clone( + &python + ))) + .is_none(), + "a remembered miss stays a miss" + ); + } + #[test] fn fuzzy_declines_a_closure_but_c_nesting_stays_reachable() { let mut inner = mk( @@ -11030,4 +16837,77 @@ mod tests { .name("run", vec![method]); assert!(match_by_exact_name(&bare, &only_method).is_none()); } + + // ================= import statements are never name-match targets ======== + + /// Upstream #965 (`0a91d0f5`): an `import` node is a statement, not a + /// definition. Neither another file's import of the same module nor the + /// file's own is a candidate, so the reference stays unresolved. + #[test] + fn exact_match_never_offers_an_import_statement() { + let theirs = mk( + "import:widget-vue", + NodeKind::Import, + "vue", + "vue", + "src/Widget.svelte", + Language::Svelte, + ); + let ours = mk( + "import:app-vue", + NodeKind::Import, + "vue", + "vue", + "src/App.vue", + Language::Vue, + ); + let ctx = Ctx::default().name("vue", vec![theirs, ours]); + let reference = refv("vue", EdgeKind::Imports, "src/App.vue", Language::Vue, 6); + assert_eq!(match_by_exact_name(&reference, &ctx), None); + } + + /// An import statement named like a definition does not make the + /// definition look ambiguous: the lone definition takes the single-match + /// confidence, not a proximity-ranked one. + #[test] + fn an_import_statement_never_makes_a_definition_ambiguous() { + let definition = mk( + "method:json", + NodeKind::Method, + "json", + "Response::json", + "lib/response.rb", + Language::Ruby, + ); + let statement = mk( + "import:json", + NodeKind::Import, + "json", + "json", + "app/main.rb", + Language::Ruby, + ); + let ctx = Ctx::default().name("json", vec![statement, definition]); + let reference = refv("json", EdgeKind::Calls, "app/main.rb", Language::Ruby, 4); + let resolved = match_by_exact_name(&reference, &ctx).expect("the definition resolves"); + assert_eq!(resolved.target_node_id, "method:json"); + assert_eq!(resolved.confidence, 0.9); + } + + /// The fuzzy fallback considers callables only, so an import statement is + /// never its guess either. + #[test] + fn fuzzy_match_never_offers_an_import_statement() { + let statement = mk( + "import:vue", + NodeKind::Import, + "Vue", + "Vue", + "src/Widget.svelte", + Language::Svelte, + ); + let ctx = Ctx::default().lower("vue", vec![statement]); + let reference = refv("vue", EdgeKind::Calls, "src/App.vue", Language::Vue, 6); + assert_eq!(match_fuzzy(&reference, &ctx), None); + } } diff --git a/crates/codegraph-resolve/src/objc_scope.rs b/crates/codegraph-resolve/src/objc_scope.rs new file mode 100644 index 00000000..d6843722 --- /dev/null +++ b/crates/codegraph-resolve/src/objc_scope.rs @@ -0,0 +1,552 @@ +//! Objective-C messages reached by name alone (upstream #2150, #2179). +//! +//! A message to `self` or `super`, whose receiver the extractor drops, +//! reaches a member of the sender's hierarchy, read from the `@interface +//! Name : Super` heads of the classes it passes and from UIKit / AppKit +//! ancestry for a category on a system class; a message to `super` goes past +//! the class it is written in. An untyped receiver keeps a unique method +//! name's match when it names a class, or ends in a property whose declared +//! type, has the method's owner in its hierarchy. +//! +//! What these read of other files is in each Objective-C file's head row +//! ([`HeadRow`]), which a sync compares to keep unchanged files current. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::types::{Language, Node, NodeKind}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::source_facts::SourceFacts; +use crate::types::{RefView, ResolutionContext}; + +/// The memo of one resolution pass (upstream `OBJC_SUPERS`). +#[derive(Debug, Default)] +pub struct ObjcTables { + supers: Mutex>>>, +} + +impl ObjcTables { + pub fn new() -> Self { + Self::default() + } +} + +/// Kinds a member of an Objective-C class is (upstream `OBJC_MEMBER_KINDS`). +pub(crate) fn is_objc_member_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Method | NodeKind::Property | NodeKind::Field + ) +} + +/// The superclass an `@interface Name : Super` line names (upstream +/// `/@interface\s+\w+\s*:\s*(\w+)/`). +pub fn written_superclass(line: &str) -> Option { + static INTERFACE: OnceLock = OnceLock::new(); + INTERFACE + .get_or_init(|| Regex::new(r"@interface\s+\w+\s*:\s*(\w+)").expect("objc interface")) + .captures(line) + .map(|c| c[1].to_string()) +} + +/// The class an `@property … Type *name` line declares `name` as (upstream +/// `objcPropertyTypes`' pattern). One pattern reads every declaration, the +/// name it captures checked against `name`, so no pattern is compiled per +/// name. +pub fn written_property_type(line: &str, name: &str) -> Option { + static PROPERTY: OnceLock = OnceLock::new(); + PROPERTY + .get_or_init(|| { + Regex::new( + r"@property\s*(?:\([^)]*\)\s*)?([A-Z]\w*)\s*(?:<[^;]*>\s*)?\*\s*(?:_Nullable\s+|_Nonnull\s+)?(\w+)", + ) + .expect("objc property") + }) + .captures_iter(line) + .find(|c| &c[2] == name) + .map(|c| c[1].to_string()) +} + +/// A UIKit / AppKit class's superclass (upstream `OBJC_SYSTEM_SUPERS`), the +/// ancestry of a category on a system class. +pub fn system_superclass(name: &str) -> Option<&'static str> { + crate::swift_scope::objc_system_super(name) +} + +/// The line `node` starts on, as [`str::lines`] yields it. +fn first_line<'f>(facts: &'f SourceFacts, node: &Node) -> Option<&'f str> { + let index = usize::try_from(node.start_line.checked_sub(1)?).ok()?; + (index < facts.line_count()).then(|| facts.line(index)) +} + +/// The superclass the `@interface` on `decl`'s first line writes, the part +/// one Objective-C class declaration adds to [`objc_supertypes_of`]. +pub(crate) fn written_superclass_of(facts: &SourceFacts, decl: &Node) -> Option { + first_line(facts, decl).and_then(written_superclass) +} + +/// The superclasses an Objective-C class's `@interface` declarations name, +/// or its UIKit / AppKit superclass when none does (upstream +/// `objcSupertypesOf`). +pub(crate) fn objc_supertypes_of(name: &str, context: &dyn ResolutionContext) -> Arc> { + let tables = context.objc_tables(); + if let Some(hit) = tables + .supers + .lock() + .ok() + .and_then(|memo| memo.get(name).cloned()) + { + return hit; + } + let mut supers: Vec = Vec::new(); + for decl in context.get_nodes_by_name_shared(name) { + if decl.kind != NodeKind::Class || decl.language != Language::ObjC { + continue; + } + let Some(facts) = context.source_facts(&decl.file_path) else { + continue; + }; + if let Some(sup) = first_line(&facts, &decl).and_then(written_superclass) + && !supers.contains(&sup) + { + supers.push(sup); + } + } + if supers.is_empty() + && let Some(system) = system_superclass(name) + { + supers.push(system.to_string()); + } + let supers = Arc::new(supers); + if let Ok(mut memo) = tables.supers.lock() { + memo.insert(name.to_string(), Arc::clone(&supers)); + } + supers +} + +/// The classes a message to `self` or `super` at `reference` can reach: the +/// class it is written in, first, then what that one inherits from, at most +/// 30 (upstream `objcHierarchyAt`). `None` outside any class. +pub(crate) fn objc_hierarchy_at( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + let in_file = context.get_nodes_in_file_shared(&reference.file_path); + let covers = |node: &Node| node.start_line <= reference.line && node.end_line >= reference.line; + let here = in_file + .iter() + .filter(|node| node.kind == NodeKind::Class && covers(node)) + .min_by_key(|node| node.end_line - node.start_line) + .map(|node| node.name.clone()) + .or_else(|| { + // An `@implementation` whose range the index lost still names its + // class in its methods: `SDWebImageDownloaderDecryptor::initWithBlock:`. + in_file + .iter() + .find(|node| { + node.kind == NodeKind::Method + && covers(node) + && node.qualified_name.contains("::") + }) + .and_then(|node| node.qualified_name.rsplit_once("::")) + .map(|(owner, _)| owner.to_string()) + })?; + let mut seen: Vec = Vec::new(); + let mut queue: VecDeque = VecDeque::from([here]); + while seen.len() < 30 { + let Some(name) = queue.pop_front() else { + break; + }; + if seen.contains(&name) { + continue; + } + queue.extend(objc_supertypes_of(&name, context).iter().cloned()); + seen.push(name); + } + Some(seen) +} + +/// Whether a message to `self` (or, with `to_super`, to `super`) can mean +/// `node` (upstream `isObjcSelfSendTarget`): a member of a class of the +/// sender's `hierarchy`, past the sender's own class for `super`. Where +/// tree-sitter-objc lost an `@implementation` its methods are functions: one +/// in the sender's own file, or in the file named after a class of the +/// hierarchy, still counts, as does any when the sender's class is unknown; +/// a function elsewhere is never what `[super init]` sends to. +pub(crate) fn is_objc_self_send_target( + node: &Node, + reference: &RefView, + hierarchy: Option<&[String]>, + to_super: bool, +) -> bool { + let sender = hierarchy.and_then(|classes| classes.first()); + if is_objc_member_kind(node.kind) { + let (Some((owner, _)), Some(classes)) = (node.qualified_name.rsplit_once("::"), hierarchy) + else { + return true; + }; + // `[super init]` goes past the class it is written in. + return classes.iter().any(|class| class == owner) + && !(to_super && sender.is_some_and(|sender| sender == owner)); + } + if node.file_path == reference.file_path { + return !to_super; + } + let Some(classes) = hierarchy else { + return true; + }; + let base = file_base(&node.file_path); + classes.iter().any(|class| class == base) + && !(to_super && sender.is_some_and(|sender| sender == base)) +} + +/// A path's file name without its extension (upstream +/// `.slice(lastIndexOf('/') + 1).replace(/\.\w+$/, '')`). +pub fn file_base(path: &str) -> &str { + let name = path.rsplit('/').next().unwrap_or(path); + match name.rfind('.') { + Some(dot) + if dot + 1 < name.len() + && name[dot + 1..] + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') => + { + &name[..dot] + } + _ => name, + } +} + +/// `^[A-Z]\w*$` with JavaScript's ASCII `\w`. +fn is_class_word(text: &str) -> bool { + text.starts_with(|c: char| c.is_ascii_uppercase()) + && text.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +/// Whether an Objective-C receiver's type has `method`'s owner in its +/// hierarchy (upstream `objcReceiverReaches`): the receiver names a project +/// class (`[AllTypesObject objectsInRealm:…]`, a class method RLMObject +/// declares), or ends in a property one of whose `@property … Type *name` +/// declarations gives such a type (`managed.anyDataObj` → RLMSet, for +/// `containsObject:`). +pub(crate) fn objc_receiver_reaches( + receiver: &str, + method: &Node, + context: &dyn ResolutionContext, +) -> bool { + let Some((owner, _)) = method.qualified_name.rsplit_once("::") else { + return false; + }; + let types: Vec = if is_class_word(receiver) { + if context + .get_nodes_by_name_shared(receiver) + .iter() + .any(|node| node.kind == NodeKind::Class && node.language == Language::ObjC) + { + vec![receiver.to_string()] + } else { + Vec::new() + } + } else if let Some((_, last)) = receiver.rsplit_once('.') { + objc_property_types(last, context) + } else { + Vec::new() + }; + let mut seen: HashSet = HashSet::new(); + let mut queue: VecDeque = types.into(); + while seen.len() < 40 { + let Some(name) = queue.pop_front() else { + break; + }; + if !seen.insert(name.clone()) { + continue; + } + if name == owner { + return true; + } + queue.extend(objc_supertypes_of(&name, context).iter().cloned()); + } + false +} + +/// The classes the `@property … Type *name` declarations of `name` give +/// (upstream `objcPropertyTypes`). +fn objc_property_types(name: &str, context: &dyn ResolutionContext) -> Vec { + let mut types: Vec = Vec::new(); + for node in context.get_nodes_by_name_shared(name) { + if node.kind != NodeKind::Property || node.language != Language::ObjC { + continue; + } + let Some(facts) = context.source_facts(&node.file_path) else { + continue; + }; + if let Some(written) = + first_line(&facts, &node).and_then(|line| written_property_type(line, name)) + && !types.contains(&written) + { + types.push(written); + } + } + types +} + +/// Key prefix of the Objective-C head rows (W4-SW-04): one +/// `project_metadata` row per Objective-C file with a class or property, +/// written by every full index and migration and kept current by sync. +pub const HEAD_ROW_KEY_PREFIX: &str = "objc_heads:"; + +/// The `project_metadata` key of `file_path`'s head row. +pub fn head_row_key(file_path: &str) -> String { + format!("{HEAD_ROW_KEY_PREFIX}{file_path}") +} + +/// One Objective-C class or property, as the readers of this module take it +/// from its first line: the superclass a class's `@interface` names, the +/// type a property is declared with. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct HeadEntry { + /// `class` or `property`. + pub kind: String, + pub name: String, + pub written: Option, +} + +/// An Objective-C file's head row: every class and property, sorted +/// (positions are not part of it, so an edit above a class changes nothing). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeadRow { + pub entries: Vec, +} + +impl HeadRow { + /// The row of a file whose text is `source` and whose nodes are `nodes`. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let facts = SourceFacts::new(Arc::from(source)); + let mut entries: Vec = nodes + .iter() + .filter(|node| node.language == Language::ObjC) + .filter_map(|node| { + let line = first_line(&facts, node); + let (kind, written) = match node.kind { + NodeKind::Class => ("class", line.and_then(written_superclass)), + NodeKind::Property => ( + "property", + line.and_then(|line| written_property_type(line, &node.name)), + ), + _ => return None, + }; + Some(HeadEntry { + kind: kind.to_string(), + name: node.name.clone(), + written, + }) + }) + .collect(); + entries.sort(); + Self { entries } + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("objc head row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(kind: NodeKind, qualified_name: &str, file_path: &str, lines: (i64, i64)) -> Node { + Node { + id: format!("{}:{qualified_name}", kind.as_str()), + kind, + name: qualified_name + .rsplit("::") + .next() + .unwrap_or(qualified_name) + .to_string(), + qualified_name: qualified_name.to_string(), + file_path: file_path.to_string(), + language: Language::ObjC, + start_line: lines.0, + end_line: lines.1, + start_column: 0, + end_column: 1, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + } + } + + fn send(file_path: &str) -> RefView { + RefView { + row_id: None, + from_node_id: "method:x".to_string(), + reference_name: "init".to_string(), + reference_kind: codegraph_core::types::EdgeKind::Calls, + line: 4, + column: 4, + file_path: file_path.to_string(), + language: Language::ObjC, + is_function_ref: false, + reference_subkind: None, + } + } + + #[test] + fn heads_read_the_superclass_and_the_declared_property_type() { + assert_eq!( + written_superclass("@interface SDCache : SDBase ").as_deref(), + Some("SDBase") + ); + assert_eq!(written_superclass("@interface UIView (WebCache)"), None); + assert_eq!( + written_property_type( + "@property (nonatomic, readonly) RLMSet *anyDataObj;", + "anyDataObj" + ) + .as_deref(), + Some("RLMSet") + ); + assert_eq!( + written_property_type("@property NSString * _Nullable name;", "name").as_deref(), + Some("NSString") + ); + assert_eq!( + written_property_type("@property (copy) NSString *a; @property NSURL *b;", "b") + .as_deref(), + Some("NSURL") + ); + assert_eq!( + written_property_type("@property NSString *names;", "name"), + None + ); + assert_eq!( + written_property_type("@property (assign) BOOL done;", "done"), + None + ); + } + + #[test] + fn a_self_send_reaches_the_hierarchy_and_a_super_send_goes_past_the_sender() { + let hierarchy = [ + "Child".to_string(), + "Base".to_string(), + "NSObject".to_string(), + ]; + let reference = send("Child.m"); + let child_init = node(NodeKind::Method, "Child::init", "Child.m", (3, 6)); + let base_init = node(NodeKind::Method, "Base::init", "Base.m", (3, 6)); + let other_init = node(NodeKind::Method, "Other::init", "Other.m", (3, 6)); + assert!(is_objc_self_send_target( + &child_init, + &reference, + Some(&hierarchy), + false + )); + assert!(!is_objc_self_send_target( + &child_init, + &reference, + Some(&hierarchy), + true + )); + assert!(is_objc_self_send_target( + &base_init, + &reference, + Some(&hierarchy), + true + )); + assert!(!is_objc_self_send_target( + &other_init, + &reference, + Some(&hierarchy), + false + )); + assert!(is_objc_self_send_target( + &other_init, + &reference, + None, + true + )); + // A function: the sender's own file, or a file named after a class + // of the hierarchy. + let here = node(NodeKind::Function, "helper", "Child.m", (8, 9)); + let base = node(NodeKind::Function, "initWithBlock:", "Lib/Base.m", (1, 2)); + let elsewhere = node(NodeKind::Function, "init", "Lib/Util.m", (1, 2)); + assert!(is_objc_self_send_target( + &here, + &reference, + Some(&hierarchy), + false + )); + assert!(!is_objc_self_send_target( + &here, + &reference, + Some(&hierarchy), + true + )); + assert!(is_objc_self_send_target( + &base, + &reference, + Some(&hierarchy), + true + )); + assert!(!is_objc_self_send_target( + &elsewhere, + &reference, + Some(&hierarchy), + false + )); + } + + #[test] + fn a_file_base_drops_only_a_word_extension() { + assert_eq!(file_base("Core/UIView+WebCache.m"), "UIView+WebCache"); + assert_eq!(file_base("a.b.m"), "a.b"); + assert_eq!(file_base("Makefile"), "Makefile"); + assert_eq!(file_base("x/a.b-c"), "a.b-c"); + } + + #[test] + fn a_row_holds_every_class_and_property_with_what_its_line_writes() { + let text = "@interface SDCache : SDBase\n@property (nonatomic) NSURL *url;\n@end\n@implementation SDCache\n- (void)helper {\n}\n@end\n"; + let nodes = [ + node(NodeKind::Class, "SDCache", "SDCache.m", (1, 3)), + node(NodeKind::Property, "SDCache::url", "SDCache.m", (2, 2)), + node(NodeKind::Class, "SDCache", "SDCache.m", (4, 7)), + node(NodeKind::Method, "SDCache::helper", "SDCache.m", (5, 6)), + ]; + let row = HeadRow::of(text, &nodes); + let entry = |kind: &str, name: &str, written: Option<&str>| HeadEntry { + kind: kind.to_string(), + name: name.to_string(), + written: written.map(str::to_string), + }; + assert_eq!( + row.entries, + [ + entry("class", "SDCache", None), + entry("class", "SDCache", Some("SDBase")), + entry("property", "url", Some("NSURL")), + ] + ); + assert_eq!(HeadRow::decode(&row.encode()), Some(row)); + } +} diff --git a/crates/codegraph-resolve/src/object_owners.rs b/crates/codegraph-resolve/src/object_owners.rs new file mode 100644 index 00000000..3d35127f --- /dev/null +++ b/crates/codegraph-resolve/src/object_owners.rs @@ -0,0 +1,880 @@ +//! Object literals that own their members (upstream #2363, issue #2300). +//! +//! A named object literal, `const App = {…}`, the same inside an IIFE or a +//! function, `App = {…}`, or one hung on a path (`window.App = {…}`, +//! `App.utils = {…}`), makes each function member a node qualified under its +//! owner (`App::init`, `window.App::init`). An owner is a constant or variable +//! whose initializer is an object literal. A member is reached THROUGH its +//! object: `App.init()`, `window.App.init()`, `App.utils.fmt()`, a sibling's +//! `this.init()`, a `const { init } = App` binding, never by its name alone. +//! The holder a call names is the nearest one its own file declares around +//! it (an IIFE's `App` before the file's); otherwise a global one, +//! `window.App = {…}` anywhere or a classic script's top-level `App`, when +//! the calling file neither imports nor binds the name. +//! +//! Each file is scanned once per pass: its `{…}` blocks over the code with +//! comments and string contents blanked, and the scopes and destructuring +//! patterns a lookup asks about (upstream keeps the last 32 files' scans; the +//! per-pass source facts hold them here). + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::generated_header::is_js_whitespace; +use codegraph_core::types::{EdgeKind, Node, NodeKind}; +use regex::Regex; + +use crate::name_matcher::{ + BareCallReceiver, bare_call_receiver, cjs_export_marker, is_import_binding, is_js_family, + is_js_ts, is_object_literal_language, is_simple_js_identifier, js_export_marker, + js_function_local_scope, js_import_marker, range_within, resolve_object_literal_member, + same_language_family, +}; +use crate::object_literal::resolve_object_literal_binding; +use crate::source_facts::SourceFacts; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// Offset ranges a name is bound in, `(open, close)`; -1 opens the file. +type Scopes = Arc>; + +/// What the object-literal lookups read from one JS/TS file: its `{…}` +/// blocks, and, on first use, the scopes it binds a name in, the names it +/// destructures off a path, and whether it is a classic script. +#[derive(Debug, Default)] +pub struct JsObjectScan { + /// Offsets of every `{` and `}` of the code, in order. + events: Vec, + /// The innermost block still open just after each event (its `{` + /// offset), -1 at the top level. + open: Vec, + close: HashMap, + length: i64, + bindings: Mutex>, + destructured: OnceLock>>, + classic: OnceLock, +} + +/// The file's scan, its blocks read on first use. +fn scan_of(facts: &SourceFacts) -> Arc { + facts.js_object_scan(|facts| read_js_blocks(facts.ts_blanked())) +} + +fn read_js_blocks(code: &str) -> JsObjectScan { + let mut scan = JsObjectScan { + length: code.len() as i64, + ..JsObjectScan::default() + }; + let mut stack: Vec = Vec::new(); + for (at, byte) in code.bytes().enumerate() { + let at = at as i64; + match byte { + b'{' => stack.push(at), + b'}' => { + if let Some(open) = stack.pop() { + scan.close.insert(open, at); + } + } + _ => continue, + } + scan.events.push(at); + scan.open.push(stack.last().copied().unwrap_or(-1)); + } + scan +} + +/// The offset of (line, column) in a file. +fn js_offset(facts: &SourceFacts, scan: &JsObjectScan, line: i64, column: i64) -> i64 { + let start = usize::try_from(line) + .ok() + .and_then(|line| line.checked_sub(1)) + .and_then(|index| facts.lines().raw_line_start(index)) + .map_or(scan.length, |start| start as i64); + start + column +} + +/// The innermost `{…}` holding an offset, as (open, close); `None` at the +/// file's top level. +fn js_block_at(scan: &JsObjectScan, offset: i64) -> Option<(i64, i64)> { + let before = scan.events.partition_point(|&event| event < offset); + let open = *scan.open.get(before.checked_sub(1)?)?; + (open >= 0).then(|| (open, scan.close.get(&open).copied().unwrap_or(scan.length))) +} + +/// A path on the global object, `window.` or `globalThis.` (`self` is the +/// global only in a worker; in page code it is far more often `var self = +/// this`). +fn on_host_global(path: &str) -> bool { + path.starts_with("window.") || path.starts_with("globalThis.") +} + +/// The last `::` segment of a qualified name. +fn last_qualified_segment(qualified_name: &str) -> &str { + qualified_name + .rsplit_once("::") + .map_or(qualified_name, |(_, last)| last) +} + +/// The path a constant or variable is reached by: its name, or, for an +/// object hung on a path by assignment, that path, any global-object root +/// dropped (`window.App = {…}` defines the global `App`; `App.utils = {…}` is +/// `App.utils`). +fn holder_path(node: &Node) -> String { + let last = last_qualified_segment(&node.qualified_name); + if !last.contains('.') { + return node.name.clone(); + } + last.strip_prefix("window.") + .or_else(|| last.strip_prefix("globalThis.")) + .unwrap_or(last) + .to_string() +} + +/// Whether a constant or variable was hung on a dotted path by assignment +/// rather than declared. +fn is_path_holder(node: &Node) -> bool { + last_qualified_segment(&node.qualified_name).contains('.') +} + +/// Whether `node` holds an object literal: an owner its members can belong +/// to. +fn is_object_literal_owner(node: &Node) -> bool { + matches!(node.kind, NodeKind::Constant | NodeKind::Variable) + && is_js_family(node.language) + && node.signature.as_deref().is_some_and(|signature| { + signature + .strip_prefix('=') + .is_some_and(|rest| rest.trim_start().starts_with('{')) + }) +} + +/// The object literal a function node is a member of: its qualified parent, +/// an owner in its file whose extent holds it. `None` for every other +/// function: a declaration, a helper nested in a function, a function inside +/// an initializer that is not an object literal (a module IIFE's own helpers). +pub(crate) fn object_literal_owner_of( + node: &Node, + context: &dyn ResolutionContext, +) -> Option> { + if node.kind != NodeKind::Function || !is_js_family(node.language) { + return None; + } + let (parent, _) = node.qualified_name.rsplit_once("::")?; + if parent.is_empty() { + return None; + } + context + .get_nodes_by_qualified_name_shared(parent) + .into_iter() + .find(|owner| { + owner.file_path == node.file_path + && owner.id != node.id + && is_object_literal_owner(owner) + && range_within(node, owner) + }) +} + +/// The node a qualified name's parent names, in `node`'s file and holding it. +fn qualified_parent_of(node: &Node, context: &dyn ResolutionContext) -> Option> { + let (parent, _) = node.qualified_name.rsplit_once("::")?; + if parent.is_empty() { + return None; + } + context + .get_nodes_by_qualified_name_shared(parent) + .into_iter() + .find(|holder| { + holder.file_path == node.file_path && holder.id != node.id && range_within(node, holder) + }) +} + +/// The text of `file`'s `line` (1-based) from `column` (a byte offset, moved +/// back to a character boundary). +fn line_from( + context: &dyn ResolutionContext, + file: &str, + line: i64, + column: i64, +) -> Option { + let facts = context.source_facts(file)?; + let text = facts.raw_line(usize::try_from(line).ok()?.checked_sub(1)?)?; + let mut column = usize::try_from(column).unwrap_or(0).min(text.len()); + while !text.is_char_boundary(column) { + column -= 1; + } + Some(text[column..].to_string()) +} + +/// Whether an object literal's member is an arrow function, which has no +/// `this` of its own: its text starts `(a) =>`, `async (a) =>`, `a =>` or +/// `(a: T) =>`, never `name(…) {` or `function …`. +fn is_arrow_member(node: &Node, context: &dyn ResolutionContext) -> bool { + static ARROW_HEAD: OnceLock = OnceLock::new(); + let head = ARROW_HEAD.get_or_init(|| { + Regex::new(r"^(?:async\s*)?(?:[(<]|[A-Za-z_$][0-9A-Za-z_$]*\s*=>)").expect("arrow head") + }); + line_from(context, &node.file_path, node.start_line, node.start_column) + .is_some_and(|text| head.is_match(&text)) +} + +/// The caller whose scope `this` belongs to, for code written in `caller`. An +/// object literal's own method, `load() {…}` or `load: function () {…}`, has +/// the object: it is returned as is, so its qualified parent is the owner. An +/// arrow member has no `this` of its own, and a value written in the literal +/// runs where the literal is: both take the `this` of the code around the +/// literal, a class method's in `class App { api() { return { load: () => +/// this.x() } } }`. Any other caller is its own scope. +pub(crate) fn this_scope_caller(caller: Arc, context: &dyn ResolutionContext) -> Arc { + if !is_js_family(caller.language) { + return caller; + } + let mut current = caller; + for _ in 0..8 { + let owner = if is_object_literal_owner(¤t) { + Some(Arc::clone(¤t)) + } else { + object_literal_owner_of(¤t, context) + .filter(|_| is_arrow_member(¤t, context)) + }; + let Some(owner) = owner else { + break; + }; + let Some(up) = qualified_parent_of(&owner, context) else { + break; + }; + current = up; + } + current +} + +/// The object literal `this` is inside `caller`, when it is one: the owner of +/// an object's own method. +fn this_object_of(caller: Arc, context: &dyn ResolutionContext) -> Option> { + let scope = this_scope_caller(caller, context); + let owner = object_literal_owner_of(&scope, context)?; + (!is_arrow_member(&scope, context)).then_some(owner) +} + +/// Whether (line, column) falls inside `node`'s source range. +fn position_within(line: i64, column: i64, node: &Node) -> bool { + if line < node.start_line || line > node.end_line { + return false; + } + if line == node.start_line && column < node.start_column { + return false; + } + !(line == node.end_line && column >= node.end_column) +} + +/// Whether a reference by NAME alone can mean an object literal's member. +/// Only three ways write one: the owner's own `this.load()` (a sibling +/// member), a named function expression calling itself (`load: function +/// load() { load() }`), and a binding destructured off the owner (`const { +/// load } = App`). A bare `load()`, a callback `setTimeout(load)` or +/// `window.Other.load()` means a function of that name, or nothing. +/// +/// `owner` is the member's [`object_literal_owner_of`]; a function that is no +/// object's member is reached by name as any other. +pub(crate) fn is_member_reachable_through( + owner: &Node, + node: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if reference.file_path == node.file_path { + // `this` must be that object: written in one of its own methods, not + // in an arrow member. + if reference.reference_kind == EdgeKind::Calls + && position_within(reference.line, reference.column, owner) + && matches!( + bare_call_receiver(reference, context), + Some(BareCallReceiver::OwnObject) + ) + && context + .get_node_by_id_shared(&reference.from_node_id) + .and_then(|caller| this_object_of(caller, context)) + .is_some_and(|this| this.id == owner.id) + { + return true; + } + if position_within(reference.line, reference.column, node) + && let Some(text) = + line_from(context, &node.file_path, node.start_line, node.start_column) + && let Ok(named) = Regex::new(&format!( + r"^(?:async\s+)?function\s*\*?\s*{}", + regex::escape(&node.name) + )) + && named + .find(&text) + .is_some_and(|found| !text[found.end()..].starts_with(is_js_word_char)) + { + return true; + } + } + destructures_member( + &reference.file_path, + &holder_path(owner), + &node.name, + context, + ) +} + +/// A JS word character, `[\w$]`. +fn is_js_word_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '$' +} + +/// Whether an object hung on a dotted path, not the global object, is what a +/// bare name means: never. `App.utils = {…}` and `$.event.special.swipe = +/// {…}` are properties, not bindings, so a `this.swipe()` elsewhere is not +/// that object; one hung on `window` is the global `App`. +pub(crate) fn is_unreachable_path_holder(node: &Node, reference: &RefView) -> bool { + // The kind first: most candidates are no constant or variable. + is_object_literal_owner(node) + && is_path_holder(node) + && is_simple_js_identifier(&reference.reference_name) + && !on_host_global(last_qualified_segment(&node.qualified_name)) +} + +/// Whether `file` binds `member` by destructuring it off the object at +/// `path`: `{ load }` or `{ a, load = fallback }` off `App`, never `{ x: load +/// }`, which binds `load` to `App.x`. The file's patterns are read once. +fn destructures_member( + file: &str, + path: &str, + member: &str, + context: &dyn ResolutionContext, +) -> bool { + let Some(facts) = context.source_facts(file) else { + return false; + }; + let scan = scan_of(&facts); + scan.destructured + .get_or_init(|| read_destructured_paths(facts.ts_blanked())) + .get(path) + .is_some_and(|names| names.contains(member)) +} + +/// Each plain path a pattern is destructured off (the whole right-hand side: +/// `{ … } = App`, `= window.App.utils`) → the names it binds under their own +/// key. +fn read_destructured_paths(code: &str) -> HashMap> { + static DESTRUCTURED: OnceLock = OnceLock::new(); + let pattern = DESTRUCTURED.get_or_init(|| { + Regex::new( + r"\{([^{}]*)\}\s*=\s*(?:(?:window|globalThis)\s*\.\s*)?([A-Za-z_$][0-9A-Za-z_$]*(?:\s*\.\s*[A-Za-z_$][0-9A-Za-z_$]*)*)", + ) + .expect("destructured path") + }); + let mut paths: HashMap> = HashMap::new(); + for found in pattern.captures_iter(code) { + let whole = found.get(0).expect("match"); + // The path is the whole right-hand side, not a call or a member's + // receiver. + if code[whole.end()..] + .trim_start_matches(is_js_whitespace) + .starts_with(['.', '(', '[']) + { + continue; + } + let own: Vec = destructured_keys(&found[1]) + .into_iter() + .filter(|(name, key)| name == key) + .map(|(name, _)| name) + .collect(); + if own.is_empty() { + continue; + } + let at: String = found[2].chars().filter(|c| !c.is_whitespace()).collect(); + paths.entry(at).or_default().extend(own); + } + paths +} + +/// The (local name, key) pairs of a destructuring pattern's parts whose key +/// is an identifier: `a`, `b: c`, `d = 1` give (a, a), (c, b), (d, d). +fn destructured_keys(pattern: &str) -> Vec<(String, String)> { + static DEFAULT: OnceLock = OnceLock::new(); + let default = DEFAULT.get_or_init(|| Regex::new(r"\s*=.*$").expect("default value")); + pattern + .split(',') + .filter_map(|part| { + let mut sides = part + .split(':') + .map(|side| default.replace(side.trim(), "").into_owned()); + let key = sides.next()?; + let local = sides.next().unwrap_or_else(|| key.clone()); + is_simple_js_identifier(&key).then_some((local, key)) + }) + .collect() +} + +/// The offset ranges a JS/TS file binds `name` in: a declaration's or a +/// function or class name's enclosing block (the whole file at the top +/// level), a parameter's function body (an expression-bodied arrow's, to the +/// end of its enclosing block). The patterns are the local-binding ones; +/// only the scope is added, so one function's parameter does not shadow a +/// global in another. +fn binding_scopes(name: &str, facts: &SourceFacts, scan: &JsObjectScan) -> Scopes { + if let Some(known) = scan + .bindings + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(name) + { + return Arc::clone(known); + } + let code = facts.ts_blanked(); + let mut scopes: Vec<(i64, i64)> = Vec::new(); + if code.contains(name) { + let length = code.len() as i64; + let block_of = |at: i64| js_block_at(scan, at).unwrap_or((-1, length)); + // The body a parameter list (ending in `{` or `=>`) opens. + let body_after = |start: i64, end: i64| -> (i64, i64) { + let end_at = usize::try_from(end).unwrap_or(0); + if end_at >= 1 && code.as_bytes().get(end_at - 1) == Some(&b'{') { + return ( + end - 1, + scan.close.get(&(end - 1)).copied().unwrap_or(length), + ); + } + let rest = code.get(end_at..).unwrap_or_default(); + let skipped = rest.len() - rest.trim_start_matches(is_js_whitespace).len(); + let brace = end + skipped as i64; + if rest[skipped..].starts_with('{') { + return (brace, scan.close.get(&brace).copied().unwrap_or(length)); + } + (start, block_of(start).1) + }; + let n = regex::escape(name); + let declaration = Regex::new(&format!( + r"(?-u:\b)(?:const|let|var)\s+(?:{n}(?-u:\b)|[{{\[][^;=]*?(?-u:\b){n}(?-u:\b)[^;=]*?[}}\]])\s*(?:=\s*([^;\n]*))?" + )); + let function = Regex::new(&format!(r"(?-u:\b)(?:function|class)\s+{n}(?-u:\b)")); + let parameter = Regex::new(&format!( + r"\(\s*(?:(?:\.\.\.)?[0-9A-Za-z_$]+(?:\s*(?:\?\s*)?:[^,()]+|\s*=[^,()]+|\s*),\s*)*{n}(?-u:\b)(?:\s*\??\s*:[^,()]*)?(?:\s*=[^,()]*)?(?:\s*,\s*[^()]*)?\)\s*(?::[^=;{{]*)?(?:=>|\{{)" + )); + for found in declaration + .iter() + .flat_map(|pattern| pattern.find_iter(code)) + { + scopes.push(block_of(found.start() as i64)); + } + for found in function.iter().flat_map(|pattern| pattern.find_iter(code)) { + scopes.push(block_of(found.start() as i64)); + } + for found in parameter.iter().flat_map(|pattern| pattern.find_iter(code)) { + scopes.push(body_after(found.start() as i64, found.end() as i64)); + } + // An arrow's lone parameter, `name =>`, not preceded by `[\w$.]`. + for (at, _) in code.match_indices(name) { + if code[..at] + .chars() + .next_back() + .is_some_and(|before| is_js_word_char(before) || before == '.') + { + continue; + } + let rest = &code[at + name.len()..]; + let arrow = rest.trim_start_matches(is_js_whitespace); + if arrow.starts_with("=>") { + let end = (at + name.len() + (rest.len() - arrow.len()) + 2) as i64; + scopes.push(body_after(at as i64, end)); + } + } + } + let scopes = Arc::new(scopes); + scan.bindings + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(name.to_string(), Arc::clone(&scopes)); + scopes +} + +/// Whether a binding of `name` in the reference's own file encloses the +/// reference. +fn binds_name_at(name: &str, reference: &RefView, context: &dyn ResolutionContext) -> bool { + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let scan = scan_of(&facts); + let at = js_offset(&facts, &scan, reference.line, reference.column); + binding_scopes(name, &facts, &scan) + .iter() + .any(|&(start, end)| at > start && at < end) +} + +/// Where a same-file holder of an object can be seen from: the block it is +/// declared in (an IIFE's, a function's), or the whole file for one at the +/// top level, and for a path hung by assignment, which is a property of its +/// root object wherever that is. The depth to rank by is the block's `{` +/// offset, -1 for the whole file; `None` when the reference cannot see it, +/// outside its block or where the calling function binds the root name +/// itself. +fn holder_scope_at( + holder: &Node, + reference: &RefView, + via_global: bool, + context: &dyn ResolutionContext, +) -> Option { + let mut depth = -1; + // A single-file component's source holds its template and styles too, + // whose braces are not blocks: a holder there is read as the script's + // top level. + if !is_path_holder(holder) + && is_js_ts(holder.language) + && let Some(facts) = context.source_facts(&holder.file_path) + { + let scan = scan_of(&facts); + let declared = js_offset(&facts, &scan, holder.start_line, holder.start_column); + if let Some((open, close)) = js_block_at(&scan, declared) { + let at = js_offset(&facts, &scan, reference.line, reference.column); + if at <= open || at >= close { + return None; + } + depth = open; + } + } + if !via_global { + let path = holder_path(holder); + let root = path.split('.').next().unwrap_or_default(); + if let Some((start, end)) = js_function_local_scope(root, reference, context) + && !(holder.file_path == reference.file_path + && holder.start_line >= start + && holder.start_line <= end) + { + return None; + } + } + Some(depth) +} + +/// Whether a JS/TS file is a classic script, with no `import`, `export`, +/// CommonJS export or `require(…)`, whose top-level names are globals every +/// other script on the page shares. +fn is_classic_script(file: &str, context: &dyn ResolutionContext) -> bool { + static REQUIRE: OnceLock = OnceLock::new(); + let Some(facts) = context.source_facts(file) else { + return false; + }; + let scan = scan_of(&facts); + *scan.classic.get_or_init(|| { + let lower = file.to_ascii_lowercase(); + if lower.ends_with(".mjs") + || lower.ends_with(".cjs") + || cjs_export_marker().is_match(facts.source()) + { + return false; + } + let code = facts.ts_blanked(); + let require = REQUIRE.get_or_init(|| { + Regex::new(r"(?:^|[^0-9A-Za-z_$.])require\s*\(").expect("require call") + }); + !js_import_marker().is_match(code) + && !js_export_marker().is_match(code) + && !require.is_match(code) + }) +} + +/// Whether a holder in another file is a global: on the global object, or at +/// the top level of a classic script. +fn is_global_holder(node: &Node, context: &dyn ResolutionContext) -> bool { + if on_host_global(last_qualified_segment(&node.qualified_name)) { + return true; + } + if !is_js_ts(node.language) || !is_classic_script(&node.file_path, context) { + return false; + } + if !is_path_holder(node) { + let Some(facts) = context.source_facts(&node.file_path) else { + return false; + }; + let scan = scan_of(&facts); + return js_block_at( + &scan, + js_offset(&facts, &scan, node.start_line, node.start_column), + ) + .is_none(); + } + // `App.utils = {…}` is global when `App` is: declared at a classic + // script's top level, or on the global object. + let path = holder_path(node); + let root = path.split('.').next().unwrap_or_default(); + context + .get_nodes_by_name_shared(root) + .into_iter() + .any(|holder| { + matches!(holder.kind, NodeKind::Constant | NodeKind::Variable) + && is_js_family(holder.language) + && holder_path(&holder) == root + && holder.id != node.id + && is_global_holder(&holder, context) + }) +} + +/// Resolve `member` on the object a dotted `path` names, at `reference`: +/// `App` for `App.init()` / `window.App.init()`, `App.utils` for +/// `App.utils.fmt()`. A same-file holder the reference can see comes first, +/// the innermost; then, when the file neither imports nor binds the path's +/// root, a global one: `window.App = {…}` anywhere, or a classic script's +/// top-level `App`. Several equally near holders that all have the member are +/// no answer. `host` is the global object the call was written on +/// (`window.App.init()`), `None` for a bare `App.init()`. `None` when no holder +/// of the path exists at all (so a caller can try its other strategies), +/// `Some(None)` when one exists but none has the member. +pub(crate) fn resolve_object_path_member( + path: &str, + member: &str, + reference: &RefView, + context: &dyn ResolutionContext, + host: Option<&str>, +) -> Option> { + let via_global = host.is_some(); + let tail = path.rsplit('.').next().unwrap_or(path); + // The name's object-literal constants and variables by their paths, read + // once per pass (W5-08), then this call's own filter. + let holders = context.name_matcher_tables().object_holders(tail, || { + let mut by_path = crate::name_matcher::HoldersByPath::new(); + for node in context.get_nodes_by_name_shared(tail) { + if matches!(node.kind, NodeKind::Constant | NodeKind::Variable) + && is_object_literal_language(node.language) + { + by_path.entry(holder_path(&node)).or_default().push(node); + } + } + by_path + }); + let named: Vec> = holders + .get(path) + .into_iter() + .flatten() + .filter(|node| same_language_family(node.language, reference.language)) + .cloned() + .collect(); + if named.is_empty() { + return None; + } + let hit_on = |holder: &Node| { + resolve_object_literal_member( + holder, + member, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + ) + .or_else(|| resolve_object_literal_binding(holder, member, reference, context)) + }; + + let mut local: Vec<(i64, &Arc)> = named + .iter() + .filter(|node| node.file_path == reference.file_path) + .filter_map(|node| { + holder_scope_at(node, reference, via_global, context).map(|depth| (depth, node)) + }) + .collect(); + if !local.is_empty() { + // The nearest holders only: an outer one is shadowed where an inner + // one exists. + local.sort_by_key(|(depth, _)| std::cmp::Reverse(*depth)); + let nearest = local[0].0; + return Some( + local + .iter() + .take_while(|(depth, _)| *depth == nearest) + .find_map(|(_, holder)| hit_on(holder)), + ); + } + + let hits: Vec<(&Arc, ResolvedRef)> = named + .iter() + .filter(|node| { + node.file_path != reference.file_path + && is_object_literal_owner(node) + && is_global_holder(node, context) + }) + .filter_map(|node| hit_on(node).map(|hit| (node, hit))) + .collect(); + if hits.is_empty() { + return Some(None); + } + // A global is what the call names only where nothing nearer binds the + // name: the file's own import of it, a parameter or local around the + // call, or, for `window.App`, a `window` of its own. + let root = path.split('.').next().unwrap_or(path); + let bound = match host { + Some(host) => binds_name_at(host, reference, context), + None => { + is_import_binding(root, reference, context) || binds_name_at(root, reference, context) + } + }; + if bound { + return Some(None); + } + let one_target = |hits: &[&(&Arc, ResolvedRef)]| { + hits.iter() + .map(|(_, hit)| hit.target_node_id.as_str()) + .collect::>() + .len() + == 1 + }; + let all: Vec<&(&Arc, ResolvedRef)> = hits.iter().collect(); + if one_target(&all) { + let mut hit = hits[0].1.clone(); + hit.confidence = 0.8; + return Some(Some(hit)); + } + // The same namespace defined twice (a page's copy and a build's): the + // caller's own directory decides, or nothing does. + let dir = reference + .file_path + .rfind('/') + .map_or("", |slash| &reference.file_path[..=slash]); + let near: Vec<&(&Arc, ResolvedRef)> = hits + .iter() + .filter(|(node, _)| { + node.file_path + .strip_prefix(dir) + .is_some_and(|rest| !rest.contains('/')) + }) + .collect(); + if !near.is_empty() && one_target(&near) { + let mut hit = near[0].1.clone(); + hit.confidence = 0.75; + return Some(Some(hit)); + } + Some(None) +} + +/// `a.b.m()` through a namespace an object literal was hung on (`App.utils = +/// {…}` then `App.utils.fmt()`). An identifier-rooted chain carries no type +/// (#1566), so nothing else may resolve it: this answers only when a holder of +/// exactly that path exists. +pub(crate) fn match_object_path_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + if reference.reference_kind != EdgeKind::Calls || !is_js_family(reference.language) { + return None; + } + let (path, member) = reference.reference_name.rsplit_once('.')?; + if path.is_empty() { + return None; + } + let (host, path) = match path.split_once('.') { + Some((root, rest)) if matches!(root, "window" | "globalThis") => (Some(root), rest), + _ => (None, path), + }; + if !path.contains('.') && host.is_none() { + return None; + } + resolve_object_path_member(path, member, reference, context, host).flatten() +} + +/// A call the extractor recorded by its bare name although it was written on +/// an object: `window.App.init()` (the `window` chain keeps its bare name as +/// the project-global escape, #1707) and `this.render()` inside an object +/// literal's member. Both name the owner of the member they call, which is +/// tried first. `None` when the call is neither, or no such owner has the +/// member: the bare-name strategies then run as before. +pub(crate) fn match_collapsed_object_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + static HOST_GLOBAL_RECEIVER: OnceLock = OnceLock::new(); + let name = reference.reference_name.as_str(); + if reference.reference_kind != EdgeKind::Calls + || !is_js_family(reference.language) + || !is_simple_js_identifier(name) + { + return None; + } + // A call written bare starts with its own name and `(`: no receiver to + // read. + if let Some(text) = line_from( + context, + &reference.file_path, + reference.line, + reference.column, + ) && text + .strip_prefix(name) + .is_some_and(|after| after.trim_start().starts_with('(')) + { + return None; + } + match bare_call_receiver(reference, context)? { + BareCallReceiver::OwnObject => { + // The object literal `this` is, the owner of the method the call + // is written in (an arrow member's `this` is the code around the + // literal's), when it has the member. + let caller = context.get_node_by_id_shared(&reference.from_node_id)?; + let owner = this_object_of(caller, context)?; + resolve_object_literal_member( + &owner, + name, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + ) + } + BareCallReceiver::Other { receiver, .. } => { + let host = HOST_GLOBAL_RECEIVER.get_or_init(|| { + Regex::new( + r"(?:^|[^0-9A-Za-z_$.])(window|globalThis)\s*\??\.\s*([A-Za-z_$][0-9A-Za-z_$]*(?:\s*\??\.\s*[A-Za-z_$][0-9A-Za-z_$]*)*)$", + ) + .expect("global-object receiver") + }); + let found = host.captures(&receiver)?; + let path: String = found[2] + .chars() + .filter(|c| !c.is_whitespace() && *c != '?') + .collect(); + resolve_object_path_member(&path, name, reference, context, Some(&found[1])).flatten() + } + } +} + +#[cfg(test)] +mod tests { + use super::{destructured_keys, js_block_at, read_destructured_paths, read_js_blocks}; + + #[test] + fn blocks_name_the_innermost_brace_open_at_an_offset() { + let code = "a { b { c } d } e"; + let scan = read_js_blocks(code); + assert_eq!(js_block_at(&scan, 0), None); + assert_eq!(js_block_at(&scan, 4), Some((2, 14))); + assert_eq!(js_block_at(&scan, 8), Some((6, 10))); + assert_eq!(js_block_at(&scan, 12), Some((2, 14))); + assert_eq!(js_block_at(&scan, 16), None); + // An unclosed block runs to the end of the code. + let open = read_js_blocks("x { y"); + assert_eq!(js_block_at(&open, 4), Some((2, 5))); + } + + #[test] + fn destructuring_off_a_plain_path_binds_its_own_keys() { + let paths = read_destructured_paths( + "const { load, a: b, c = 1 } = App;\nconst { d } = window.App . utils;\nconst { e } = App.make();\nconst { f } = App.x[0];\n", + ); + let names = |path: &str| { + let mut names: Vec = paths + .get(path) + .map(|names| names.iter().cloned().collect()) + .unwrap_or_default(); + names.sort(); + names + }; + assert_eq!(names("App"), ["c", "load"]); + assert_eq!(names("App.utils"), ["d"]); + assert!(names("App.make").is_empty() && names("App.x").is_empty()); + assert_eq!( + destructured_keys(" a, b: c , 'd': e, f = g"), + [ + ("a".to_string(), "a".to_string()), + ("c".to_string(), "b".to_string()), + ("f".to_string(), "f".to_string()), + ] + ); + } +} diff --git a/crates/codegraph-resolve/src/overloads.rs b/crates/codegraph-resolve/src/overloads.rs new file mode 100644 index 00000000..ecf7577e --- /dev/null +++ b/crates/codegraph-resolve/src/overloads.rs @@ -0,0 +1,1029 @@ +//! A call reaches the overload its arguments fit (upstream #2212, #2213, +//! #2238, #2241 `retargetSelfOverload`). +//! +//! A method delegating to another overload of itself (`toInstant(instant)` +//! returning `toInstant(instant, null)`) bound to itself, and a call from +//! elsewhere to the first-indexed overload, since every overload shares the +//! name. The call's argument count is read at the call site and compared with +//! each same-owner overload's parameters, defaults and varargs / `params` / +//! `...` counted; Swift compares argument labels. Nothing is stored: the +//! argument list is read from the source at the reference's position. + +use std::collections::HashMap; +use std::fmt::Write as _; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; + +use crate::types::{RefView, ResolutionContext, ResolvedRef}; + +/// Languages whose methods overload by arity (upstream `OVERLOADING_LANGUAGES`; +/// VB.NET joins with its extractor). +fn overloads_by_arity(language: Language) -> bool { + matches!( + language, + Language::CSharp + | Language::Java + | Language::Kotlin + | Language::Swift + | Language::Cpp + | Language::Scala + | Language::Dart + | Language::Solidity + ) +} + +/// How many arguments a parameter list takes; `max: None` for a variadic one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Arity { + min: usize, + max: Option, +} + +impl Arity { + fn takes(self, count: usize) -> bool { + count >= self.min && self.max.is_none_or(|max| count <= max) + } + + /// The arity once a C# extension method's `this` parameter, which an + /// instance-style call does not pass, is set aside. + fn without_this(self) -> Self { + Self { + min: self.min.saturating_sub(1), + max: self.max.map(|max| max.saturating_sub(1)), + } + } +} + +/// One overload's declared parameters: its arity, whether it is a C# +/// extension method (a `this` first parameter), the parameters' text, and +/// the type parameters and `where` clauses a C# extension method declares +/// (`Open(…) where T : struct`). +#[derive(Debug, Clone)] +struct Overload { + arity: Arity, + extension: bool, + params: Vec, + type_params: Vec, + constraints: String, +} + +/// A method's same-owner overloads in its file. +#[derive(Debug)] +pub(crate) struct OverloadSet { + name: String, + owner: String, + own: Option, + siblings: Vec<(String, Option)>, +} + +/// Overload sets by method id, per resolution pass. +pub(crate) type OverloadSets = Mutex>>>; + +/// `result` moved to the overload the call's arguments fit, or unchanged. +/// C++ overload sets (templates, SFINAE tags) are trusted only for a method's +/// call to itself. +pub(crate) fn retarget_overload( + result: ResolvedRef, + reference: &RefView, + context: &dyn ResolutionContext, + sets: &OverloadSets, +) -> ResolvedRef { + if reference.reference_kind != EdgeKind::Calls || !overloads_by_arity(reference.language) { + return result; + } + if reference.language == Language::Cpp && result.target_node_id != reference.from_node_id { + return result; + } + let set = { + let mut sets = sets + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sets.entry(result.target_node_id.clone()) + .or_insert_with(|| overload_set_of(&result.target_node_id, context).map(Arc::new)) + .clone() + }; + let Some(set) = set else { + return result; + }; + // A C# extension method called on a receiver other than its own class + // (`propertyInfo.GetGetMethod(true)`, not `TypeExtensions.GetGetMethod(p, + // true)`) is not passed its `this` parameter (W3-05c). + let instance_style = reference.language == Language::CSharp + && reference + .reference_name + .rsplit_once('.') + .is_some_and(|(receiver, _)| { + receiver.rsplit('.').next().unwrap_or(receiver) != set.owner + }); + let arity_of = |entry: &Overload| { + if entry.extension && instance_style { + entry.arity.without_this() + } else { + entry.arity + } + }; + let Some(own) = set.own.as_ref().map(arity_of) else { + return result; + }; + let Some(args) = paren_list_after( + &reference.file_path, + reference.line, + usize::try_from(reference.column).unwrap_or(0), + &set.name, + context, + ) else { + return result; + }; + let retarget = |id: &str| ResolvedRef { + target_node_id: id.to_string(), + ..result.clone() + }; + if reference.language == Language::Swift { + // Swift overloads by argument label as much as by count. + let labels: Vec = if args.trim().is_empty() { + Vec::new() + } else { + split_top_level(&args) + .iter() + .map(|arg| argument_label(arg)) + .collect() + }; + let fits_labels = |id: &str| -> Option { + let decl = context.get_node_by_id_shared(id)?; + let list = paren_list_after(&decl.file_path, decl.start_line, 0, &set.name, context)?; + Some(swift_labels_fit(&labels, &list)) + }; + if fits_labels(&result.target_node_id) != Some(false) { + return result; + } + let fit: Vec<&String> = set + .siblings + .iter() + .map(|(id, _)| id) + .filter(|id| fits_labels(id) == Some(true)) + .collect(); + return match fit.as_slice() { + [only] => retarget(only), + _ => result, + }; + } + let arguments = if args.trim().is_empty() { + Vec::new() + } else { + split_top_level(&args) + }; + let count = arguments.len(); + let extension_call = instance_style + && set + .own + .iter() + .chain(set.siblings.iter().filter_map(|(_, entry)| entry.as_ref())) + .any(|entry| entry.extension); + if extension_call { + return match csharp_extension_choice(&set, &result.target_node_id, &arguments) { + Some(id) if id != result.target_node_id => retarget(&id), + _ => result, + }; + } + if own.takes(count) { + return result; + } + let fits: Vec<&String> = set + .siblings + .iter() + .filter(|(_, entry)| { + entry + .as_ref() + .is_some_and(|entry| arity_of(entry).takes(count)) + }) + .map(|(id, _)| id) + .collect(); + match fits.as_slice() { + [only] => retarget(only), + _ => result, + } +} + +/// How a C# overload takes a call's arguments. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Form { + /// As declared: no `params`, or an array passed at the `params` position. + Normal, + /// The `params` array built from the trailing arguments. + Expanded, +} + +/// The overload of `set` an instance-style call of a C# extension method +/// takes, by C#'s rules with no `this` argument (W3-05c). Only the extension +/// overloads whose `this` parameter is declared as the current target's is +/// ([`receiver_key`]: the method's own type parameters numbered, so `Box` +/// and `Box` agree) take the receiver it was chosen for: a static method +/// that is no extension, or one extending another type, is never a +/// candidate. Of those, an overload that takes the arguments in its normal +/// form beats one that takes them only expanded; an array creation passed at +/// a `params` position goes to the `params` overload over one whose +/// parameter there is not an array. `None` when the current target is no +/// extension method, or no overload fits; the current target when several +/// still do. +fn csharp_extension_choice( + set: &OverloadSet, + current: &str, + arguments: &[String], +) -> Option { + let chosen = csharp_extension_chosen(set, current, arguments)?; + match chosen.as_slice() { + [] => None, + [id] => Some(id.clone()), + _ => chosen + .iter() + .any(|id| id == current) + .then(|| current.to_string()), + } +} + +/// The one of `candidates` (C# extension overloads of one type, from any of +/// its partial parts' files) an instance-style call with `arguments` takes by +/// [`csharp_extension_choice`]'s rules, or `None` when none or several do +/// (W3-05c, for a receiver of a C# keyword type now that the receiver-word +/// guess no longer reads the method's own name, upstream #2171). +pub(crate) fn csharp_extension_unique_choice( + candidates: &[Arc], + arguments: &[String], + context: &dyn ResolutionContext, +) -> Option { + let (first, rest) = candidates.split_first()?; + let owner = owner_of(&first.qualified_name); + let set = OverloadSet { + name: first.name.clone(), + owner: owner.rsplit("::").next().unwrap_or(owner).to_string(), + own: overload_of(first, &first.name, context), + siblings: rest + .iter() + .map(|other| (other.id.clone(), overload_of(other, &first.name, context))) + .collect(), + }; + match csharp_extension_chosen(&set, &first.id, arguments)?.as_slice() { + [id] => Some(id.clone()), + _ => None, + } +} + +/// The overloads [`csharp_extension_choice`] chooses between, best form +/// first applied: `None` when the current target is no extension method. +fn csharp_extension_chosen( + set: &OverloadSet, + current: &str, + arguments: &[String], +) -> Option> { + let receiver = set.own.as_ref().and_then(receiver_key)?; + let count = arguments.len(); + let array_last = arguments.last().is_some_and(|arg| is_array_creation(arg)); + let form = |entry: &Overload| -> Option
      { + let params = entry.params.get(1..).unwrap_or_default(); + let variadic = params.last().is_some_and(|p| is_pack(p)); + let fixed = if variadic { + ¶ms[..params.len() - 1] + } else { + params + }; + let required = fixed.iter().filter(|p| !p.contains('=')).count(); + if !variadic { + return (required <= count && count <= fixed.len()).then_some(Form::Normal); + } + if count == params.len() && array_last { + return Some(Form::Normal); + } + (count >= required).then_some(Form::Expanded) + }; + let entries: Vec<(&str, &Overload)> = set + .own + .iter() + .map(|entry| (current, entry)) + .chain(set.siblings.iter().filter_map(|(id, entry)| { + entry + .as_ref() + .filter(|entry| receiver_key(entry).as_ref() == Some(&receiver)) + .map(|entry| (id.as_str(), entry)) + })) + .collect(); + let fitting: Vec<(&str, &Overload, Form)> = entries + .iter() + .filter_map(|&(id, entry)| form(entry).map(|form| (id, entry, form))) + .collect(); + let best = if fitting.iter().any(|(_, _, form)| *form == Form::Normal) { + Form::Normal + } else { + Form::Expanded + }; + let mut chosen: Vec<(&str, &Overload)> = fitting + .iter() + .filter(|(_, _, form)| *form == best) + .map(|&(id, entry, _)| (id, entry)) + .collect(); + if chosen.len() > 1 && array_last { + // `new object?[] { … }` at a `params object?[]` position is an + // identity conversion; to an `object?` parameter it is not. + let at = count - 1; + let declared = |entry: &Overload| entry.params.get(1 + at).cloned().unwrap_or_default(); + let packs: Vec<(&str, &Overload)> = chosen + .iter() + .copied() + .filter(|(_, entry)| is_pack(&declared(entry))) + .collect(); + let others_take_no_array = chosen + .iter() + .filter(|(_, entry)| !is_pack(&declared(entry))) + .all(|(_, entry)| !declares_array(&declared(entry))); + if packs.len() == 1 && others_take_no_array { + chosen = packs; + } + } + Some(chosen.iter().map(|(id, _)| (*id).to_string()).collect()) +} + +/// A C# extension overload's `this` type as written, then its `where` +/// clauses, token by token with the method's own type parameters numbered by +/// first appearance: `Box` over `Open` and `Box< U >` over `Open` +/// are both `Box<#0>`, `where T: struct` and `where T : struct` agree, while +/// `IList` stays apart from `IList` and `where T : struct` from +/// `where U : class`. `None` for an overload that is no extension method. +fn receiver_key(entry: &Overload) -> Option { + let text = crate::csharp_extensions::this_parameter_text(entry.params.first()?)?; + let mut seen: Vec = Vec::new(); + let mut key = number_type_parameters(&text, &entry.type_params, &mut seen); + if !entry.constraints.is_empty() { + key.push(' '); + key.push_str(&number_type_parameters( + &entry.constraints, + &entry.type_params, + &mut seen, + )); + } + Some(key) +} + +/// `text`'s tokens with each of `type_params` replaced by `#` and its +/// ordinal in `seen`, which a type parameter joins when it first appears. +/// Spacing is dropped, except one space between two words (`ref int`). +fn number_type_parameters(text: &str, type_params: &[String], seen: &mut Vec) -> String { + let is_word = |c: char| c.is_alphanumeric() || c == '_'; + let mut out = String::with_capacity(text.len()); + let mut after_word = false; + let mut rest = text; + while let Some(first) = rest.chars().next() { + if first.is_whitespace() { + rest = &rest[first.len_utf8()..]; + continue; + } + let word = is_word(first); + let len = if word { + rest.find(|c: char| !is_word(c)).unwrap_or(rest.len()) + } else { + first.len_utf8() + }; + let token = &rest[..len]; + if word && after_word { + out.push(' '); + } + after_word = word; + if type_params.iter().any(|param| param == token) { + let ordinal = seen.iter().position(|s| s == token).unwrap_or_else(|| { + seen.push(token.to_string()); + seen.len() - 1 + }); + let _ = write!(out, "#{ordinal}"); + } else { + out.push_str(token); + } + rest = &rest[len..]; + } + out +} + +/// The type parameters a C# method declares (`Open(…)`: `T`, `U`) and +/// the `where` clauses between its parameter list and its body, read from +/// the declaration with its comments and literals blanked by the C# lexer +/// (raw strings included); empty when it declares none. +fn generic_head( + file: &str, + line: i64, + name: &str, + context: &dyn ResolutionContext, +) -> (Vec, String) { + let none = (Vec::new(), String::new()); + let Some(facts) = context.source_facts(file) else { + return none; + }; + let Some(start) = usize::try_from(line).ok().and_then(|l| l.checked_sub(1)) else { + return none; + }; + let end = (start + 12).min(facts.line_count()); + if start >= end { + return none; + } + let text = crate::csharp_scopes::blank_non_code(&facts.join_lines(start, end, "\n")); + // The first `name(` or `name<…>(` is the declaration's own head, as in + // [`paren_list_after`]; a later overload's type parameters are not its. + let Ok(pattern) = Regex::new(&format!( + r"(?-u:\b){}\s*(?:<([^<>()]*)>)?\s*\(", + regex::escape(name) + )) else { + return none; + }; + let Some(head) = pattern.captures(&text) else { + return none; + }; + let type_params: Vec = head + .get(1) + .map(|list| { + list.as_str() + .split(',') + .filter_map(|param| { + // `[Attr] T`: the name is the last word. + param + .trim() + .rsplit(|c: char| !(c.is_alphanumeric() || c == '_')) + .next() + .filter(|name| !name.is_empty()) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default(); + let open = head.get(0).map_or(0, |all| all.end() - 1); + let mut depth = 0usize; + let mut close = None; + for (offset, ch) in text[open..].char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + close = Some(open + offset + 1); + break; + } + } + _ => {} + } + } + let constraints = close + .map(|close| { + let tail = &text[close..]; + let body = [tail.find(['{', ';']), tail.find("=>")] + .into_iter() + .flatten() + .min() + .unwrap_or(tail.len()); + tail[..body].trim().to_string() + }) + .unwrap_or_default(); + (type_params, constraints) +} + +/// `new T[] { … }`, `new T[n]` or `new[] { … }`. +fn is_array_creation(argument: &str) -> bool { + let Some(rest) = argument.trim().strip_prefix("new") else { + return false; + }; + if !rest.starts_with(|c: char| c.is_whitespace() || c == '[') { + return false; + } + let head = rest.split(['{', '(']).next().unwrap_or_default(); + head.contains('[') +} + +/// Whether a declared parameter's type is an array (`object?[] args`). +fn declares_array(param: &str) -> bool { + let without_default = param.split('=').next().unwrap_or(param); + without_default + .trim() + .rsplit_once(char::is_whitespace) + .is_some_and(|(declared, _)| declared.trim_end().ends_with(']')) +} + +/// The owner prefix of a qualified name: everything before its last `::`. +fn owner_of(qualified_name: &str) -> &str { + qualified_name + .rfind("::") + .map_or("", |cut| &qualified_name[..cut]) +} + +/// A method's same-owner overloads, read from its declaration; `None` when it +/// has none. +fn overload_set_of(id: &str, context: &dyn ResolutionContext) -> Option { + let own = context.get_node_by_id_shared(id)?; + if !matches!(own.kind, NodeKind::Method | NodeKind::Function) { + return None; + } + let owner = owner_of(&own.qualified_name); + let siblings: Vec> = context + .get_nodes_in_file_shared(&own.file_path) + .into_iter() + .filter(|n| { + n.id != own.id + && n.name == own.name + && matches!(n.kind, NodeKind::Method | NodeKind::Function) + && owner_of(&n.qualified_name) == owner + }) + .collect(); + if siblings.is_empty() { + return None; + } + Some(OverloadSet { + name: own.name.clone(), + owner: owner.rsplit("::").next().unwrap_or(owner).to_string(), + own: overload_of(&own, &own.name, context), + siblings: siblings + .iter() + .map(|sibling| (sibling.id.clone(), overload_of(sibling, &own.name, context))) + .collect(), + }) +} + +/// The declared parameters of the overload `node` of a method named `name`. +fn overload_of(node: &Node, name: &str, context: &dyn ResolutionContext) -> Option { + let list = paren_list_after(&node.file_path, node.start_line, 0, name, context)?; + let params: Vec = split_top_level(&list) + .into_iter() + .filter(|p| !p.is_empty() && p != "void") + .collect(); + let min = params + .iter() + .filter(|p| !p.contains('=') && !is_pack(p)) + .count(); + let max = (!params.iter().any(|p| is_pack(p))).then_some(params.len()); + let extension = node.language == Language::CSharp + && params + .first() + .is_some_and(|first| crate::csharp_extensions::is_this_parameter(first)); + let (type_params, constraints) = if extension { + generic_head(&node.file_path, node.start_line, name, context) + } else { + (Vec::new(), String::new()) + }; + Some(Overload { + arity: Arity { min, max }, + extension, + params, + type_params, + constraints, + }) +} + +/// A variadic parameter: `...` (outside one level of `<…>`), C#'s `params`, +/// Kotlin's `vararg`. +pub(crate) fn is_pack(param: &str) -> bool { + static ANGLES: OnceLock = OnceLock::new(); + static KEYWORD: OnceLock = OnceLock::new(); + let flat = ANGLES + .get_or_init(|| Regex::new(r"<[^<>]*>").expect("angle group")) + .replace_all(param, ""); + flat.contains("...") + || KEYWORD + .get_or_init(|| Regex::new(r"(?-u:\b)(?:params|vararg)\s").expect("variadic keyword")) + .is_match(&flat) +} + +/// A Swift argument's label (`numberOfRowsInSection: section`), or `_`. +fn argument_label(argument: &str) -> String { + static LABEL: OnceLock = OnceLock::new(); + let label = LABEL.get_or_init(|| Regex::new(r"^\s*([A-Za-z_]\w*)\s*:").expect("swift label")); + match label.captures(argument) { + Some(c) if !argument[c.get(0).map_or(0, |m| m.end())..].starts_with(':') => { + c[1].to_string() + } + _ => "_".to_string(), + } +} + +/// Whether a Swift call's argument labels (`_` for none) fit a declaration's +/// parameter list: each parameter's external label in order, one with a +/// default value or a variadic one free to be left out (upstream +/// `swiftLabelsFit`). +fn swift_labels_fit(labels: &[String], param_list: &str) -> bool { + static HEAD: OnceLock = OnceLock::new(); + let head = HEAD.get_or_init(|| { + Regex::new( + r"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:inout\s+)?([A-Za-z_]\w*)(?:\s+([A-Za-z_]\w*))?\s*:", + ) + .expect("swift parameter head") + }); + let params: Vec<(String, bool)> = if param_list.trim().is_empty() { + Vec::new() + } else { + split_top_level(param_list) + .iter() + .map(|param| { + let label = head + .captures(param) + .map_or_else(|| "_".to_string(), |c| c[1].to_string()); + (label, param.contains('=') || param.contains("...")) + }) + .collect() + }; + let mut at = 0; + for (label, optional) in ¶ms { + if at < labels.len() && labels[at] == *label { + at += 1; + continue; + } + if !optional { + return false; + } + } + at == labels.len() +} + +/// Split `a, f(b, c), d` at its top-level commas (upstream +/// `splitCppTopLevel`): a quoted string is one unit, `(`, `[`, `{` and `<` +/// nest, and `->` closes nothing. +pub(crate) fn split_top_level(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut depth = 0usize; + let mut current = String::new(); + let mut at = 0; + while at < chars.len() { + let ch = chars[at]; + if ch == '"' || ch == '\'' { + let mut end = at + 1; + while end < chars.len() && chars[end] != ch { + end += if chars[end] == '\\' { 2 } else { 1 }; + } + let stop = (end + 1).min(chars.len()); + current.extend(&chars[at..stop]); + at = end + 1; + continue; + } + match ch { + '(' | '[' | '{' | '<' => depth += 1, + ')' | ']' | '}' => depth = depth.saturating_sub(1), + '>' if at == 0 || chars[at - 1] != '-' => depth = depth.saturating_sub(1), + _ => {} + } + if ch == ',' && depth == 0 { + out.push(current.trim().to_string()); + current.clear(); + } else { + current.push(ch); + } + at += 1; + } + if !current.trim().is_empty() { + out.push(current.trim().to_string()); + } + out +} + +/// The balanced `( … )` after `name`, from `line` / `column` of `file` +/// (twelve lines at most), or `None` (upstream `cppParenListAfter`). +pub(crate) fn paren_list_after( + file: &str, + line: i64, + column: usize, + name: &str, + context: &dyn ResolutionContext, +) -> Option { + let facts = context.source_facts(file)?; + let start = usize::try_from(line).ok()?.checked_sub(1)?; + let end = (start + 12).min(facts.line_count()); + if start >= end { + return None; + } + let text = facts.join_lines(start, end, "\n"); + let rest = text.get(column..)?; + let at = Regex::new(&format!( + r"(?-u:\b){}\s*(?:<[^<>()]*>)?\s*\(", + regex::escape(name) + )) + .ok()? + .find(rest)?; + let open = column + at.end() - 1; + let mut depth = 0usize; + for (offset, ch) in text[open..].char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(text[open + 1..open + offset].to_string()); + } + } + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{ + Arity, Overload, OverloadSet, csharp_extension_choice, declares_array, is_array_creation, + split_top_level, swift_labels_fit, + }; + + fn overload(params: &[&str]) -> Overload { + generic(&[], params) + } + + /// An overload declaring the type parameters `type_params`. + fn generic(type_params: &[&str], params: &[&str]) -> Overload { + constrained(type_params, params, "") + } + + /// An overload declaring `type_params` under the `where` clauses + /// `constraints`. + fn constrained(type_params: &[&str], params: &[&str], constraints: &str) -> Overload { + let params: Vec = params.iter().map(|p| (*p).to_string()).collect(); + let min = params + .iter() + .filter(|p| !p.contains('=') && !super::is_pack(p)) + .count(); + let max = (!params.iter().any(|p| super::is_pack(p))).then_some(params.len()); + Overload { + arity: Arity { min, max }, + extension: params + .first() + .is_some_and(|p| crate::csharp_extensions::is_this_parameter(p)), + params, + type_params: type_params.iter().map(|p| (*p).to_string()).collect(), + constraints: constraints.to_string(), + } + } + + fn set(own: &[&str], siblings: &[(&str, &[&str])]) -> OverloadSet { + OverloadSet { + name: "M".to_string(), + owner: "Ext".to_string(), + own: Some(overload(own)), + siblings: siblings + .iter() + .map(|(id, params)| ((*id).to_string(), Some(overload(params)))) + .collect(), + } + } + + fn args(text: &str) -> Vec { + split_top_level(text) + } + + #[test] + fn an_array_at_a_params_position_takes_the_params_overload() { + let formats = set( + &[ + "this string format", + "IFormatProvider provider", + "object? arg0", + ], + &[( + "params", + &[ + "this string format", + "IFormatProvider provider", + "params object?[] args", + ], + )], + ); + assert_eq!( + csharp_extension_choice(&formats, "own", &args("provider, new object?[] { a, b }")) + .as_deref(), + Some("params") + ); + // A plain argument: the normal form of the fixed overload beats the + // expanded `params` one. + assert_eq!( + csharp_extension_choice(&formats, "own", &args("provider, a")).as_deref(), + Some("own") + ); + assert_eq!( + csharp_extension_choice(&formats, "params", &args("provider, a, b")).as_deref(), + Some("params") + ); + } + + #[test] + fn an_attributed_this_parameter_is_not_an_argument() { + let getters = set( + &["[NotNull] this PropertyInfo propertyInfo", "bool nonPublic"], + &[("one", &["[NotNull] this PropertyInfo propertyInfo"])], + ); + assert_eq!( + csharp_extension_choice(&getters, "own", &args("true")).as_deref(), + Some("own") + ); + assert_eq!( + csharp_extension_choice(&getters, "own", &args("")).as_deref(), + Some("one") + ); + } + + #[test] + fn same_arity_overloads_keep_the_current_target() { + let methods = set( + &["this Type type", "string name", "BindingFlags flags"], + &[( + "other", + &["this Type type", "string name", "IList types"], + )], + ); + assert_eq!( + csharp_extension_choice(&methods, "own", &args("null, parameterTypes")).as_deref(), + Some("own") + ); + } + + #[test] + fn only_overloads_extending_the_receivers_type_compete() { + // `type.Pick(arg)`: the plain static `Pick(object?)` and + // `Pick(this Exception, object?)` fit one argument in their normal + // form, but neither takes a `Type` receiver. + let picks = set( + &["this Type type", "params object?[] args"], + &[ + ("plain", &["object? arg"]), + ("other", &["this Exception error", "object? arg"]), + ], + ); + assert_eq!( + csharp_extension_choice(&picks, "own", &args("arg")).as_deref(), + Some("own") + ); + // The `this` types are compared as written, spacing aside: + // `IList` is not the `IList` the current target extends. + let lists = OverloadSet { + name: "M".to_string(), + owner: "Ext".to_string(), + own: Some(generic(&["T"], &["this IList list", "params T[] items"])), + siblings: vec![ + ( + "ints".to_string(), + Some(overload(&["this IList list", "int item"])), + ), + ( + "same".to_string(), + Some(generic(&["T"], &["this IList list", "T item"])), + ), + ], + }; + assert_eq!( + csharp_extension_choice(&lists, "own", &args("1")).as_deref(), + Some("same") + ); + let timers = set( + &["this System.Timers.Timer timer", "params object?[] args"], + &[( + "threading", + &["this System.Threading.Timer timer", "object? arg"], + )], + ); + assert_eq!( + csharp_extension_choice(&timers, "own", &args("arg")).as_deref(), + Some("own") + ); + // A current target that is no extension method names no receiver + // type to compare with. + let plain = set( + &["object? arg"], + &[("ext", &["this Type type", "object? arg"])], + ); + assert_eq!(csharp_extension_choice(&plain, "own", &args("arg")), None); + } + + #[test] + fn renamed_type_parameters_extend_the_same_type() { + let pair = |own: Overload, sibling: Overload| OverloadSet { + name: "Open".to_string(), + owner: "BoxExtensions".to_string(), + own: Some(own), + siblings: vec![("sibling".to_string(), Some(sibling))], + }; + // `box.Open(1)`: `Open(this Box, U)` takes it in its normal + // form, though its type parameter is not named `T`. + let boxes = pair( + generic(&["T"], &["this Box box", "params T[] values"]), + generic(&["U"], &["this Box box", "U value"]), + ); + assert_eq!( + csharp_extension_choice(&boxes, "own", &args("1")).as_deref(), + Some("sibling") + ); + // Numbered by first appearance in the `this` type: `Map` over + // `` is `Map` over ``. + let maps = pair( + generic(&["T", "U"], &["this Map map", "params T[] keys"]), + generic(&["A", "B"], &["this Map map", "B key"]), + ); + assert_eq!( + csharp_extension_choice(&maps, "own", &args("k")).as_deref(), + Some("sibling") + ); + // A constructed `Box` is not the open `Box`. + let constructed = pair( + generic(&["T"], &["this Box box", "params T[] values"]), + overload(&["this Box box", "int value"]), + ); + assert_eq!( + csharp_extension_choice(&constructed, "own", &args("1")).as_deref(), + Some("own") + ); + // `where` clauses are part of the receiver: `U : class` takes no + // receiver `T : struct` does, `U : struct` takes the same ones. + let own = || { + constrained( + &["T"], + &["this Box box", "params T[] values"], + "where T : struct", + ) + }; + let classes = pair( + own(), + constrained(&["U"], &["this Box box", "U value"], "where U : class"), + ); + assert_eq!( + csharp_extension_choice(&classes, "own", &args("1")).as_deref(), + Some("own") + ); + let structs = pair( + own(), + constrained(&["U"], &["this Box box", "U value"], "where U : struct"), + ); + assert_eq!( + csharp_extension_choice(&structs, "own", &args("1")).as_deref(), + Some("sibling") + ); + // Spacing is no part of either: `Box< T >` with `where T: struct` is + // the `Box` with `where T : struct` the current target extends. + let spaced = pair( + own(), + constrained(&["T"], &["this Box< T > box", "T value"], "where T: struct"), + ); + assert_eq!( + csharp_extension_choice(&spaced, "own", &args("1")).as_deref(), + Some("sibling") + ); + // Nor are comments in the `this` parameter. + let commented = pair( + own(), + constrained( + &["T"], + &["this Box /* boxed */ box", "T value"], + "where T : struct", + ), + ); + assert_eq!( + csharp_extension_choice(&commented, "own", &args("1")).as_deref(), + Some("sibling") + ); + // Words stay apart: `ref int` is not `refint`. + let words = pair( + overload(&["this ref int x", "params int[] xs"]), + overload(&["this refint x", "int y"]), + ); + assert_eq!( + csharp_extension_choice(&words, "own", &args("1")).as_deref(), + Some("own") + ); + } + + #[test] + fn array_creations_and_array_parameters_are_recognized() { + assert!(is_array_creation("new object?[] { a, b }")); + assert!(is_array_creation("new[] { 1, 2 }")); + assert!(is_array_creation("new int[3]")); + assert!(!is_array_creation("new Widget(1)")); + assert!(!is_array_creation("newer")); + assert!(declares_array("object?[] args")); + assert!(declares_array("params object?[] args")); + assert!(!declares_array("object? arg0")); + } + + #[test] + fn top_level_commas_skip_nested_groups_and_strings() { + assert_eq!( + split_top_level("a, f(b, c), d, \"x, y\""), + ["a", "f(b, c)", "d", "\"x, y\""] + ); + assert_eq!(split_top_level("p -> q, r"), ["p -> q", "r"]); + assert!(split_top_level(" ").is_empty()); + } + + #[test] + fn swift_labels_fit_in_order_with_optional_parameters() { + let labels = vec!["_".to_string(), "numberOfRowsInSection".to_string()]; + assert!(swift_labels_fit( + &labels, + "_ tableView: UITableView, numberOfRowsInSection section: Int" + )); + assert!(!swift_labels_fit( + &labels, + "_ tableView: UITableView, titleForHeaderInSection section: Int" + )); + let labels = vec!["optionallyProducingResumeData".to_string()]; + assert!(swift_labels_fit( + &labels, + "optionallyProducingResumeData handler: ((Data?) -> Void)?, retry: Bool = false" + )); + } +} diff --git a/crates/codegraph-resolve/src/package_manifests.rs b/crates/codegraph-resolve/src/package_manifests.rs new file mode 100644 index 00000000..efd78d02 --- /dev/null +++ b/crates/codegraph-resolve/src/package_manifests.rs @@ -0,0 +1,375 @@ +//! Which bare JS-family import specifiers name a package from outside the +//! repository (upstream #2128 `isDeclaredOutsidePackage` / `manifestScope`). +//! +//! A specifier is outside only when the importing file's package.json, or an +//! enclosing one, declares it (a Deno import map counts), or it is a Node +//! built-in, `@std/…`, a runtime scheme (`node:`, `npm:`, …) or a framework's +//! virtual module. A specifier nothing declares is an alias this resolver +//! does not follow (SvelteKit's `$lib/…`, a nested Nuxt app's `~/…`, a +//! nested app's own `@/…`) and stays the project's. Manifests are read by +//! project-relative path through [`ResolutionContext::read_file_shared`], the +//! bounded, stat-first reader, from directories of indexed files only. + +use std::collections::BTreeSet; + +use serde_json::Value; + +use crate::types::ResolutionContext; + +/// The package names a directory's package.json and every enclosing one own +/// (`name`, and a `workspace:` / `file:` / `link:` / `portal:` dependency) +/// and depend on, Deno import-map entries included. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ManifestScope { + pub own: BTreeSet, + pub deps: BTreeSet, +} + +/// Node's built-in modules without the `node:` scheme +/// (`require('module').builtinModules` across Node 20 to 26: upstream's +/// engines range and the runtime it was measured on). Sorted for binary +/// search. +const NODE_BUILTIN_MODULES: [&str; 68] = [ + "_http_agent", + "_http_client", + "_http_common", + "_http_incoming", + "_http_outgoing", + "_http_server", + "_stream_duplex", + "_stream_passthrough", + "_stream_readable", + "_stream_transform", + "_stream_wrap", + "_stream_writable", + "_tls_common", + "_tls_wrap", + "assert", + "assert/strict", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "dns/promises", + "domain", + "events", + "fs", + "fs/promises", + "http", + "http2", + "https", + "inspector", + "inspector/promises", + "module", + "net", + "os", + "path", + "path/posix", + "path/win32", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "readline/promises", + "repl", + "stream", + "stream/consumers", + "stream/promises", + "stream/web", + "string_decoder", + "sys", + "timers", + "timers/promises", + "tls", + "trace_events", + "tty", + "url", + "util", + "util/types", + "v8", + "vm", + "wasi", + "worker_threads", + "zlib", +]; + +/// Is `source`, imported by `from_file`, a package from outside the +/// repository? See the module documentation. +pub fn is_declared_outside_package( + source: &str, + from_file: &str, + context: &dyn ResolutionContext, +) -> bool { + // Deno's standard library is `@std/…` from JSR. + if has_runtime_scheme(source) + || source.starts_with("@std/") + || NODE_BUILTIN_MODULES.binary_search(&source).is_ok() + { + return true; + } + let name = package_name(source); + let file = from_file.replace('\\', "/"); + let scope = importing_dir(&file) + .map(|dir| context.manifest_scope(dir)) + .unwrap_or_default(); + if scope.own.contains(name) { + return false; + } + if scope.deps.contains(name) { + return true; + } + // SvelteKit's `$app/…` and Astro's `astro:…` belong to the framework, + // unless this repository is that framework. + let provider = if source.starts_with("astro:") { + Some("astro") + } else if is_sveltekit_virtual(source) { + Some("@sveltejs/kit") + } else { + None + }; + provider.is_some_and(|provider| { + !scope.own.contains(provider) + && !context + .get_workspace_packages() + .is_some_and(|ws| ws.by_name.contains_key(provider)) + }) +} + +/// `dir`'s manifest scope: its parent's, plus what `dir/package.json`, +/// `dir/deno.json` and `dir/deno.jsonc` declare. The parent comes from +/// [`ResolutionContext::manifest_scope`], so a caching context reads each +/// directory once per pass. +pub fn read_manifest_scope(dir: &str, context: &C) -> ManifestScope { + let mut scope = if dir.is_empty() { + ManifestScope::default() + } else { + let parent = dir.rsplit_once('/').map_or("", |(parent, _)| parent); + context.manifest_scope(parent).as_ref().clone() + }; + let at = |file: &str| { + if dir.is_empty() { + file.to_string() + } else { + format!("{dir}/{file}") + } + }; + if let Some(Value::Object(json)) = context + .read_file_shared(&at("package.json")) + .and_then(|text| serde_json::from_str::(&text).ok()) + { + if let Some(name) = json + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + { + scope.own.insert(name.to_string()); + } + for field in [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ] { + let Some(deps) = json.get(field).and_then(Value::as_object) else { + continue; + }; + for (dep, version) in deps { + // `workspace:*`, `file:../shared`, `link:…`: a package in + // this repository. + if version.as_str().is_some_and(is_in_repo_version) { + scope.own.insert(dep.clone()); + } else { + scope.deps.insert(dep.clone()); + } + } + } + } + // A Deno import map names packages the same way: an entry that maps to a + // registry or a URL, not one that maps to a path in the repository. + for file in ["deno.json", "deno.jsonc"] { + let Some(raw) = context.read_file_shared(&at(file)) else { + continue; + }; + let Ok(json) = serde_json::from_str::(&crate::path_aliases::strip_jsonc(&raw)) + else { + continue; + }; + let Some(imports) = json.get("imports").and_then(Value::as_object) else { + continue; + }; + for (key, target) in imports { + if target.as_str().is_some_and(is_registry_target) { + scope + .deps + .insert(key.strip_suffix('/').unwrap_or(key).to_string()); + } + } + } + scope +} + +/// The project paths [`read_manifest_scope`] reads for the files `files` +/// (project paths): `package.json`, `deno.json` and `deno.jsonc` in each +/// directory from a file's up to the root, a missing one included, sorted, +/// each once. What a sync compares to know when the manifests may have +/// moved (W5-06). +pub fn manifest_inputs(files: &[String]) -> Vec { + let mut dirs: BTreeSet = BTreeSet::new(); + for file in files { + let Some(mut dir) = importing_dir(file) else { + continue; + }; + loop { + dirs.insert(dir.to_string()); + if dir.is_empty() { + break; + } + dir = dir.rsplit_once('/').map_or("", |(parent, _)| parent); + } + } + let mut out: Vec = dirs + .iter() + .flat_map(|dir| { + ["package.json", "deno.json", "deno.jsonc"].map(|name| { + if dir.is_empty() { + name.to_string() + } else { + format!("{dir}/{name}") + } + }) + }) + .collect(); + out.sort(); + out +} + +/// The directory a project-relative file sits in (`""` at the root), or +/// `None` for a path that could leave the project (absolute, or with a `..` +/// segment), whose manifests are never read. +fn importing_dir(file: &str) -> Option<&str> { + if file.starts_with('/') + || file.as_bytes().get(1) == Some(&b':') + || file.split('/').any(|segment| segment == "..") + { + return None; + } + Some(file.rsplit_once('/').map_or("", |(dir, _)| dir)) +} + +/// `@scope/name` of a scoped specifier, else its first segment. +fn package_name(source: &str) -> &str { + if source.starts_with('@') { + let mut slashes = source.match_indices('/').map(|(at, _)| at); + slashes + .nth(1) + .map_or(source, |second_slash| &source[..second_slash]) + } else { + source.split('/').next().unwrap_or(source) + } +} + +/// `node:`, `bun:`, `jsr:`, `npm:`, `http:` or `https:`. +fn has_runtime_scheme(source: &str) -> bool { + ["node:", "bun:", "jsr:", "npm:", "http:", "https:"] + .iter() + .any(|scheme| source.starts_with(scheme)) +} + +/// `jsr:`, `npm:`, `http:` or `https:`: an import-map target outside the +/// repository. +fn is_registry_target(target: &str) -> bool { + ["jsr:", "npm:", "http:", "https:"] + .iter() + .any(|scheme| target.starts_with(scheme)) +} + +/// `workspace:`, `file:`, `link:` or `portal:`: a dependency in the +/// repository. +fn is_in_repo_version(version: &str) -> bool { + ["workspace:", "file:", "link:", "portal:"] + .iter() + .any(|protocol| version.starts_with(protocol)) +} + +/// SvelteKit's `$app`, `$env` and `$service-worker` modules. +fn is_sveltekit_virtual(source: &str) -> bool { + ["$app", "$env", "$service-worker"].iter().any(|module| { + source + .strip_prefix(module) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) + }) +} + +#[cfg(test)] +mod tests { + use super::{ + NODE_BUILTIN_MODULES, importing_dir, is_sveltekit_virtual, manifest_inputs, package_name, + }; + + #[test] + fn manifest_inputs_are_each_folder_up_from_a_file() { + let files = [ + "apps/web/src/main.ts".to_string(), + "apps/web/index.ts".to_string(), + "../outside.ts".to_string(), + ]; + let mut expected = Vec::new(); + for dir in ["", "apps/", "apps/web/", "apps/web/src/"] { + for name in ["deno.json", "deno.jsonc", "package.json"] { + expected.push(format!("{dir}{name}")); + } + } + expected.sort(); + assert_eq!(manifest_inputs(&files), expected); + assert!(manifest_inputs(&[]).is_empty()); + } + + #[test] + fn node_builtins_are_sorted_for_binary_search() { + assert!( + NODE_BUILTIN_MODULES + .windows(2) + .all(|pair| pair[0] < pair[1]) + ); + assert!(NODE_BUILTIN_MODULES.binary_search(&"fs/promises").is_ok()); + assert!(NODE_BUILTIN_MODULES.binary_search(&"lodash").is_err()); + } + + #[test] + fn package_names_keep_their_scope() { + assert_eq!(package_name("@app/shared/utils"), "@app/shared"); + assert_eq!(package_name("@app/shared"), "@app/shared"); + assert_eq!(package_name("@app"), "@app"); + assert_eq!(package_name("dayjs/plugin/utc"), "dayjs"); + assert_eq!(package_name("$lib/design"), "$lib"); + } + + #[test] + fn sveltekit_virtual_modules_are_whole_segments() { + assert!(is_sveltekit_virtual("$app/navigation")); + assert!(is_sveltekit_virtual("$env/static/private")); + assert!(is_sveltekit_virtual("$app")); + assert!(!is_sveltekit_virtual("$application/x")); + assert!(!is_sveltekit_virtual("$lib/design")); + } + + #[test] + fn only_project_relative_files_name_a_manifest_directory() { + assert_eq!( + importing_dir("frontend/pages/labels.vue"), + Some("frontend/pages") + ); + assert_eq!(importing_dir("app.ts"), Some("")); + assert_eq!(importing_dir("/etc/app.ts"), None); + assert_eq!(importing_dir("C:/app.ts"), None); + assert_eq!(importing_dir("../outside/app.ts"), None); + assert_eq!(importing_dir("a/../../b.ts"), None); + } +} diff --git a/crates/codegraph-resolve/src/path_aliases.rs b/crates/codegraph-resolve/src/path_aliases.rs index 4d19fb06..5d88246c 100644 --- a/crates/codegraph-resolve/src/path_aliases.rs +++ b/crates/codegraph-resolve/src/path_aliases.rs @@ -10,9 +10,42 @@ use crate::pathutil; use serde_json::Value; +use std::cell::RefCell; use std::collections::HashSet; use std::path::{Path, PathBuf}; +thread_local! { + /// While [`alias_config_inputs`] runs, every path the loader probes or + /// reads, in order. + static PROBES: RefCell>> = const { RefCell::new(None) }; +} + +fn note_probe(path: &Path) { + PROBES.with(|probes| { + if let Some(list) = probes.borrow_mut().as_mut() { + list.push(path.to_path_buf()); + } + }); +} + +/// Every path [`load_project_aliases`] probes or reads for `project_root`, +/// once each, in the order it first does: the three root configs and each +/// `extends` candidate, a missing one included, wherever the loader looks +/// (an absolute or `../` target, a `node_modules` above the project). What a +/// sync compares to know when the aliases may have moved (W5-06). +pub fn alias_config_inputs(project_root: &str) -> Vec { + PROBES.with(|probes| *probes.borrow_mut() = Some(Vec::new())); + let _ = load_project_aliases(project_root); + let probed = PROBES + .with(|probes| probes.borrow_mut().take()) + .unwrap_or_default(); + let mut seen = HashSet::new(); + probed + .into_iter() + .filter(|path| seen.insert(path.clone())) + .collect() +} + /// A single alias pattern from `compilerOptions.paths` /// (`AliasPattern`, `path-aliases.ts:31-45`). #[derive(Debug, Clone, PartialEq, Eq)] @@ -56,7 +89,7 @@ const MAX_EXTENDS_DEPTH: usize = 32; /// /// Walks the source as a tiny state machine tracking string context so that a /// `//` inside a string value (e.g. a URL) is never truncated. -fn strip_jsonc(src: &str) -> String { +pub(crate) fn strip_jsonc(src: &str) -> String { let chars: Vec = src.chars().collect(); let n = chars.len(); let mut out = String::with_capacity(src.len()); @@ -152,6 +185,7 @@ fn append_json_extension(path: &Path) -> PathBuf { } fn is_file(path: &Path) -> bool { + note_probe(path); std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) } @@ -187,6 +221,7 @@ fn resolve_extends_target(spec: &str, from_dir: &Path) -> Option { } fn read_tsconfig_like(path: &Path) -> Option { + note_probe(path); let text = std::fs::read_to_string(path).ok()?; let parsed = serde_json::from_str::(&strip_jsonc(&text)).ok()?; parsed.is_object().then_some(parsed) @@ -403,6 +438,51 @@ mod tests { std::fs::write(path, serde_json::to_string_pretty(&value).unwrap()).unwrap(); } + #[test] + fn alias_config_inputs_are_every_path_the_loader_probes() { + let root = temp_dir("inputs"); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ "extends": ["./configs/base.json", "@acme/tsconfig"] }), + ); + // No config holds `paths`: all three root configs are probed, and + // each `extends` candidate, missing ones included, the package's in + // every `node_modules` up to the filesystem root. + let base = root.join("configs/base.json"); + let mut expected = vec![ + root.join("tsconfig.json"), + base.clone(), + append_json_extension(&base), + base.join("tsconfig.json"), + ]; + for dir in root.ancestors() { + let package = dir.join("node_modules").join("@acme/tsconfig"); + expected.extend([ + package.clone(), + append_json_extension(&package), + package.join("tsconfig.json"), + ]); + } + expected.extend([root.join("jsconfig.json"), root.join("tsconfig.base.json")]); + assert_eq!(alias_config_inputs(&root.to_string_lossy()), expected); + + // The first config with `paths` ends the search, and its existing + // `extends` target is read once. + write_json( + &base, + serde_json::json!({ "compilerOptions": { "paths": { "@lib/*": ["src/lib/*"] } } }), + ); + write_json( + root.join("tsconfig.json"), + serde_json::json!({ "extends": "./configs/base.json" }), + ); + assert_eq!( + alias_config_inputs(&root.to_string_lossy()), + [root.join("tsconfig.json"), base] + ); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn strip_jsonc_removes_line_and_block_comments() { let src = "{\n // line\n \"a\": 1, /* block */ \"b\": 2\n}"; diff --git a/crates/codegraph-resolve/src/php_scope.rs b/crates/codegraph-resolve/src/php_scope.rs new file mode 100644 index 00000000..e8126d25 --- /dev/null +++ b/crates/codegraph-resolve/src/php_scope.rs @@ -0,0 +1,1099 @@ +//! PHP calls and class names read against the class hierarchy and the file's +//! scope (upstream #2142, #2152, #2103, #2235). +//! +//! Extraction records `$this->m()` under the bare name `m`, so exact and fuzzy +//! matching took any same-named method: a call written through `$this->`, +//! `self::`, `static::` or `parent::` reaches only a method of the enclosing +//! class's ancestry — `extends` and trait `use`, read from source and resolved +//! through the file's `namespace` and `use` imports to fully qualified classes +//! — or a subclass's. A call through a receiver nothing typed keeps the one +//! project method of its name only when the receiver is named after a class +//! whose ancestry has it. +//! +//! A call written without a receiver is a function call (#2103), and a bare +//! class name is its namespace's class or the one a `use` imports (#2235). + +use std::collections::{BTreeSet, HashMap, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::generated_header::is_js_whitespace; +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::types::{RefView, ResolutionContext}; + +/// The most types one ancestry walk visits (upstream's 80). +pub const ANCESTRY_LIMIT: usize = 80; + +/// Per-pass memos: each file's scope, each declaration's supertypes, each +/// ancestry walk, the type enclosing each call line and whether a name is a +/// trait's. +#[derive(Debug, Default)] +pub struct PhpTables { + file_scopes: Mutex>>, + supertypes: Mutex>>>, + ancestries: Mutex, Arc>>, + enclosing: Mutex>>>, + traits: Mutex>, +} + +/// A call's file and 1-based line. +type CallLine = (String, i64); + +/// What one ancestry walk reached. +#[derive(Debug, Default)] +pub(crate) struct PhpAncestry { + /// Every type the walk visited, its start included. + pub(crate) types: BTreeSet, + /// Whether a visited name has no PHP type declaration in the repository. + pub(crate) leaves_repo: bool, +} + +impl PhpTables { + pub fn new() -> Self { + Self::default() + } +} + +/// A PHP file's `namespace` and its file-level `use A\B\C [as D];` imports, +/// alias → fully qualified name (upstream `phpFileScope`). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct PhpFileScope { + pub namespace: String, + pub uses: HashMap, +} + +/// How a bare PHP method name is written at its call site (upstream +/// `phpSelfReceiver`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PhpVia { + /// `$this->m()`, `self::m()`, `static::m()`: the enclosing class's own or + /// inherited methods, or a subclass's. + SelfCall, + /// `parent::m()`: an ancestor's. + Parent, +} + +fn is_word_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +/// Whether `node` is a PHP type a call's hierarchy walks. +fn is_php_type(node: &Node) -> bool { + node.language == Language::Php + && matches!( + node.kind, + NodeKind::Class | NodeKind::Trait | NodeKind::Interface | NodeKind::Enum + ) +} + +/// How the bare PHP call `reference` is written on its line: through +/// `$this` / `self` / `static`, through `parent`, or neither. +pub(crate) fn php_self_receiver( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let name = reference.reference_name.as_str(); + if reference.language != Language::Php + || reference.reference_kind != EdgeKind::Calls + || name.is_empty() + || !name.bytes().all(is_word_byte) + { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + let index = usize::try_from(reference.line).ok()?.checked_sub(1)?; + let line = facts.raw_line(index)?; + let mut parent = false; + for (at, _) in line.match_indices(name) { + let after = line[at + name.len()..].trim_start(); + if !after.starts_with('(') { + continue; + } + let before = line[..at].trim_end(); + if let Some(arrow) = before.strip_suffix("->") { + let arrow = arrow.trim_end(); + let arrow = arrow.strip_suffix('?').map_or(arrow, str::trim_end); + if arrow.strip_suffix("$this").is_some() { + return Some(PhpVia::SelfCall); + } + continue; + } + let Some(path) = before.strip_suffix("::") else { + continue; + }; + let path = path.trim_end(); + let word_at = path + .bytes() + .rposition(|byte| !is_word_byte(byte)) + .map_or(0, |position| position + 1); + match &path[word_at..] { + "self" | "static" => return Some(PhpVia::SelfCall), + "parent" => parent = true, + _ => {} + } + } + parent.then_some(PhpVia::Parent) +} + +/// `^\s*` with JavaScript's whitespace, U+FEFF included. +const JS_SPACE: &str = r"[\s\x{FEFF}]"; + +/// A PHP file's scope, read once per pass. +pub(crate) fn php_file_scope(file: &str, context: &dyn ResolutionContext) -> Arc { + let tables = context.php_tables(); + if let Some(hit) = tables + .file_scopes + .lock() + .ok() + .and_then(|memo| memo.get(file).cloned()) + { + return hit; + } + let text = context + .source_facts(file) + .map(|facts| facts.source().to_string()) + .unwrap_or_default(); + let scope = Arc::new(read_file_scope(&text)); + if let Ok(mut memo) = tables.file_scopes.lock() { + memo.insert(file.to_string(), Arc::clone(&scope)); + } + scope +} + +fn read_file_scope(text: &str) -> PhpFileScope { + static NAMESPACE: OnceLock = OnceLock::new(); + static FIRST_TYPE: OnceLock = OnceLock::new(); + static USE: OnceLock = OnceLock::new(); + let namespace = NAMESPACE.get_or_init(|| { + Regex::new(&format!( + r"(?m)^{JS_SPACE}*namespace{JS_SPACE}+([\w\\]+){JS_SPACE}*[;{{]" + )) + .expect("php namespace") + }); + let first_type = FIRST_TYPE.get_or_init(|| { + Regex::new(&format!( + r"(?m)^{JS_SPACE}*(?:(?:abstract|final|readonly){JS_SPACE}+)*(?:class|trait|interface|enum){JS_SPACE}" + )) + .expect("php first type") + }); + let use_line = USE.get_or_init(|| { + Regex::new(&format!( + r"(?m)^{JS_SPACE}*use{JS_SPACE}+(?:function{JS_SPACE}+|const{JS_SPACE}+)?([\w\\]+)(?:{JS_SPACE}+as{JS_SPACE}+(\w+))?{JS_SPACE}*;" + )) + .expect("php use") + }); + let mut scope = PhpFileScope { + namespace: namespace + .captures(text) + .map(|caps| caps[1].to_string()) + .unwrap_or_default(), + uses: HashMap::new(), + }; + // File-level imports sit before the first type; a trait `use` inside a + // class body is not one. A type at the file's very start leaves the + // whole file, as upstream's `search(…) >>> 0 || length` does. + let end = match first_type.find(text) { + Some(found) if found.start() > 0 => found.start(), + _ => text.len(), + }; + for caps in use_line.captures_iter(&text[..end]) { + let fqn = caps[1].trim_start_matches('\\').to_string(); + let alias = caps.get(2).map_or_else( + || fqn.rsplit('\\').next().unwrap_or(&fqn).to_string(), + |alias| alias.as_str().to_string(), + ); + scope.uses.insert(alias, fqn); + } + scope +} + +/// The qualified name (`A\B::C`) the PHP class name `name` written in `file` +/// refers to (upstream `phpTypeQn`). +pub(crate) fn php_type_qn(name: &str, file: &str, context: &dyn ResolutionContext) -> String { + if name.starts_with('\\') { + return qualify(name, &PhpFileScope::default()); + } + qualify(name, &php_file_scope(file, context)) +} + +/// The qualified name `name`, written in a file whose scope is `scope`, +/// refers to. +fn qualify(name: &str, scope: &PhpFileScope) -> String { + let fqn = match name.strip_prefix('\\') { + Some(rooted) => rooted.to_string(), + None => { + let (head, rest) = match name.split_once('\\') { + Some((head, rest)) => (head, Some(rest)), + None => (name, None), + }; + match (scope.uses.get(head), rest) { + (Some(imported), Some(rest)) => format!("{imported}\\{rest}"), + (Some(imported), None) => imported.clone(), + (None, _) if !scope.namespace.is_empty() => format!("{}\\{name}", scope.namespace), + (None, _) => name.to_string(), + } + } + }; + match fqn.rsplit_once('\\') { + Some((namespace, last)) => format!("{namespace}::{last}"), + None => fqn, + } +} + +/// The qualified names a PHP class or trait extends and the traits it uses +/// (upstream `phpSupertypeQns`): the `extends` clause of the declaration's +/// first five lines, and every `use A, B;` in its body before its first +/// `function`. +pub(crate) fn php_supertype_qns(decl: &Node, context: &dyn ResolutionContext) -> Arc> { + let tables = context.php_tables(); + if let Some(hit) = tables + .supertypes + .lock() + .ok() + .and_then(|memo| memo.get(&decl.id).cloned()) + { + return hit; + } + let names = context + .source_facts(&decl.file_path) + .map(|facts| supertype_names(&facts, decl.start_line, decl.end_line)) + .unwrap_or_default(); + let qns = Arc::new( + names + .iter() + .map(|name| php_type_qn(name, &decl.file_path, context)) + .collect::>(), + ); + if let Ok(mut memo) = tables.supertypes.lock() { + memo.insert(decl.id.clone(), Arc::clone(&qns)); + } + qns +} + +/// The supertype names a declaration at 1-based `start_line ..= end_line` +/// writes, as written. +pub fn supertype_names( + facts: &crate::source_facts::SourceFacts, + start_line: i64, + end_line: i64, +) -> Vec { + static EXTENDS: OnceLock = OnceLock::new(); + static NAME: OnceLock = OnceLock::new(); + static BODY_USE: OnceLock = OnceLock::new(); + let extends = EXTENDS.get_or_init(|| { + Regex::new(r"\bextends[\s\x{FEFF}]+([^{]*?)(?:\bimplements\b|\{)").expect("php extends") + }); + let name = NAME.get_or_init(|| Regex::new(r"(\\?[A-Za-z_][\w\\]*)").expect("php name")); + let body_use = BODY_USE.get_or_init(|| { + Regex::new(&format!( + r"(?m)^{JS_SPACE}*use{JS_SPACE}+([\w\\,\s\x{{FEFF}}]+?){JS_SPACE}*[;{{]" + )) + .expect("php body use") + }); + let count = facts.line_count(); + let start = usize::try_from(start_line).unwrap_or(1).max(1); + let end = usize::try_from(end_line).unwrap_or(0); + let head_end = (start + 4).min(count); + let head = if start <= count { + facts.join_lines(start - 1, head_end, " ") + } else { + String::new() + }; + let mut names: Vec = extends + .captures(&head) + .map(|caps| { + name.captures_iter(&caps[1]) + .map(|m| m[1].to_string()) + .collect() + }) + .unwrap_or_default(); + // `use StringTranslationTrait, MessengerTrait;` at the top of the body, + // one per line or one list across several. + let body_end = end.min(start + 80).min(count); + if start < body_end { + let first_function = + (start..body_end).find(|&index| contains_word(facts.line(index), "function")); + let body = facts.join_lines(start, first_function.unwrap_or(body_end), "\n"); + for caps in body_use.captures_iter(&body) { + names.extend( + caps[1] + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + ); + } + } + names +} + +/// Whether `line` writes `word` as a whole word (`\bfunction\b`). +fn contains_word(line: &str, word: &str) -> bool { + let bytes = line.as_bytes(); + line.match_indices(word).any(|(at, _)| { + (at == 0 || !is_word_byte(bytes[at - 1])) + && !bytes + .get(at + word.len()) + .is_some_and(|byte| is_word_byte(*byte)) + }) +} + +/// Every type `start` reaches through `extends` and trait `use`, and whether +/// the walk left the repository (upstream `phpAncestry`, at most 80). The +/// declarations of one name are read in path order, so the limit cuts the +/// same names on every index (upstream reads them in index order; KEEP-RUST, +/// as the Ruby walk). +pub(crate) fn php_ancestry(start: &[String], context: &dyn ResolutionContext) -> Arc { + let tables = context.php_tables(); + if let Some(hit) = tables + .ancestries + .lock() + .ok() + .and_then(|memo| memo.get(start).cloned()) + { + return hit; + } + let mut seen: BTreeSet = BTreeSet::new(); + let mut queue: VecDeque = start.iter().cloned().collect(); + let mut leaves_repo = false; + while seen.len() < ANCESTRY_LIMIT { + let Some(current) = queue.pop_front() else { + break; + }; + if !seen.insert(current.clone()) { + continue; + } + let mut declarations: Vec> = context + .get_nodes_by_qualified_name_shared(¤t) + .into_iter() + .filter(|node| is_php_type(node)) + .collect(); + if declarations.is_empty() { + leaves_repo = true; + } + declarations.sort_by(|a, b| { + (&a.file_path, a.start_line, a.start_column, &a.id).cmp(&( + &b.file_path, + b.start_line, + b.start_column, + &b.id, + )) + }); + for declaration in &declarations { + queue.extend(php_supertype_qns(declaration, context).iter().cloned()); + } + } + let walk = Arc::new(PhpAncestry { + types: seen, + leaves_repo, + }); + if let Ok(mut memo) = tables.ancestries.lock() { + memo.insert(start.to_vec(), Arc::clone(&walk)); + } + walk +} + +/// The innermost PHP type around `reference`'s line, read once per pass. +fn enclosing_php_type(reference: &RefView, context: &dyn ResolutionContext) -> Option> { + let tables = context.php_tables(); + let key = (reference.file_path.clone(), reference.line); + if let Some(hit) = tables + .enclosing + .lock() + .ok() + .and_then(|memo| memo.get(&key).cloned()) + { + return hit; + } + let enclosing = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|node| { + is_php_type(node) + && node.start_line <= reference.line + && node.end_line >= reference.line + }) + .max_by(|a, b| { + a.start_line + .cmp(&b.start_line) + .then_with(|| b.id.cmp(&a.id)) + }); + if let Ok(mut memo) = tables.enclosing.lock() { + memo.insert(key, enclosing.clone()); + } + enclosing +} + +/// What a bare PHP call through `$this`, `self`, `static` or `parent` can +/// reach, read once per reference (upstream `isPhpMethodInScope`'s caller +/// side). +pub(crate) struct PhpSelfScope { + via: PhpVia, + /// The innermost type around the call and the ancestry the call walks; + /// `None` outside any type or inside a trait, where `$this` is whichever + /// class uses it and nothing is judged. + caller: Option<(Arc, Arc)>, +} + +/// The scope of `reference` when it is written through `$this`, `self`, +/// `static` or `parent`. +pub(crate) fn php_self_scope( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let via = php_self_receiver(reference, context)?; + let caller = enclosing_php_type(reference, context) + .filter(|node| node.kind != NodeKind::Trait) + .map(|enclosing| { + let start = match via { + PhpVia::Parent => php_supertype_qns(&enclosing, context).as_ref().clone(), + PhpVia::SelfCall => vec![enclosing.qualified_name.clone()], + }; + let up = php_ancestry(&start, context); + (enclosing, up) + }); + Some(PhpSelfScope { via, caller }) +} + +impl PhpSelfScope { + /// Whether `method` can be what the call means: a method of the + /// enclosing class, one it extends or a trait any of them uses, or — + /// through `$this` — of a subclass; past an ancestor outside the + /// repository, a repository trait's. + pub(crate) fn admits(&self, method: &Node, context: &dyn ResolutionContext) -> bool { + let Some((owner, _)) = method.qualified_name.rsplit_once("::") else { + return true; + }; + let Some((enclosing, up)) = &self.caller else { + return true; + }; + if up.types.contains(owner) { + return true; + } + // Past an ancestor outside the repository its members are unseen, the + // repository's traits it uses among them: a trait's method may still + // be the one meant, an unrelated class's never is. + if up.leaves_repo && is_php_trait(owner, context) { + return true; + } + // A base class may call what a subclass defines: the owner descends + // from the caller. + self.via == PhpVia::SelfCall + && php_ancestry(&[owner.to_string()], context) + .types + .contains(&enclosing.qualified_name) + } +} + +/// Whether the repository declares a trait named `qualified_name`, read once +/// per pass. +fn is_php_trait(qualified_name: &str, context: &dyn ResolutionContext) -> bool { + let tables = context.php_tables(); + if let Some(hit) = tables + .traits + .lock() + .ok() + .and_then(|memo| memo.get(qualified_name).copied()) + { + return hit; + } + let is_trait = context + .get_nodes_by_qualified_name_shared(qualified_name) + .iter() + .any(|node| node.kind == NodeKind::Trait); + if let Ok(mut memo) = tables.traits.lock() { + memo.insert(qualified_name.to_string(), is_trait); + } + is_trait +} + +/// The class names a PHP receiver is read as named after (upstream +/// `phpReceiverReaches`): its last link without the `$`, and the last word of +/// that, each capitalized (`$pageRepo` → `PageRepo`, `Repo`). +pub fn receiver_class_names(receiver: &str) -> Vec { + let last = receiver + .rsplit('.') + .next() + .unwrap_or(receiver) + .trim_start_matches('$'); + if last.is_empty() { + return Vec::new(); + } + let capitalize = |word: &str| { + let mut chars = word.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().chain(chars).collect::() + }) + }; + let mut names: Vec = vec![capitalize(last)]; + if let Some(word) = crate::name_matcher::split_camel_case(last).last() { + names.push(capitalize(word)); + } + names.dedup(); + names +} + +/// Whether a PHP receiver is named after a class that has `method` in its +/// ancestry (upstream `phpReceiverReaches`, #2152): `$page->save()` → Page, +/// which extends Entity. +pub(crate) fn php_receiver_reaches( + receiver: &str, + method: &Node, + context: &dyn ResolutionContext, +) -> bool { + let Some((owner, _)) = method.qualified_name.rsplit_once("::") else { + return false; + }; + receiver_class_names(receiver).iter().any(|name| { + context + .get_nodes_by_name_shared(name) + .iter() + .filter(|node| is_php_type(node)) + .any(|decl| { + php_ancestry(std::slice::from_ref(&decl.qualified_name), context) + .types + .contains(owner) + }) + }) +} + +/// The PHP reference kinds whose name is a class name (upstream +/// `PHP_CLASS_NAME_REFS`): what the extractor records of them is the written +/// name's last segment (`new Field\FirstName()` is `FirstName`), at the +/// name's column, or at `new` for an instantiation. +pub(crate) fn is_php_class_name_ref(kind: EdgeKind) -> bool { + matches!( + kind, + EdgeKind::Instantiates | EdgeKind::Extends | EdgeKind::Implements | EdgeKind::References + ) +} + +/// The class name a PHP class reference writes at its position, qualified +/// as in the source (`Field\FirstName`, `\Vendor\Lib\X`, or the bare name): +/// at its column, past `new` and any whitespace or comment after it for an +/// instantiation, and the entry of a trait `use A, B\C;` list (recorded at +/// the `use`, by its written name) that is the recorded name. KEEP-RUST: +/// upstream's extractor records the written name, this one the last segment +/// for every other class name ref, so the rules that read the qualification +/// read it here; `None` when the text there is neither the recorded name nor +/// ends in it. +pub(crate) fn php_written_class_name( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + if reference.language != Language::Php || !is_php_class_name_ref(reference.reference_kind) { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + let index = usize::try_from(reference.line).ok()?.checked_sub(1)?; + let at = facts + .lines() + .raw_line_start(index)? + .checked_add(usize::try_from(reference.column).ok()?)?; + let mut text = facts.source().get(at..)?; + let name = reference.reference_name.as_str(); + let is_named = |written: &str| written == name || written.rsplit('\\').next() == Some(name); + if reference.reference_kind == EdgeKind::Instantiates + && let Some(rest) = strip_php_keyword(text, "new") + { + text = skip_php_trivia(rest); + } else if reference.reference_kind == EdgeKind::Implements + && let Some(rest) = strip_php_keyword(text, "use") + { + let mut rest = skip_php_trivia(rest); + loop { + let written = php_name_at(rest); + if written.is_empty() { + return None; + } + if is_named(written) { + return Some(written.to_string()); + } + rest = skip_php_trivia(&rest[written.len()..]); + rest = skip_php_trivia(rest.strip_prefix(',')?); + } + } + let written = php_name_at(text); + is_named(written).then(|| written.to_string()) +} + +/// `text` past the PHP keyword `keyword` it starts with, in any case, when +/// no word character follows it (a `\` may: `new\Foo()`). +fn strip_php_keyword<'t>(text: &'t str, keyword: &str) -> Option<&'t str> { + let head = text.get(..keyword.len())?; + let rest = &text[keyword.len()..]; + let word = |c: char| c.is_ascii_alphanumeric() || c == '_' || !c.is_ascii(); + (head.eq_ignore_ascii_case(keyword) && !rest.starts_with(word)).then_some(rest) +} + +/// `text` past its leading whitespace and comments (`/* … */`, `// …`, +/// `# …` but not a `#[` attribute), line breaks included. +fn skip_php_trivia(mut text: &str) -> &str { + loop { + text = text.trim_start_matches(is_js_whitespace); + if let Some(rest) = text.strip_prefix("/*") { + text = rest.find("*/").map_or("", |end| &rest[end + 2..]); + } else if text.starts_with("//") || (text.starts_with('#') && !text.starts_with("#[")) { + text = text.find('\n').map_or("", |end| &text[end + 1..]); + } else { + return text; + } + } +} + +/// Whether `c` can be part of a PHP class name as written: a word +/// character, a namespace separator or any non-ASCII character. +fn is_php_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '\\' || !c.is_ascii() +} + +/// The class name written at the start of `text`, possibly empty. +fn php_name_at(text: &str) -> &str { + &text[..text + .find(|c: char| !is_php_name_char(c)) + .unwrap_or(text.len())] +} + +/// Whether a bare PHP class name at `reference` can mean `candidate` +/// (upstream `isPhpClassVisible`, #2235): an unqualified class name is the +/// current namespace's class or the one a `use` imports; PHP never falls +/// back to another namespace for classes. koel's `extends Request` went to +/// the first `Request` indexed. KEEP-RUST: the extractor records a class +/// name ref by its last segment, so its written name is read back +/// ([`php_written_class_name`]). A name written with a namespace means only +/// what PHP reads it as, whatever the candidate's kind (upstream's ref keeps +/// the written name, which no other node's name matches); a name that cannot +/// be read back reaches no class, since its namespace is unknown. +pub(crate) fn is_php_class_visible( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if reference.language != Language::Php { + return true; + } + let name = reference.reference_name.as_str(); + let keyword = ["self", "static", "parent"] + .iter() + .any(|keyword| name.eq_ignore_ascii_case(keyword)); + if is_php_class_name_ref(reference.reference_kind) && !keyword { + match php_written_class_name(reference, context) { + Some(written) if written.contains('\\') => { + return candidate.language == Language::Php + && candidate.qualified_name + == crate::import_resolver::php_qualified_class_qn( + &written, reference, context, + ); + } + Some(_) => {} + None => return !is_php_type(candidate), + } + } + if candidate.language != Language::Php || !is_php_type(candidate) { + return true; + } + let simple = name + .bytes() + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_') + && name.bytes().all(is_word_byte); + if !simple || keyword { + return true; + } + let fqn = candidate.qualified_name.replace("::", "\\").to_lowercase(); + let scope = php_file_scope(&reference.file_path, context); + if let Some(imported) = scope.uses.get(name) { + return imported.to_lowercase() == fqn; + } + let expected = if scope.namespace.is_empty() { + name.to_string() + } else { + format!("{}\\{name}", scope.namespace) + }; + expected.to_lowercase() == fqn +} + +/// Whether a PHP `calls` ref is a bare function call, `redirect($url)` or +/// `view('books.show')`, rather than `$this->redirect()`, `$obj->view()` or +/// `Foo::view()` (upstream `isBarePhpCall`, #2103, #2141): PHP has no +/// implicit `$this`, so a call written without a receiver can only be a +/// function. A PHP ref records its call expression's column +/// (`$this->setPageTitle(` sits at `$this`), so a bare call is one whose +/// text at its column is the name itself, a fully qualified function's +/// leading `\` allowed; a `->`, `?->` or `::` just before it is a member +/// call, a `=>` is not (`'size' => filesize($zip)`). +pub(crate) fn is_bare_php_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { + if reference.language != Language::Php || reference.reference_kind != EdgeKind::Calls { + return false; + } + // KEEP-RUST: the extractor keeps a fully qualified function's `\` in the + // name (`\strlen`); upstream's records `strlen`. + let name = reference + .reference_name + .strip_prefix('\\') + .unwrap_or(&reference.reference_name); + if name.is_empty() || !name.bytes().all(is_word_byte) { + return false; + } + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let Some(line) = usize::try_from(reference.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .and_then(|index| facts.raw_line(index)) + else { + return false; + }; + let Ok(column) = usize::try_from(reference.column) else { + return false; + }; + let at = if line.as_bytes().get(column) == Some(&b'\\') { + column + 1 + } else { + column + }; + let Some(after) = line.get(at..).and_then(|text| text.strip_prefix(name)) else { + return false; + }; + // `\s*(?:\?\.\s*)?[(<]` (upstream `CALL_OPENER`). + let after = after.trim_start_matches(is_js_whitespace); + let after = after + .strip_prefix("?.") + .map_or(after, |rest| rest.trim_start_matches(is_js_whitespace)); + if !after.starts_with(['(', '<']) { + return false; + } + let before = line[..at].trim_end_matches(is_js_whitespace); + !(before.ends_with("->") || before.ends_with("::")) +} + +/// Key prefix of the PHP head rows (W4-PHP-01): one `project_metadata` row +/// per PHP file declaring a class, trait, interface or enum, written by every +/// full index and migration and kept current by sync. +pub const HEAD_ROW_KEY_PREFIX: &str = "php_heads:"; + +/// The `project_metadata` key of `file_path`'s head row. +pub fn head_row_key(file_path: &str) -> String { + format!("{HEAD_ROW_KEY_PREFIX}{file_path}") +} + +/// One PHP type as the ancestry walk reads it: its kind (a trait's `$this` +/// is whichever class uses it, and only a trait is reached past an ancestor +/// outside the repository) and the qualified names it extends and uses, +/// resolved through its own file's scope. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct HeadEntry { + pub qualified_name: String, + pub kind: String, + pub supers: Vec, +} + +/// A PHP file's head row: every type it declares, sorted (positions are not +/// part of it, so an edit above a class changes nothing). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeadRow { + pub entries: Vec, +} + +impl HeadRow { + /// The row of a file whose text is `source` and whose nodes are `nodes`: + /// what [`php_supertype_qns`] reads of each of its types. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let facts = crate::source_facts::SourceFacts::new(Arc::from(source)); + let scope = read_file_scope(source); + let mut entries: Vec = nodes + .iter() + .filter(|node| is_php_type(node)) + .map(|node| HeadEntry { + qualified_name: node.qualified_name.clone(), + kind: node.kind.as_str().to_string(), + supers: supertype_names(&facts, node.start_line, node.end_line) + .iter() + .map(|name| qualify(name, &scope)) + .collect(), + }) + .collect(); + entries.sort(); + Self { entries } + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("php head row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One PHP file, `a.php`, and nothing else. + struct OneFile(&'static str); + + impl ResolutionContext for OneFile { + fn get_nodes_in_file(&self, _file_path: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_name(&self, _name: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_qualified_name(&self, _qualified_name: &str) -> Vec { + Vec::new() + } + fn get_nodes_by_kind(&self, _kind: NodeKind) -> Vec { + Vec::new() + } + fn file_exists(&self, file_path: &str) -> bool { + file_path == "a.php" + } + fn read_file(&self, file_path: &str) -> Option { + (file_path == "a.php").then(|| self.0.to_string()) + } + fn get_project_root(&self) -> &str { + "/proj" + } + fn get_all_files(&self) -> Vec { + vec!["a.php".to_string()] + } + fn get_nodes_by_lower_name(&self, _lower_name: &str) -> Vec { + Vec::new() + } + fn get_node_by_id(&self, _id: &str) -> Option { + None + } + fn get_import_mappings( + &self, + _file_path: &str, + _language: Language, + ) -> Vec { + Vec::new() + } + } + + /// A PHP ref of `kind` named `name` in `a.php` at 1-based `line` and + /// zero-based byte `column`. + fn php_ref(kind: EdgeKind, name: &str, line: i64, column: i64) -> RefView { + RefView { + row_id: None, + from_node_id: "method:x".to_string(), + reference_name: name.to_string(), + reference_kind: kind, + line, + column, + file_path: "a.php".to_string(), + language: Language::Php, + is_function_ref: false, + reference_subkind: None, + } + } + + #[test] + fn a_bare_php_call_is_written_without_a_receiver() { + let context = OneFile( + "redirect();\n$a = ['total' => count($items)];\nFoo::view();\n\\strlen('x');\n$x?->user ();\n$y = user ();\n", + ); + let call = |name: &str, line: i64, column: i64| { + is_bare_php_call(&php_ref(EdgeKind::Calls, name, line, column), &context) + }; + assert!(call("redirect", 2, 7)); + assert!( + !call("redirect", 3, 0), + "`$this->redirect()` sits at `$this`" + ); + assert!(!call("redirect", 3, 7), "a column on the name after `->`"); + assert!(call("count", 4, 17), "`=>` is no member arrow"); + assert!(!call("view", 5, 5), "a column on the name after `::`"); + assert!(call("\\strlen", 6, 0), "a fully qualified function"); + assert!(!call("user", 7, 5), "a column on the name after `?->`"); + assert!(call("user", 8, 5), "whitespace before the parenthesis"); + assert!( + !call("user", 8, 0), + "the text at the column is not the name" + ); + } + + /// A PHP node of `kind` named after the last segment of `qualified_name`. + fn php_node(kind: NodeKind, qualified_name: &str) -> Node { + Node { + id: format!("{}:{qualified_name}", kind.as_str()), + kind, + name: qualified_name + .rsplit("::") + .next() + .unwrap_or(qualified_name) + .to_string(), + qualified_name: qualified_name.to_string(), + file_path: "b.php".to_string(), + language: Language::Php, + start_line: 1, + end_line: 1, + start_column: 0, + end_column: 0, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + } + } + + #[test] + fn a_php_class_name_that_cannot_be_read_back_reaches_no_class() { + let context = OneFile(", + value: String, + }, + /// An import of the name, keyed by where it comes from (`a:x` for `from + /// a import x`, `a.b:*` for `import a.b as x`). + Import(String), + /// Any other binding: a tuple target, a loop, `with … as`, a lambda, … + Other, +} + +/// What one pass over a Python file's code finds for every name. +#[derive(Debug)] +pub(crate) struct PythonFileScan { + /// Per line: does it start a statement (bracket depth 0, no `\` + /// continuation)? + statements: Vec, + /// Per line: inside a top-level `if __name__ == "__main__":` block, which + /// is script code, not module state. + script: Vec, +} + +/// One file's answers for one name. +#[derive(Debug, Default)] +pub(crate) struct NameScan { + import_keys: OnceLock>, + global_bindings: OnceLock>, +} + +/// One module global's answers. +#[derive(Debug, Default)] +pub(crate) struct GlobalMemo { + own_writes: OnceLock>>, + external: OnceLock, + classes: OnceLock>>>, +} + +/// A class a write assigns to a global, as spelled in the file that wrote it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct GlobalWrite { + type_name: String, + file: String, +} + +/// What other files write to a module global. +#[derive(Debug, Default)] +struct ExternalWrites { + writes: Vec, + /// A production write that is not a constructor call, or that may land + /// on another module. + unknown: bool, + /// Test files that install a double: a reference in one of them + /// resolves nothing through the global. + writers: BTreeSet, +} + +/// Python files by the words they spell after `.`, `'` or `"` (upstream +/// #2332 `pythonWriteCandidates`), so that a global's write scan reads only +/// the files that can write it. +#[derive(Debug, Default)] +pub struct PythonWriteIndex { + files: Vec, + spelled: HashMap>, + /// The files that write through `__dict__`, whose statements may spell + /// a name anywhere: every word they hold. + dict: Vec<(usize, Vec)>, +} + +impl PythonWriteIndex { + /// Index every Python file of the project once. + pub fn build(context: &C) -> Self { + static SPELLED: OnceLock = OnceLock::new(); + static WORD: OnceLock = OnceLock::new(); + let spelled_pattern = + SPELLED.get_or_init(|| Regex::new(r#"[.'"]([0-9A-Za-z_]+)"#).expect("spelled word")); + let word_pattern = WORD.get_or_init(|| Regex::new(r"[0-9A-Za-z_]+").expect("word")); + let mut files: Vec = context + .get_all_files_shared() + .iter() + .filter(|file| file.ends_with(".py") || file.ends_with(".pyi")) + .cloned() + .collect(); + files.sort(); + let mut spelled: HashMap> = HashMap::new(); + let mut dict = Vec::new(); + for (at, file) in files.iter().enumerate() { + let source = context + .read_file_shared(file) + .unwrap_or_else(|| Arc::from("")); + let names: BTreeSet<&str> = spelled_pattern + .captures_iter(&source) + .filter_map(|captures| captures.get(1)) + .map(|word| word.as_str()) + .collect(); + if names.contains("__dict__") { + let words: BTreeSet<&str> = word_pattern + .find_iter(&source) + .map(|word| word.as_str()) + .collect(); + dict.push((at, words.into_iter().map(str::to_string).collect())); + } + for name in names { + spelled.entry(name.to_string()).or_default().push(at); + } + } + Self { + files, + spelled, + dict, + } + } + + /// The files that can write a module global named `name`, in path + /// order. + fn candidates(&self, name: &str, context: &dyn ResolutionContext) -> Vec { + if !is_ascii_word(name) { + return self + .files + .iter() + .filter(|file| { + context + .read_file_shared(file) + .is_some_and(|text| text.contains(name)) + }) + .cloned() + .collect(); + } + let mut hits: BTreeSet = self + .spelled + .get(name) + .into_iter() + .flatten() + .copied() + .collect(); + // A word-only name is in the text exactly when it is in one of its + // words. + for (at, words) in &self.dict { + if words.iter().any(|word| word.contains(name)) { + hits.insert(*at); + } + } + hits.into_iter().map(|at| self.files[at].clone()).collect() + } +} + +fn is_ascii_word(text: &str) -> bool { + !text.is_empty() && text.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +fn is_word_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +/// A file's comment-free code by line (upstream `pythonMemberLines`). +struct Lines<'a> { + code: &'a str, + index: &'a LineIndex, +} + +impl<'a> Lines<'a> { + fn of(facts: &'a SourceFacts) -> Self { + let (code, index) = facts.python_comment_free(); + Self { code, index } + } + + fn len(&self) -> usize { + self.index.line_count() + } + + fn get(&self, index: usize) -> &'a str { + if index < self.len() { + self.index.line(self.code, index) + } else { + "" + } + } +} + +fn file_scan(facts: &SourceFacts) -> Arc { + facts.python_file_scan(|facts| { + let lines = Lines::of(facts); + PythonFileScan { + statements: statement_starts(&lines), + script: main_block_lines(&lines), + } + }) +} + +/// Per line: does it start a statement? String contents are skipped. +fn statement_starts(lines: &Lines<'_>) -> Vec { + let mut starts = Vec::with_capacity(lines.len()); + let mut depth = 0usize; + let mut continued = false; + for index in 0..lines.len() { + let line = lines.get(index).as_bytes(); + starts.push(depth == 0 && !continued); + let mut quote = 0u8; + let mut at = 0; + while at < line.len() { + let c = line[at]; + if quote != 0 { + if c == b'\\' { + at += 1; + } else if c == quote { + quote = 0; + } + } else if c == b'"' || c == b'\'' { + quote = c; + } else if matches!(c, b'(' | b'[' | b'{') { + depth += 1; + } else if matches!(c, b')' | b']' | b'}') { + depth = depth.saturating_sub(1); + } + at += 1; + } + continued = lines.get(index).trim_end().ends_with('\\'); + } + starts +} + +/// Per line: inside a top-level `if __name__ == "__main__":` block (the +/// guard line too when its body follows on it). +fn main_block_lines(lines: &Lines<'_>) -> Vec { + static GUARD: OnceLock = OnceLock::new(); + let guard = GUARD.get_or_init(|| { + Regex::new( + r#"^if\s+(?:__name__\s*==\s*(?:'__main__'|"__main__")|(?:'__main__'|"__main__")\s*==\s*__name__)\s*:"#, + ) + .expect("main guard") + }); + let mut inside = vec![false; lines.len()]; + for index in 0..lines.len() { + let line = lines.get(index); + let Some(found) = guard.find(line) else { + continue; + }; + if !line[found.end()..].trim().is_empty() { + inside[index] = true; + } + let mut next = index + 1; + while next < lines.len() + && !lines + .get(next) + .chars() + .next() + .is_some_and(|c| !c.is_whitespace()) + { + inside[next] = true; + next += 1; + } + } + inside +} + +/// The statement starting at line `index`, with its continuation lines. +fn statement_at(lines: &Lines<'_>, scan: &PythonFileScan, index: usize) -> String { + let mut text = lines.get(index).to_string(); + if scan.statements.get(index).copied().unwrap_or(false) { + let mut next = index + 1; + while next < lines.len() && !scan.statements.get(next).copied().unwrap_or(true) { + text.push('\n'); + text.push_str(lines.get(next)); + next += 1; + } + } + text +} + +/// Line indexes that belong to `scope` itself (`None`: the module), not to a +/// def or class nested in it. +fn own_lines( + scope: Option<&Node>, + file: &str, + line_count: usize, + context: &dyn ResolutionContext, +) -> Vec { + let from = scope.map_or(1, |scope| scope.start_line.max(1) as usize); + let to = scope.map_or(line_count, |scope| { + (scope.end_line.max(0) as usize).min(line_count) + }); + if to < from { + return Vec::new(); + } + let mut nested = vec![false; to - from + 1]; + for node in context.get_nodes_in_file_shared(file) { + if !matches!( + node.kind, + NodeKind::Function | NodeKind::Method | NodeKind::Class + ) || scope.is_some_and(|scope| scope.id == node.id) + { + continue; + } + let (start, end) = ( + node.start_line.max(0) as usize, + node.end_line.max(0) as usize, + ); + if start < from + || end > to + || scope.is_some_and(|scope| node.start_line <= scope.start_line) + { + continue; + } + for slot in &mut nested[start - from..=end - from] { + *slot = true; + } + } + (from..=to) + .filter(|line| !nested[line - from]) + .map(|line| line - 1) + .collect() +} + +/// `a = b = value` split at its top-level assignment operators. +struct Assignment { + targets: Vec, + value: String, + augmented: bool, +} + +/// The assignment `line` makes, or `None` when it assigns nothing. +fn assignment(line: &str) -> Option { + let bytes = line.as_bytes(); + let mut targets = Vec::new(); + let mut depth = 0i64; + let mut quote = 0u8; + let mut start = 0usize; + let mut augmented = false; + let mut at = 0usize; + while at < bytes.len() { + let c = bytes[at]; + if quote != 0 { + if c == b'\\' { + at += 1; + } else if c == quote { + quote = 0; + } + at += 1; + continue; + } + match c { + b'"' | b'\'' => quote = c, + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth -= 1, + b'=' if depth == 0 => { + let prev = at.checked_sub(1).map(|i| bytes[i]); + if bytes.get(at + 1) == Some(&b'=') { + // `==` + at += 2; + continue; + } + // `!=` and the walrus; `<=` and `>=` (but `<<=`, `>>=` + // assign). + if matches!(prev, Some(b'!' | b':')) + || (matches!(prev, Some(b'<' | b'>')) + && at.checked_sub(2).map(|i| bytes[i]) != prev) + { + at += 1; + continue; + } + let head = &line[start..at]; + let op = ["//", "**", ">>", "<<"] + .into_iter() + .find(|op| head.ends_with(op)) + .map(str::len) + .or_else(|| { + head.ends_with(['-', '+', '*', '/', '%', '&', '|', '^', '@']) + .then_some(1) + }); + if op.is_some() { + augmented = true; + } + targets.push(head[..head.len() - op.unwrap_or(0)].to_string()); + start = at + 1; + } + _ => {} + } + at += 1; + } + (!targets.is_empty()).then(|| Assignment { + targets, + value: line[start..].to_string(), + augmented, + }) +} + +/// Whether `text` holds `name` as a binding target: not after a word +/// character or `.`, and not followed by `.` or `[` (upstream +/// `(? bool { + let bytes = text.as_bytes(); + text.match_indices(name).any(|(at, _)| { + let end = at + name.len(); + at.checked_sub(1) + .is_none_or(|before| !is_word_byte(bytes[before]) && bytes[before] != b'.') + && bytes.get(end).is_none_or(|&after| !is_word_byte(after)) + && !text[end..].trim_start().starts_with(['.', '[']) + }) +} + +/// The patterns that read how a statement binds one name. +struct NamePatterns<'n> { + name: &'n str, + annotated_target: Regex, + annotated_line: Regex, + walrus_or_as: Regex, +} + +impl<'n> NamePatterns<'n> { + fn new(name: &'n str) -> Option { + let n = regex::escape(name); + Some(Self { + name, + annotated_target: Regex::new(&format!(r#"^{n}\s*:\s*["']?([0-9A-Za-z_.]+)["']?$"#)) + .ok()?, + annotated_line: Regex::new(&format!(r#"^\s*{n}\s*:\s*["']?([0-9A-Za-z_.]+)["']?\s*$"#)) + .ok()?, + walrus_or_as: Regex::new(&format!(r"(?-u:\b){n}[ \t]*:=|(?-u:\b)as[ \t]+{n}(?-u:\b)")) + .ok()?, + }) + } +} + +/// Every way the statement starting at line `index` binds the name, read +/// across its continuation lines. Only statement lines can assign or +/// import; any line can bind through `as`, a loop, a lambda or the walrus +/// (upstream `pythonLineBindings`). +fn line_bindings( + lines: &Lines<'_>, + scan: &PythonFileScan, + index: usize, + patterns: &NamePatterns<'_>, +) -> Vec { + static IMPORT: OnceLock = OnceLock::new(); + static PART: OnceLock = OnceLock::new(); + static DECLARED: OnceLock = OnceLock::new(); + static CASE: OnceLock = OnceLock::new(); + static DEL: OnceLock = OnceLock::new(); + static LOOP: OnceLock = OnceLock::new(); + static LAMBDA: OnceLock = OnceLock::new(); + let name = patterns.name; + let statement = scan.statements.get(index).copied().unwrap_or(false); + let line = statement_at(lines, scan, index); + let mut out = Vec::new(); + // An import statement can name the name on a continuation line: `from x + // import (\n name,\n)`. + let import = IMPORT.get_or_init(|| { + Regex::new(r"(?s)^\s*(?:from\s+([0-9A-Za-z_.]+)\s+)?import\s+(.*)$") + .expect("python import statement") + }); + if statement && let Some(imported) = import.captures(&line) { + let names = imported.get(2).map_or("", |names| names.as_str()); + let from = imported.get(1).map(|from| from.as_str()); + // `from x import *` can bind any name. + if from.is_some() && names.trim() == "*" { + return vec![Binding::Other]; + } + if !names.contains(name) { + return out; + } + let part = PART.get_or_init(|| { + Regex::new(r"^([0-9A-Za-z_.]+)(?:\s+as\s+([0-9A-Za-z_]+))?$").expect("import part") + }); + let cleaned = names.replace(['(', ')', '\\'], " "); + for piece in cleaned.split(',') { + let Some(found) = part.captures(piece.trim()) else { + continue; + }; + let module = found.get(1).map_or("", |m| m.as_str()); + let alias = found.get(2).map(|alias| alias.as_str()); + let local = alias.unwrap_or_else(|| { + if from.is_some() { + module + } else { + module.split('.').next().unwrap_or(module) + } + }); + if local != name { + continue; + } + out.push(Binding::Import(match from { + Some(from) => format!("{from}:{module}"), + None if alias.is_some() => format!("{module}:*"), + None => format!("{local}:*"), + })); + } + return out; + } + if !line.contains(name) || !has_word(&line, name) { + return out; + } + let declared = DECLARED.get_or_init(|| { + Regex::new(r"^\s*(global|nonlocal)\s+([0-9A-Za-z_\s,]+)$").expect("global declaration") + }); + if let Some(found) = declared.captures(&line) { + let names = found.get(2).map_or("", |names| names.as_str()); + if !names.split(',').any(|declared| declared.trim() == name) { + return Vec::new(); + } + return vec![if &found[1] == "global" { + Binding::Global + } else { + Binding::Other + }]; + } + if statement { + // A `case` pattern binds its capture names (`case [name]:`, `case + // Cls(k=name):`, `case name:`). + if CASE + .get_or_init(|| Regex::new(r"^\s*case(?-u:\b)").expect("case")) + .is_match(&line) + { + return vec![Binding::Other]; + } + if let Some(assigned) = assignment(&line) { + let target = if assigned.targets.len() == 1 && !assigned.augmented { + assigned.targets[0].trim() + } else { + "" + }; + let annotated = patterns.annotated_target.captures(target); + if target == name || annotated.is_some() { + out.push(Binding::Assign { + annotation: annotated + .and_then(|found| found.get(1)) + .map(|found| found.as_str().to_string()), + value: assigned.value.trim().to_string(), + }); + } else if assigned.targets.iter().any(|target| has_word(target, name)) { + out.push(Binding::Other); + } + } else if let Some(annotation) = patterns + .annotated_line + .captures(&line) + .and_then(|found| found.get(1)) + { + out.push(Binding::Assign { + annotation: Some(annotation.as_str().to_string()), + value: String::new(), + }); + } else if DEL + .get_or_init(|| Regex::new(r"^\s*del(?-u:\b)").expect("del")) + .is_match(&line) + { + out.push(Binding::Other); + } + } + if patterns.walrus_or_as.is_match(&line) { + out.push(Binding::Other); + } + let for_loop = LOOP + .get_or_init(|| Regex::new(r"(?-u:\b)for\s+([^:]+?)\s+in(?-u:\b)").expect("for target")); + for found in for_loop.captures_iter(&line) { + if has_word(&found[1], name) { + out.push(Binding::Other); + } + } + let lambda = + LAMBDA.get_or_init(|| Regex::new(r"(?-u:\b)lambda(?-u:\b)([^:]*):").expect("lambda")); + for found in lambda.captures_iter(&line) { + if has_word(&found[1], name) { + out.push(Binding::Other); + } + } + out +} + +/// Whether `name`, read at the reference, is bound by the calling function +/// or one around it (a parameter, an assignment, a loop, `as`, a lambda, an +/// import) rather than being the module global. With `imports_bind` false, +/// an import of the name is no shadow: it binds the module the file imports +/// (upstream `pythonBindsLocally`). +pub(crate) fn binds_locally( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, + imports_bind: bool, +) -> bool { + static DEF: OnceLock = OnceLock::new(); + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let (Some(patterns), Ok(param)) = ( + NamePatterns::new(name), + Regex::new(&format!( + r"[(,]\s*\*{{0,2}}{}\s*[:=,)]", + regex::escape(name) + )), + ) else { + return false; + }; + let def = DEF.get_or_init(|| Regex::new(r"^\s*(?:async\s+)?def(?-u:\b)").expect("def")); + let lines = Lines::of(&facts); + let scan = file_scan(&facts); + let mut scopes: Vec> = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|node| { + matches!(node.kind, NodeKind::Function | NodeKind::Method) + && node.start_line <= reference.line + && node.end_line >= reference.line + }) + .collect(); + scopes.sort_by(|a, b| { + b.start_line + .cmp(&a.start_line) + .then_with(|| a.id.cmp(&b.id)) + }); + for scope in scopes { + let own = own_lines( + Some(scope.as_ref()), + &reference.file_path, + lines.len(), + context, + ); + let bindings: Vec = own + .iter() + .flat_map(|&index| line_bindings(&lines, &scan, index, &patterns)) + .collect(); + if bindings.contains(&Binding::Global) { + return false; + } + let def_line = own + .iter() + .copied() + .find(|&index| def.is_match(lines.get(index))); + let mut header = scope.signature.clone().unwrap_or_default(); + if let Some(def_line) = def_line { + let mut index = def_line; + while index < lines.len() + && (index == def_line || !scan.statements.get(index).copied().unwrap_or(true)) + { + header.push_str(lines.get(index)); + index += 1; + } + } + if param.is_match(&header) { + return true; + } + if bindings.iter().any(|binding| { + !matches!(binding, Binding::Global) + && (!matches!(binding, Binding::Import(_)) || imports_bind) + }) { + return true; + } + } + false +} + +/// The distinct sources `file` imports `name` from, at any scope (`from a +/// import x` is `a:x`). +fn import_keys(name: &str, file: &str, context: &dyn ResolutionContext) -> BTreeSet { + static IMPORT_WORD: OnceLock = OnceLock::new(); + let Some(facts) = context.source_facts(file) else { + return BTreeSet::new(); + }; + facts + .python_name_scan(name) + .import_keys + .get_or_init(|| { + let Some(patterns) = NamePatterns::new(name) else { + return BTreeSet::new(); + }; + let import_word = IMPORT_WORD + .get_or_init(|| Regex::new(r"(?-u:\b)import(?-u:\b)").expect("import word")); + let lines = Lines::of(&facts); + let scan = file_scan(&facts); + let mut keys = BTreeSet::new(); + for index in 0..lines.len() { + if !scan.statements[index] || !import_word.is_match(lines.get(index)) { + continue; + } + for binding in line_bindings(&lines, &scan, index, &patterns) { + if let Binding::Import(key) = binding { + keys.insert(key); + } + } + } + keys + }) + .clone() +} + +/// Every binding of module global `name` in `file`: at module scope, and in +/// each function that declares it `global`, outside a `__main__` block. +fn global_bindings(name: &str, file: &str, context: &dyn ResolutionContext) -> Vec { + static GLOBAL_LINE: OnceLock = OnceLock::new(); + let Some(facts) = context.source_facts(file) else { + return Vec::new(); + }; + facts + .python_name_scan(name) + .global_bindings + .get_or_init(|| { + let Some(patterns) = NamePatterns::new(name) else { + return Vec::new(); + }; + let global_line = + GLOBAL_LINE.get_or_init(|| Regex::new(r"^\s*global(?-u:\b)").expect("global line")); + let lines = Lines::of(&facts); + let scan = file_scan(&facts); + let scopes: Vec> = context + .get_nodes_in_file_shared(file) + .into_iter() + .filter(|node| { + matches!( + node.kind, + NodeKind::Class | NodeKind::Function | NodeKind::Method + ) + }) + .collect(); + let mut regions = vec![own_lines(None, file, lines.len(), context)]; + for index in 0..lines.len() { + if !global_line.is_match(lines.get(index)) + || line_bindings(&lines, &scan, index, &patterns).is_empty() + { + continue; + } + let line = index as i64 + 1; + let scope = scopes + .iter() + .filter(|scope| scope.start_line <= line && scope.end_line >= line) + .max_by(|a, b| { + a.start_line + .cmp(&b.start_line) + .then_with(|| b.id.cmp(&a.id)) + }); + if let Some(scope) = scope + && scope.kind != NodeKind::Class + { + regions.push(own_lines(Some(scope.as_ref()), file, lines.len(), context)); + } + } + regions + .iter() + .flatten() + .filter(|&&index| !scan.script.get(index).copied().unwrap_or(false)) + .flat_map(|&index| line_bindings(&lines, &scan, index, &patterns)) + .filter(|binding| *binding != Binding::Global) + .collect() + }) + .clone() +} + +/// A module-scope Python variable: not a class attribute or a function +/// local. +pub(crate) fn is_module_global(node: &Node, context: &dyn ResolutionContext) -> bool { + node.language == Language::Python + && matches!(node.kind, NodeKind::Variable | NodeKind::Constant) + && !context + .get_nodes_in_file_shared(&node.file_path) + .iter() + .any(|scope| { + matches!( + scope.kind, + NodeKind::Class | NodeKind::Function | NodeKind::Method + ) && scope.start_line <= node.start_line + && scope.end_line >= node.start_line + }) +} + +/// `Cls(...)` / `pkg.mod.Cls(...)` as the WHOLE (possibly multi-line) value, +/// its callee; `None` for anything else (`Cls() if x else y`, `make()`). +fn constructor_call(text: &str) -> Option { + static CALLEE: OnceLock = OnceLock::new(); + let mut text = text.trim(); + // `(Cls())` is the same value: peel parentheses that wrap the whole + // expression. + while text.starts_with('(') { + let mut depth = 0i64; + let mut close = None; + for (at, c) in text.char_indices() { + if c == '(' { + depth += 1; + } else if c == ')' { + depth -= 1; + if depth == 0 { + close = Some(at); + break; + } + } + } + if close != Some(text.len() - 1) { + break; + } + text = text[1..text.len() - 1].trim(); + } + let callee = CALLEE.get_or_init(|| { + Regex::new(r"^((?:[A-Za-z_][0-9A-Za-z_]*\.)*[A-Z][0-9A-Za-z_]*)\s*\(").expect("callee") + }); + let found = callee.captures(text)?; + let open = found.get(0)?.end() - 1; + let bytes = text.as_bytes(); + let mut depth = 0i64; + let mut quote = 0u8; + let mut at = open; + while at < bytes.len() { + let c = bytes[at]; + if quote != 0 { + if c == b'\\' { + at += 1; + } else if c == quote { + quote = 0; + } + } else if c == b'"' || c == b'\'' { + quote = c; + } else if matches!(c, b'(' | b'[' | b'{') { + depth += 1; + } else if matches!(c, b')' | b']' | b'}') { + depth -= 1; + if depth == 0 { + let rest = &text[at + 1..]; + return rest + .chars() + .all(|c| c.is_whitespace() || c == '\\') + .then(|| found[1].to_string()); + } + } + at += 1; + } + None +} + +/// Single-line string contents blanked (triple-quoted strings are blanked +/// by the comment stripper already). +fn blank_strings(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let mut out = String::with_capacity(text.len()); + let mut at = 0; + while at < chars.len() { + let c = chars[at]; + if c == '"' || c == '\'' { + let mut end = at + 1; + let mut closed = None; + while end < chars.len() && chars[end] != '\n' { + if chars[end] == '\\' { + // `\.` takes any character but a newline. + if chars.get(end + 1).is_none_or(|&next| next == '\n') { + break; + } + end += 2; + continue; + } + if chars[end] == c { + closed = Some(end); + break; + } + end += 1; + } + if let Some(close) = closed { + out.push(c); + out.extend(std::iter::repeat_n(' ', close - at - 1)); + out.push(c); + at = close + 1; + continue; + } + } + out.push(c); + at += 1; + } + out +} + +/// Test code installs doubles: a test suite's file, or pytest's +/// `conftest.py` wherever it sits. +fn is_test_file(file: &str) -> bool { + codegraph_core::file_class::is_test_path(file) + || file == "conftest.py" + || file.ends_with("/conftest.py") +} + +/// The project files a Python module path can name from `from_file`: +/// relative paths (`..settings`) exactly, absolute ones by path suffix, so a +/// source root still resolves, and two files sharing the tail +/// (`x/settings.py`, `y/settings.py`) both come back. +fn module_files(dotted: &str, from_file: &str, context: &dyn ResolutionContext) -> Vec { + let dots = dotted.bytes().take_while(|&b| b == b'.').count(); + let parts: Vec<&str> = dotted[dots..] + .split('.') + .filter(|part| !part.is_empty()) + .collect(); + let Some(last) = parts.last() else { + return Vec::new(); + }; + let mut dir = String::new(); + if dots > 0 { + let mut at = from_file.rsplit_once('/').map_or("", |(dir, _)| dir); + for _ in 1..dots { + at = at.rsplit_once('/').map_or("", |(dir, _)| dir); + } + dir = at.to_string(); + } + let rel = std::iter::once(dir.as_str()) + .chain(parts.iter().copied()) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/"); + let wants = [ + format!("{rel}.py"), + format!("{rel}.pyi"), + format!("{rel}/__init__.py"), + ]; + let matches = |file: &str| { + wants.iter().any(|want| { + if dots > 0 { + file == want + } else { + file == want || file.ends_with(&format!("/{want}")) + } + }) + }; + let mut out = Vec::new(); + for basename in [ + format!("{last}.py"), + format!("{last}.pyi"), + "__init__.py".to_string(), + ] { + for node in context.get_nodes_by_name_shared(&basename) { + if node.kind == NodeKind::File + && matches(&node.file_path) + && !out.contains(&node.file_path) + { + out.push(node.file_path.clone()); + } + } + } + out +} + +/// The project files an import of `file` can name. +fn imported_files( + mapping: &ImportMapping, + file: &str, + context: &dyn ResolutionContext, +) -> Vec { + let dotted = if mapping.is_namespace { + mapping.source.clone() + } else if !mapping.source.is_empty() && mapping.source.bytes().all(|b| b == b'.') { + format!("{}{}", mapping.source, mapping.exported_name) + } else { + format!("{}.{}", mapping.source, mapping.exported_name) + }; + module_files(&dotted, file, context) +} + +/// How `file` spells the module `module_file`: `aliases` import exactly that +/// file, `ambiguous` could also name another file sharing its dotted tail. +/// `import a.b` binds `a`, so that module is spelled `a.b`; `import a.b as +/// c` binds `c`. +fn module_aliases( + file: &str, + module_file: &str, + context: &dyn ResolutionContext, +) -> (Vec, Vec) { + let mut aliases = BTreeSet::new(); + let mut ambiguous = BTreeSet::new(); + let facts = context.source_facts(file); + for mapping in context.get_import_mappings(file, Language::Python) { + let files = imported_files(&mapping, file, context); + if !files.iter().any(|found| found == module_file) { + continue; + } + // The mapping cannot tell `import a.b` from `import a.b as b`; the + // source line can, for exactly this module, outside strings. + let plain_dotted = mapping.is_namespace + && mapping.source.contains('.') + && mapping.source.rsplit('.').next() == Some(mapping.local_name.as_str()) + && !facts.as_ref().is_some_and(|facts| { + let lines = Lines::of(facts); + (0..lines.len()).any(|index| { + imports_explicitly_as( + &blank_strings(lines.get(index)), + &mapping.source, + &mapping.local_name, + ) + }) + }); + let spelling = if plain_dotted { + mapping.source.clone() + } else { + mapping.local_name.clone() + }; + if files.len() == 1 { + aliases.insert(spelling); + } else { + ambiguous.insert(spelling); + } + } + ( + aliases.into_iter().collect(), + ambiguous.into_iter().collect(), + ) +} + +/// Whether `line` imports exactly `source` as `alias` (upstream +/// `\bimport\s[^\n]*(? bool { + static IMPORT: OnceLock = OnceLock::new(); + let import = IMPORT.get_or_init(|| Regex::new(r"(?-u:\b)import\s").expect("import")); + let Some(found) = import.find(line) else { + return false; + }; + let rest = &line[found.end()..]; + let bytes = rest.as_bytes(); + rest.match_indices(source).any(|(at, _)| { + if at + .checked_sub(1) + .is_some_and(|before| is_word_byte(bytes[before]) || bytes[before] == b'.') + { + return false; + } + let after = &rest[at + source.len()..]; + let spaced = after.trim_start(); + if spaced.len() == after.len() { + return false; + } + let Some(tail) = spaced.strip_prefix("as") else { + return false; + }; + let named = tail.trim_start(); + named.len() < tail.len() + && named + .strip_prefix(alias) + .is_some_and(|end| !end.bytes().next().is_some_and(is_word_byte)) + }) +} + +/// The global's own module's bindings, or `None` when one leaves its type +/// unknown, whatever other modules write. +fn own_writes(global: &Node, context: &dyn ResolutionContext) -> Option> { + let facts = context.source_facts(&global.file_path)?; + facts + .python_global_memo(&global.id) + .own_writes + .get_or_init(|| { + let file = &global.file_path; + if dynamic_global_write(&global.name, file, context) { + return None; + } + let mut writes = Vec::new(); + for binding in global_bindings(&global.name, file, context) { + let Binding::Assign { annotation, value } = binding else { + return None; + }; + let constructor = (!value.is_empty() && value != "None") + .then(|| constructor_call(&value)) + .flatten(); + if let Some(annotation) = annotation { + // `conn: Base = make()` trusts the annotation; `conn: A + // = B()` contradicts it. + if constructor.as_ref().is_some_and(|constructor| { + constructor.rsplit('.').next() != annotation.rsplit('.').next() + }) { + return None; + } + writes.push(GlobalWrite { + type_name: annotation, + file: file.clone(), + }); + continue; + } + if value == "None" { + continue; + } + writes.push(GlobalWrite { + type_name: constructor?, + file: file.clone(), + }); + } + Some(writes) + }) + .clone() +} + +/// Whether the global's own module can write it through its namespace dict: +/// `globals()` / `vars()` / `sys.modules[__name__]` used as anything but a +/// literal-key read or `.get`, or a literal-key write of this name. +fn dynamic_global_write(name: &str, file: &str, context: &dyn ResolutionContext) -> bool { + static MODULE_SCOPE: OnceLock = OnceLock::new(); + static FUNCTION_SCOPE: OnceLock = OnceLock::new(); + static SAFE: OnceLock = OnceLock::new(); + let Some(facts) = context.source_facts(file) else { + return false; + }; + let lines = Lines::of(&facts); + let scan = file_scan(&facts); + // `vars()` is the module dict only at module scope; inside a function it + // is the locals. + let module_lines: HashSet = own_lines(None, file, lines.len(), context) + .into_iter() + .collect(); + let module_scope = MODULE_SCOPE.get_or_init(|| { + Regex::new( + r"(?-u:\b)globals\(\s*\)|(?-u:\b)vars\(\s*\)|(?-u:\b)sys\.modules\s*\[\s*__name__\s*\]", + ) + .expect("module namespace") + }); + let function_scope = FUNCTION_SCOPE.get_or_init(|| { + Regex::new(r"(?-u:\b)globals\(\s*\)|(?-u:\b)sys\.modules\s*\[\s*__name__\s*\]") + .expect("function namespace") + }); + let safe = SAFE.get_or_init(|| { + Regex::new( + r#"(?-u:\b)globals\(\s*\)\s*(?:\[\s*(?:'([0-9A-Za-z_]+)'|"([0-9A-Za-z_]+)")\s*\]|\.\s*get\s*\()"#, + ) + .expect("namespace read") + }); + for index in 0..lines.len() { + if !scan.statements[index] || scan.script.get(index).copied().unwrap_or(false) { + continue; + } + let statement = statement_at(&lines, &scan, index); + let code = blank_strings(&statement); + let namespace = if module_lines.contains(&index) { + module_scope + } else { + function_scope + }; + let uses = namespace.find_iter(&code).count(); + if uses == 0 { + continue; + } + let targets = assignment(&statement) + .map(|assigned| assigned.targets) + .unwrap_or_default(); + let mut safe_uses = 0; + for found in safe.captures_iter(&statement) { + let whole = found.get(0).map_or("", |whole| whole.as_str()); + let key = found + .get(1) + .or_else(|| found.get(2)) + .map(|key| key.as_str()); + let written = key.is_some() && targets.iter().any(|target| target.contains(whole)); + if written && key == Some(name) { + return true; + } + safe_uses += 1; + } + if uses > safe_uses { + return true; + } + } + false +} + +/// Writes to module global `global` from OTHER files. A production write +/// `. = Cls(...)` adds a type; any other production write +/// (another value, a tuple target, `setattr`) makes the type unknown. Test +/// files install doubles that do not define the production type; they are +/// recorded as writers, and a reference inside one resolves nothing through +/// the global. +fn external_writes<'m>( + global: &Node, + memo: &'m GlobalMemo, + context: &dyn ResolutionContext, +) -> &'m ExternalWrites { + memo.external.get_or_init(|| { + let mut out = ExternalWrites::default(); + let name = &global.name; + let n = regex::escape(name); + let index = context.python_write_index(); + for file in index.candidates(name, context) { + if file == global.file_path { + continue; + } + let test = is_test_file(&file); + let (aliases, ambiguous) = module_aliases(&file, &global.file_path, context); + if !test && aliases.is_empty() && ambiguous.is_empty() { + continue; + } + let spell = |names: &[String]| { + format!( + "(?:{})", + names + .iter() + .map(|name| regex::escape(name)) + .collect::>() + .join("|") + ) + }; + // Test files: any receiver (the double may sit on any spelling + // of the module). + let receiver = if test { + r"[0-9A-Za-z_.]+".to_string() + } else { + spell(&[aliases.as_slice(), ambiguous.as_slice()].concat()) + }; + let exact_receiver = if test { + receiver.clone() + } else if aliases.is_empty() { + // Matches nothing: a write through an ambiguous spelling may + // land on another module. + "\\x00".to_string() + } else { + spell(&aliases) + }; + let (Ok(target_pattern), Ok(exact), Ok(dynamic)) = ( + Regex::new(&format!(r"(?:^|[^0-9A-Za-z_.]){receiver}\.{n}(?-u:\b)")), + Regex::new(&format!(r"^{exact_receiver}\.{n}(?:\s*:[^=]*)?$")), + Regex::new(&format!( + r#"(?-u:\b)(?:setattr|patch\.object)\s*\(\s*{receiver}\s*,\s*['"]{n}['"]|(?:^|[^0-9A-Za-z_.]){receiver}\.__dict__\s*(?:\[|\.\s*update\s*\()"# + )), + ) else { + out.unknown = true; + return out; + }; + let Some(facts) = context.source_facts(&file) else { + continue; + }; + let lines = Lines::of(&facts); + let scan = file_scan(&facts); + for line in 0..lines.len() { + if !scan.statements[line] || scan.script.get(line).copied().unwrap_or(false) { + continue; + } + let statement = statement_at(&lines, &scan, line); + if !statement.contains(name.as_str()) { + continue; + } + let assigned = assignment(&statement); + let written = assigned.as_ref().is_some_and(|assigned| { + assigned + .targets + .iter() + .any(|target| writes_global(target.trim(), &target_pattern)) + }); + if !written && !dynamic.is_match(&statement) { + continue; + } + if test { + out.writers.insert(file.clone()); + continue; + } + let single = assigned.as_ref().filter(|assigned| { + assigned.targets.len() == 1 + && !assigned.augmented + && exact.is_match(assigned.targets[0].trim()) + }); + let value = single.map_or(String::new(), |assigned| { + assigned.value.trim().to_string() + }); + if value == "None" { + continue; + } + let Some(constructor) = (!value.is_empty()) + .then(|| constructor_call(&value)) + .flatten() + else { + out.unknown = true; + return out; + }; + out.writes.push(GlobalWrite { + type_name: constructor, + file: file.clone(), + }); + } + } + out + }) +} + +/// Whether an assignment target writes the global: the pattern matches and +/// what follows the global's name is not `.`, `[` or `(` (a deeper chain, a +/// subscript, a call). +fn writes_global(text: &str, target: &Regex) -> bool { + target.find_iter(text).any(|found| { + !text[found.end()..] + .trim_start() + .starts_with(['.', '[', '(']) + }) +} + +/// The classes a module global can hold: what its module assigns and what +/// production writes from other modules join, each resolved in the file that +/// wrote it; `None` when any is unknown. +fn global_classes( + global: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option>> { + let facts = context.source_facts(&global.file_path)?; + let memo = facts.python_global_memo(&global.id); + memo.classes + .get_or_init(|| { + let own = own_writes(global, context)?; + let external = external_writes(global, &memo, context); + if external.unknown { + return None; + } + let mut classes: Vec> = Vec::new(); + for write in external.writes.iter().chain(&own) { + let mut at_writer = reference.clone(); + at_writer.file_path = write.file.clone(); + let class = python_ref_class(&write.type_name, &at_writer, context)?; + if !classes.iter().any(|seen| seen.id == class.id) { + classes.push(Arc::new(class)); + } + } + Some(classes) + }) + .clone() +} + +/// The method a module global's value can dispatch to for `member`: one +/// class's own method; for several, the nearest declaration they all +/// inherit, as a base-typed receiver does; otherwise every candidate, which +/// the caller declines as ambiguous. +pub(crate) fn global_members( + global: &Node, + member: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Vec> { + // Unknown from its own module alone: no edge, and no other module to read. + if own_writes(global, context).is_none() { + return Vec::new(); + } + let Some(facts) = context.source_facts(&global.file_path) else { + return Vec::new(); + }; + let memo = facts.python_global_memo(&global.id); + // A test that installs its own double sees the double, not the + // production type. + if external_writes(global, &memo, context) + .writers + .contains(&reference.file_path) + { + return Vec::new(); + } + let Some(classes) = global_classes(global, reference, context) else { + return Vec::new(); + }; + let mut targets: BTreeMap> = BTreeMap::new(); + for class in &classes { + for node in python_members(class, member, reference, context, &mut HashSet::new()) { + targets.entry(node.id.clone()).or_insert(node); + } + } + let targets: Vec> = targets.into_values().collect(); + if targets.len() <= 1 { + return targets; + } + // Several targets: the nearest declaration every candidate class + // inherits, whether or not a candidate overrides it. + let owner = |node: &Node| { + context + .get_nodes_in_file_shared(&node.file_path) + .into_iter() + .find(|class| { + class.kind == NodeKind::Class + && node.qualified_name == format!("{}::{member}", class.qualified_name) + }) + }; + let mut declarations: Vec<(Arc, Arc)> = Vec::new(); + let mut queue: std::collections::VecDeque> = targets.iter().cloned().collect(); + while let Some(declaration) = queue.pop_front() { + if declarations.len() >= 32 { + break; + } + let Some(class) = owner(&declaration) else { + continue; + }; + if declarations + .iter() + .any(|(seen, _)| seen.id == declaration.id) + { + continue; + } + declarations.push((Arc::clone(&declaration), Arc::clone(&class))); + for base in python_bases(&class, reference, context) { + queue.extend(python_members( + &base, + member, + reference, + context, + &mut HashSet::new(), + )); + } + } + let inherits = |class: &Node, base: &Node| { + class.id == base.id + || python_derives_from(class, base, reference, context, &mut HashSet::new()) + }; + let shared: Vec<&(Arc, Arc)> = declarations + .iter() + .filter(|(_, owner)| classes.iter().all(|class| inherits(class, owner))) + .collect(); + let nearest: Vec<&&(Arc, Arc)> = shared + .iter() + .filter(|(_, owner)| shared.iter().all(|(_, other)| inherits(owner, other))) + .collect(); + match nearest.as_slice() { + [(declaration, _)] => vec![Arc::clone(declaration)], + _ => targets, + } +} + +/// The module global a member value's receiver names through an import: +/// `settings.conn` after `import settings`, `conn` after `from settings +/// import conn`, never a deeper chain. A local import of the root binds what +/// the file imports, unless the file imports it from two places or binds +/// the root otherwise. +pub(crate) fn imported_global( + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + let segments: Vec<&str> = receiver.split('.').collect(); + if segments.len() > 2 { + return None; + } + let mut lookup = reference.clone(); + lookup.reference_name = receiver.to_string(); + lookup.reference_kind = EdgeKind::References; + let hit = resolve_via_import(&lookup, context)?; + let global = context.get_node_by_id_shared(&hit.target_node_id)?; + let root = segments[0]; + (Some(global.name.as_str()) == segments.last().copied() + && is_module_global(&global, context) + && import_keys(root, &reference.file_path, context).len() == 1 + && global_bindings(root, &reference.file_path, context) + .iter() + .all(|binding| matches!(binding, Binding::Import(_))) + && !binds_locally(root, reference, context, false)) + .then_some(global) +} + +/// The module global of the reference's own file that `receiver` names, +/// unless the calling function or one around it binds the name itself. +pub(crate) fn own_file_global( + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + let mut globals: Vec> = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|node| node.name == receiver && is_module_global(node, context)) + .collect(); + globals.sort_by(|a, b| { + (a.start_line, a.start_column, &a.id).cmp(&(b.start_line, b.start_column, &b.id)) + }); + let global = globals.into_iter().next()?; + (!binds_locally(receiver, reference, context, true)).then_some(global) +} + +/// The names of the module globals of `file` that a member value can type: +/// those whose own module's bindings are all constructor calls, `None` or +/// annotations. Sync re-resolves the member values through them when a +/// module that can write them changes. +pub fn typable_module_globals(file: &str, context: &dyn ResolutionContext) -> BTreeSet { + context + .get_nodes_in_file_shared(file) + .into_iter() + .filter(|node| is_module_global(node, context) && own_writes(node, context).is_some()) + .map(|node| node.name.clone()) + .collect() +} + +/// The names of every module global of `file`. +pub fn module_globals(file: &str, context: &dyn ResolutionContext) -> BTreeSet { + context + .get_nodes_in_file_shared(file) + .into_iter() + .filter(|node| is_module_global(node, context)) + .map(|node| node.name.clone()) + .collect() +} + +/// The project files the Python import statements of `file` name, the +/// modules it can write a global of. +pub fn imported_module_files<'s>( + file: &str, + statements: impl IntoIterator, + context: &dyn ResolutionContext, +) -> BTreeSet { + statements + .into_iter() + .flat_map(|statement| { + crate::import_resolver::extract_import_mappings(statement, Language::Python) + }) + .flat_map(|mapping| imported_files(&mapping, file, context)) + .collect() +} + +/// Whether a function-value reference's text before its member, `line[.. +/// column]`, names one of `globals` as its receiver: `conn.` or +/// `settings.conn.`. +pub fn reads_through_global(before_member: &str, globals: &BTreeSet) -> bool { + let Some(chain) = before_member.strip_suffix('.') else { + return false; + }; + let start = chain + .bytes() + .rposition(|b| !(is_word_byte(b) || b == b'.')) + .map_or(0, |at| at + 1); + let segments: Vec<&str> = chain[start..].split('.').collect(); + segments.len() <= 2 + && segments.last().is_some_and(|last| globals.contains(*last)) + && segments.iter().all(|segment| is_ascii_word(segment)) +} + +/// Whether a function-value reference name reads through one of `globals`: +/// `conn.fetch` or `settings.conn.fetch`. +pub fn names_through_global(reference_name: &str, globals: &BTreeSet) -> bool { + let segments: Vec<&str> = reference_name.split('.').collect(); + matches!(segments.len(), 2 | 3) + && globals.contains(segments[segments.len() - 2]) + && segments.iter().all(|segment| is_ascii_word(segment)) +} + +/// The module files a `conftest.py`'s text pulls pytest fixtures in from +/// (`from tests.fixtures.cli import *`, `pytest_plugins = [...]`). +pub fn conftest_plugged_modules(text: &str) -> Vec { + crate::python_values::plugged_modules_in(text) +} + +/// The module files the quoted dotted names in `text` spell, as a +/// `pytest_plugins` value lists them. +pub fn quoted_module_files(text: &str) -> Vec { + crate::python_values::quoted_module_files(text) +} + +/// The parameter names of a Python signature (`(self, request, *args, +/// user: User = None)`). +pub fn parameter_names(signature: &str) -> Vec { + let inner = signature + .find('(') + .map_or(signature, |open| &signature[open + 1..]); + let mut names = Vec::new(); + let mut depth = 0i64; + let mut part = String::new(); + let mut flush = |part: &mut String| { + let name: String = part + .trim() + .trim_start_matches('*') + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + names.push(name); + } + part.clear(); + }; + for c in inner.chars() { + match c { + '(' | '[' | '{' => { + depth += 1; + part.push(c); + } + ')' | ']' | '}' if depth == 0 => break, + ')' | ']' | '}' => { + depth -= 1; + part.push(c); + } + ',' if depth == 0 => flush(&mut part), + _ => part.push(c), + } + } + flush(&mut part); + names +} + +#[cfg(test)] +mod tests { + use super::{ + assignment, blank_strings, constructor_call, has_word, imports_explicitly_as, + parameter_names, + }; + + #[test] + fn parameter_names_read_a_signature() { + assert_eq!( + parameter_names("(self, request, *args, user: Dict[str, int] = None, **kwargs)"), + ["self", "request", "args", "user", "kwargs"] + ); + assert_eq!(parameter_names("(account)"), ["account"]); + assert!(parameter_names("()").is_empty()); + } + + #[test] + fn assignment_splits_targets_and_marks_augmented_operators() { + let plain = assignment("a = b = Store()").expect("assigns"); + assert_eq!(plain.targets, ["a ", " b "]); + assert_eq!(plain.value, " Store()"); + assert!(!plain.augmented); + assert!(assignment("x += 1").expect("assigns").augmented); + assert!(assignment("x //= 2").expect("assigns").augmented); + assert!(assignment("x <<= 2").expect("assigns").augmented); + for none in ["x == y", "x != y", "f(a=1)", "(n := 3)", "x <= y", "x >= y"] { + assert!(assignment(none).is_none(), "{none}"); + } + let annotated = assignment("conn: Store = Store()").expect("assigns"); + assert_eq!(annotated.targets, ["conn: Store "]); + } + + #[test] + fn has_word_reads_a_binding_target() { + assert!(has_word("conn, _ = x", "conn")); + assert!(has_word("(a,\n conn)", "conn")); + assert!(!has_word("self.conn = x", "conn")); + assert!(!has_word("conn.pool = x", "conn")); + assert!(!has_word("conn[0] = x", "conn")); + assert!(!has_word("connection = x", "conn")); + } + + #[test] + fn constructor_call_takes_the_whole_value_only() { + assert_eq!(constructor_call("Store()").as_deref(), Some("Store")); + assert_eq!( + constructor_call("backends.es.ES(\n x=1,\n)").as_deref(), + Some("backends.es.ES") + ); + assert_eq!(constructor_call("(Store())").as_deref(), Some("Store")); + assert_eq!(constructor_call("Store() if flag else Decoy()"), None); + assert_eq!(constructor_call("make_store()"), None); + assert_eq!(constructor_call("Store().fetch"), None); + } + + #[test] + fn strings_are_blanked_and_explicit_aliases_read_exactly() { + assert_eq!(blank_strings(r#"x = "a\"b" + 'c'"#), r#"x = " " + ' '"#); + assert!(imports_explicitly_as( + "import pkg.settings as settings", + "pkg.settings", + "settings" + )); + assert!(!imports_explicitly_as( + "import other.pkg.settings as settings", + "pkg.settings", + "settings" + )); + assert!(!imports_explicitly_as( + "import pkg.settings", + "pkg.settings", + "settings" + )); + } +} diff --git a/crates/codegraph-resolve/src/python_values.rs b/crates/codegraph-resolve/src/python_values.rs new file mode 100644 index 00000000..b15e6588 --- /dev/null +++ b/crates/codegraph-resolve/src/python_values.rs @@ -0,0 +1,655 @@ +//! What a Python name passed as a value is where it is written (upstream +//! #2219): a name the enclosing function binds (a parameter, an assignment, a +//! `for` / `with … as` / `except … as` target) is that local's value, a name a +//! `from … import` line binds is the import's, and a pytest fixture is what a +//! test's parameter of its name receives only in its own module or under its +//! `conftest.py`. +//! +//! The patterns are upstream's, with `\w` and `\b` kept ASCII as in +//! JavaScript, and `=(?!=)` written `=(?:[^=]|$)` (the same match test +//! without a lookahead). + +use std::cell::OnceCell; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; + +use crate::types::{RefView, ResolutionContext}; + +/// `name` → the module a `from import name` line takes it from +/// (upstream `pythonFromImports`). +fn scan_from_imports(text: &str) -> HashMap { + static FROM: OnceLock = OnceLock::new(); + static BOUND: OnceLock = OnceLock::new(); + let from = FROM.get_or_init(|| { + Regex::new(r"(?m)^\s*from\s+([0-9A-Za-z_.]+)\s+import\s+(\([^)]*\)|[^\n#]+)") + .expect("python from import") + }); + let bound = + BOUND.get_or_init(|| Regex::new(r"([0-9A-Za-z_]+)\s*$").expect("python import binding")); + let mut names = HashMap::new(); + for m in from.captures_iter(text) { + for item in m[2].replace(['(', ')'], "").split(',') { + if let Some(c) = bound.captures(item.trim()) { + names.insert(c[1].to_string(), m[1].to_string()); + } + } + } + names +} + +/// The shape of a Python call that reaches the resolver as a bare name, read +/// at the call's column (upstream #2125 `pythonCallShape`): `Bare` is +/// `get(1)`, which cannot mean a method (Python has no implicit self); +/// `Chained` is a call through a receiver the extractor did not keep, which +/// can only mean a member of what the chain names last. `self.x()` / +/// `cls.x()`, and a chain split across lines, have no shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PythonCallShape { + Bare(BareCall), + Chained { owner: String }, +} + +/// What a bare call's file says of the name, read on the first candidate of +/// another file and kept for the rest: it does not depend on the candidate. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct BareCall { + imported_from_outside: OnceCell, + locally_bound: OnceCell, +} + +pub(crate) fn python_call_shape( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + if reference.language != Language::Python || reference.reference_kind != EdgeKind::Calls { + return None; + } + let name = reference.reference_name.as_str(); + let plain = name + .bytes() + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'); + if !plain { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + let line = facts.raw_line(usize::try_from(reference.line).ok()?.checked_sub(1)?)?; + let mut column = usize::try_from(reference.column) + .unwrap_or(0) + .min(line.len()); + while !line.is_char_boundary(column) { + column -= 1; + } + shape_of_call(&line[column..], name) +} + +/// Whether a Python call that reaches the resolver as a bare name was +/// written on the instance, `self.get_ip(request)` (upstream #2290 +/// `isPythonSelfCall`): the extractor drops the `self.`, so a name the file +/// imports would otherwise claim the method call. +pub(crate) fn is_python_self_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { + use crate::name_matcher::{BareCallReceiver, bare_call_receiver}; + if reference.language != Language::Python + || !matches!( + bare_call_receiver(reference, context), + Some(BareCallReceiver::OwnObject) + ) + { + return false; + } + // `OwnObject` is `super()` and `this` too: the receiver is `self` alone. + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let Some(line) = usize::try_from(reference.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .and_then(|index| facts.raw_line(index)) + else { + return false; + }; + usize::try_from(reference.column) + .ok() + .and_then(|column| line.get(column..)) + .and_then(|text| text.strip_prefix("self")) + .is_some_and(|rest| rest.trim_start().starts_with('.')) +} + +/// The shape of the call of `name` that `text` (its line from the call's +/// column on) starts with. +fn shape_of_call(text: &str, name: &str) -> Option { + static OWNER: OnceLock = OnceLock::new(); + // `name(`, whitespace allowed before the parenthesis. + let calls_name = |rest: &str| { + rest.strip_prefix(name) + .is_some_and(|after| after.trim_start().starts_with('(')) + }; + if calls_name(text) { + return Some(PythonCallShape::Bare(BareCall::default())); + } + // `self.name(` / `cls.name(`, read by hand: a pattern built per call + // would be compiled once for every Python call of the project. + let after_dot = |rest: &str| { + rest.trim_start() + .strip_prefix('.') + .is_some_and(|member| calls_name(member.trim_start())) + }; + if ["self", "cls"] + .iter() + .any(|own| text.strip_prefix(own).is_some_and(after_dot)) + { + return None; + } + // The call starts at its receiver, so everything up to the first + // `.name(` is the receiver chain. + let receiver = text + .match_indices('.') + .find(|(at, _)| calls_name(text[at + 1..].trim_start())) + .map(|(at, _)| &text[..at])?; + let owner = OWNER + .get_or_init(|| { + Regex::new(r"([0-9A-Za-z_]+)\s*(?:\([^()]*\)|\[[^\[\]]*\])?\s*$").expect("chain owner") + }) + .captures(receiver)? + .get(1)? + .as_str() + .to_string(); + Some(PythonCallShape::Chained { owner }) +} + +/// Whether a Python call of `shape` can mean `node` (upstream +/// `fitsPythonCallShape`): a bare call never a method, nor another file's +/// symbol when the file takes the name `from` a module outside the project +/// (`from django.shortcuts import render`) or binds it itself (`view = +/// SomeView.as_view()`, upstream #2198) unless it is a fixture in reach; a +/// chained call only a method of a class the chain's last link names +/// (`self.user_service` → `UserService`) or a function or class of a module +/// of that name (`app.helpers.slugify()`). +pub(crate) fn fits_python_call_shape( + node: &Node, + shape: &PythonCallShape, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + match shape { + PythonCallShape::Bare(call) => { + let name = reference.reference_name.as_str(); + if node.kind == NodeKind::Method { + return false; + } + if node.file_path == reference.file_path { + return true; + } + if *call + .imported_from_outside + .get_or_init(|| is_python_name_imported_from_outside(name, reference, context)) + { + return false; + } + is_fixture_in_reach(node, &reference.file_path, context) + || !*call + .locally_bound + .get_or_init(|| is_python_locally_bound(name, reference, context)) + } + PythonCallShape::Chained { owner } => { + let plain = |text: &str| text.replace('_', "").to_lowercase(); + if node.kind == NodeKind::Method { + let class = node + .qualified_name + .rsplit_once("::") + .map(|(parent, _)| parent.rsplit("::").next().unwrap_or(parent)) + .unwrap_or_default(); + return !class.is_empty() && plain(class) == plain(owner); + } + let mut parts = node.file_path.rsplit('/'); + let file = parts.next().unwrap_or_default(); + let stem = file + .strip_suffix(".pyi") + .or_else(|| file.strip_suffix(".py")) + .unwrap_or(file); + stem == owner || (stem == "__init__" && parts.next() == Some(owner.as_str())) + } + } +} + +/// Whether `file_path` takes `name` `from` a module the project does not +/// contain (upstream `isPythonNameImportedFromOutside`): `from +/// django.shortcuts import render`. A relative import is the project's. +fn is_python_name_imported_from_outside( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let Some(module) = facts + .python_from_imports(scan_from_imports) + .get(name) + .cloned() + else { + return false; + }; + if module.starts_with('.') { + return false; + } + !facts.node_decision("python_module_local", &module, |_| { + let rel = module.replace('.', "/"); + let (file, package) = (format!("{rel}.py"), format!("{rel}/__init__.py")); + context.get_all_files_shared().iter().any(|f| { + *f == file + || f.ends_with(&format!("/{file}")) + || *f == package + || f.ends_with(&format!("/{package}")) + }) + }) +} + +/// Whether a `from … import` line of `file_path` binds `name`. +pub(crate) fn python_imports_name( + file_path: &str, + name: &str, + context: &dyn ResolutionContext, +) -> bool { + context.source_facts(file_path).is_some_and(|facts| { + facts + .python_from_imports(scan_from_imports) + .contains_key(name) + }) +} + +/// Whether the function around a Python reference, or its module, binds +/// `name` itself: an assignment (`view = X.as_view()`, `a, view = …`, +/// `view: T = …`), a parameter, a `for` / `with … as` / `except … as` target +/// (upstream `isPythonLocallyBound`). Answered per reference line: upstream +/// keeps the first answer for every later reference in the function, which +/// would make the answer depend on resolution order. +pub(crate) fn is_python_locally_bound( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + // An imported name is the import's, for the import resolver to follow. + if python_imports_name(&reference.file_path, name, context) { + return false; + } + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let function = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|f| { + matches!(f.kind, NodeKind::Function | NodeKind::Method) + && f.start_line <= reference.line + && f.end_line >= reference.line + }) + .min_by(|a, b| { + (a.end_line - a.start_line) + .cmp(&(b.end_line - b.start_line)) + .then_with(|| a.id.cmp(&b.id)) + }); + let n = regex::escape(name); + let function_key = function + .as_ref() + .map(|f| format!("{}\0{name}\0{}", f.id, reference.line)); + let locally = function_key.is_some_and(|key| { + facts.node_decision("python_local_binding", &key, |facts| { + // Code only: a docstring's `as user_display` binds nothing. + let (code, lines) = facts.python_comment_free(); + let line = |index: usize| (index < lines.line_count()).then(|| lines.line(code, index)); + let (Ok(assigns), Ok(targets), Ok(params)) = ( + Regex::new(&format!( + r"^\s*(?:[0-9A-Za-z_\s,*()\[\]]*,\s*)?\(?\*?{n}\)?\s*(?:,[0-9A-Za-z_\s,*()\[\]]*)?(?::[^=]+)?=(?:[^=]|$)" + )), + Regex::new(&format!( + r"(?-u:\b)for\s+[0-9A-Za-z_\s,()]*(?-u:\b){n}(?-u:\b)[0-9A-Za-z_\s,()]*\s+in(?-u:\b)|(?-u:\b)as\s+{n}(?-u:\b)" + )), + Regex::new(&format!(r"[(,]\s*\*{{0,2}}{n}\s*(?:[:=,)]|$)")), + ) else { + return false; + }; + static SIGNATURE_END: OnceLock = OnceLock::new(); + let signature_end = SIGNATURE_END + .get_or_init(|| Regex::new(r"\)\s*(?:->[^:]*)?:\s*(?:#.*)?$").expect("signature end")); + let Some(function) = function.as_ref() else { + return false; + }; + // The signature up to its `:` (it may span lines), then the body + // above the reference. + let start = usize::try_from(function.start_line).unwrap_or(1).saturating_sub(1); + let limit = lines.line_count().min(start + 21); + let mut at = start; + let mut signature = String::new(); + while at < limit { + let text = line(at).unwrap_or_default(); + signature.push_str(text); + if signature_end.is_match(text) { + break; + } + at += 1; + } + if params.is_match(signature.find('(').map_or("", |open| &signature[open..])) { + return true; + } + let before_reference = usize::try_from(reference.line).unwrap_or(0).saturating_sub(1); + (at + 1..before_reference).any(|index| { + line(index).is_some_and(|text| assigns.is_match(text) || targets.is_match(text)) + }) + }) + }); + // A module-level binding (`view = api_view(['GET'])(handler)`). + locally + || facts.node_decision("python_module_binding", name, |facts| { + let (code, lines) = facts.python_comment_free(); + Regex::new(&format!( + r"^(?:[0-9A-Za-z_,\s]*,\s*)?{n}\s*(?:,[0-9A-Za-z_\s,]*)?(?::[^=]+)?=(?:[^=]|$)" + )) + .is_ok_and(|top| { + (0..lines.line_count()).any(|index| top.is_match(lines.line(code, index))) + }) + }) +} + +/// A pytest fixture: `@pytest.fixture` / `@fixture` above its `def`, or +/// anything a `conftest.py` defines (upstream `isPytestFixture`). +fn is_pytest_fixture(node: &Node, context: &dyn ResolutionContext) -> bool { + static DECORATOR: OnceLock = OnceLock::new(); + let decorator = DECORATOR + .get_or_init(|| Regex::new(r"(?:^|\.)fixture(?-u:\b)").expect("fixture decorator name")); + is_conftest(&node.file_path) + || node.decorators.iter().any(|d| decorator.is_match(d)) + || is_decorated_fixture(node, context) +} + +fn is_conftest(file_path: &str) -> bool { + file_path == "conftest.py" || file_path.ends_with("/conftest.py") +} + +/// Read upward through the decorator lines above a `def`, each starting with +/// `@` or sitting inside one's parentheses, for a `fixture` decorator +/// (upstream `isDecoratedFixture`). +fn is_decorated_fixture(node: &Node, context: &dyn ResolutionContext) -> bool { + static FIXTURE: OnceLock = OnceLock::new(); + let fixture = FIXTURE.get_or_init(|| { + Regex::new(r"^@(?:[0-9A-Za-z_]+\.)*fixture(?-u:\b)").expect("fixture decorator") + }); + if node.language != Language::Python || node.kind != NodeKind::Function { + return false; + } + let Some(facts) = context.source_facts(&node.file_path) else { + return false; + }; + let mut open: i64 = 0; + let mut index = node.start_line - 2; + while index >= 0 && index >= node.start_line - 16 { + let text = usize::try_from(index) + .ok() + .filter(|&i| i < facts.line_count()) + .map_or("", |i| facts.line(i).trim()); + open += text.matches(')').count() as i64 - text.matches('(').count() as i64; + if open <= 0 { + if !text.starts_with('@') { + return false; + } + if fixture.is_match(text) { + return true; + } + } + index -= 1; + } + false +} + +/// A fixture a test at `file_path` can take by name: one its own module +/// defines, one a `conftest.py` of its directory or a parent does, or one a +/// module such a conftest pulls in defines (`from tests.fixtures.cli import +/// *`, `pytest_plugins = ["tests.fixtures.cli"]`, upstream #2228). A test +/// module's fixture is that module's alone (upstream `isFixtureInReach`). +pub(crate) fn is_fixture_in_reach( + node: &Node, + file_path: &str, + context: &dyn ResolutionContext, +) -> bool { + if node.file_path == file_path { + return is_pytest_fixture(node, context); + } + if node.file_path == "conftest.py" { + return true; + } + if let Some(dir) = node.file_path.strip_suffix("/conftest.py") { + return file_path + .strip_prefix(dir) + .is_some_and(|rest| rest.starts_with('/')); + } + is_pytest_fixture(node, context) + && plugged_fixture_modules(file_path, context) + .iter() + .any(|module| { + node.file_path == *module || node.file_path.ends_with(&format!("/{module}")) + }) +} + +/// The module files (`tests/fixtures/cli.py`) the `conftest.py` files above +/// `file_path` star-import or list in `pytest_plugins` (upstream +/// `pluggedFixtureModules`). +fn plugged_fixture_modules(file_path: &str, context: &dyn ResolutionContext) -> Vec { + let scan = || { + let mut modules = Vec::new(); + let mut dir = file_path.rsplit_once('/').map_or("", |(dir, _)| dir); + loop { + let conftest = if dir.is_empty() { + "conftest.py".to_string() + } else { + format!("{dir}/conftest.py") + }; + if let Some(text) = context.read_file_shared(&conftest) { + modules.extend(plugged_modules_in(&text)); + } + if dir.is_empty() { + break; + } + dir = dir.rsplit_once('/').map_or("", |(parent, _)| parent); + } + modules + }; + match context.source_facts(file_path) { + Some(facts) => facts.python_plugged_modules(scan).to_vec(), + None => scan(), + } +} + +/// The module files a `conftest.py`'s text pulls fixtures in from: what it +/// star-imports, and what its `pytest_plugins` lists. +pub(crate) fn plugged_modules_in(text: &str) -> Vec { + static STAR: OnceLock = OnceLock::new(); + static PLUGINS: OnceLock = OnceLock::new(); + let star = STAR.get_or_init(|| { + Regex::new(r"(?m)^\s*from\s+([0-9A-Za-z_.]+)\s+import\s+\*").expect("star import") + }); + let plugins = PLUGINS.get_or_init(|| { + Regex::new(r"(?m)^\s*pytest_plugins\s*=\s*[\[(]([^\])]*)[\])]").expect("pytest_plugins") + }); + let mut modules: Vec = star + .captures_iter(text) + .map(|found| format!("{}.py", found[1].replace('.', "/"))) + .collect(); + if let Some(list) = plugins.captures(text).and_then(|found| found.get(1)) { + modules.extend(quoted_module_files(list.as_str())); + } + modules +} + +/// The module files the quoted dotted names in `text` spell +/// (`"tests.fixtures.cli"` is `tests/fixtures/cli.py`). +pub(crate) fn quoted_module_files(text: &str) -> Vec { + static QUOTED: OnceLock = OnceLock::new(); + QUOTED + .get_or_init(|| Regex::new(r#"["']([0-9A-Za-z_.]+)["']"#).expect("quoted module")) + .captures_iter(text) + .map(|found| format!("{}.py", found[1].replace('.', "/"))) + .collect() +} + +/// The class a pytest fixture returns, for a test's parameter of its name +/// (upstream #2228 `pythonFixtureReturnType`): the fixture in reach (the +/// test's module first, then the nearest `conftest.py` above it) whose body +/// returns or yields `Cls(…)`, directly or through a local assigned +/// `Cls(…)`. `None` for anything else: a receiver that is no parameter, a +/// fixture returning a call of a function. +pub(crate) fn python_fixture_return_type( + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + static SIGNATURE_END: OnceLock = OnceLock::new(); + static RETURNED: OnceLock = OnceLock::new(); + let lower_word = receiver + .bytes() + .next() + .is_some_and(|b| b.is_ascii_lowercase() || b == b'_') + && receiver + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_'); + if !lower_word || receiver == "self" || receiver == "cls" { + return None; + } + let compute = || { + let caller = context.get_node_by_id_shared(&reference.from_node_id)?; + if !matches!(caller.kind, NodeKind::Function | NodeKind::Method) { + return None; + } + let caller_facts = context.source_facts(&caller.file_path)?; + // The receiver must be the test's own parameter. + let start = usize::try_from(caller.start_line).ok()?.checked_sub(1)?; + let header = (start..start + 5) + .filter_map(|index| caller_facts.raw_line(index)) + .map(|line| line.trim_end_matches('\r')) + .collect::>() + .join(" "); + let signature_end = + SIGNATURE_END.get_or_init(|| Regex::new(r"\)\s*(?:->[^:]*)?:").expect("signature end")); + let signature = signature_end + .find(&header) + .map_or(header.as_str(), |end| &header[..end.start()]); + let param = Regex::new(&format!( + r"[(,]\s*{}\s*(?:[:=,)]|$)", + regex::escape(receiver) + )) + .ok()?; + if !param.is_match(signature) { + return None; + } + let mut fixtures: Vec> = context + .get_nodes_by_name_shared(receiver) + .into_iter() + .filter(|node| { + node.kind == NodeKind::Function + && node.language == Language::Python + && is_fixture_in_reach(node, &reference.file_path, context) + }) + .collect(); + // The test's own module first, then the deepest conftest. + fixtures.sort_by(|a, b| { + (b.file_path == reference.file_path) + .cmp(&(a.file_path == reference.file_path)) + .then_with(|| b.file_path.len().cmp(&a.file_path.len())) + .then_with(|| a.file_path.cmp(&b.file_path)) + .then_with(|| a.start_line.cmp(&b.start_line)) + }); + let fixture = fixtures.first()?; + let facts = context.source_facts(&fixture.file_path)?; + let from = usize::try_from(fixture.start_line).ok()?; + let to = usize::try_from(fixture.end_line).ok()?; + let body = (from..to) + .filter_map(|index| facts.raw_line(index)) + .map(|line| line.trim_end_matches('\r')) + .collect::>() + .join("\n"); + let returned = RETURNED.get_or_init(|| { + Regex::new(r"(?m)^\s*(?:return|yield)\s+([A-Za-z_][0-9A-Za-z_.]*)\s*(\()?") + .expect("fixture return") + }); + let found = returned.captures(&body)?; + let value = found.get(1)?.as_str(); + let direct = if found.get(2).is_some() { + Some(value.to_string()) + } else { + Regex::new(&format!( + r"(?m)^\s*{}\s*=\s*([A-Za-z_][0-9A-Za-z_.]*)\s*\(", + regex::escape(value) + )) + .ok()? + .captures(&body) + .and_then(|assigned| assigned.get(1)) + .map(|callee| callee.as_str().to_string()) + }?; + let class = direct.rsplit('.').next()?; + class + .starts_with(|c: char| c.is_ascii_uppercase()) + .then(|| class.to_string()) + }; + let key = format!("{}\0{receiver}", reference.from_node_id); + match context.source_facts(&reference.file_path) { + Some(facts) => facts.python_fixture_type(&key, compute), + None => compute(), + } +} + +#[cfg(test)] +mod tests { + use super::{BareCall, PythonCallShape, scan_from_imports, shape_of_call}; + + #[test] + fn a_call_shape_is_read_from_its_column() { + let bare = Some(PythonCallShape::Bare(BareCall::default())); + let chained = |owner: &str| { + Some(PythonCallShape::Chained { + owner: owner.to_string(), + }) + }; + assert_eq!(shape_of_call("all(1)", "all"), bare); + assert_eq!(shape_of_call("render (request, 'x.html')", "render"), bare); + assert_eq!( + shape_of_call("User.objects.get(pk=1)", "get"), + chained("objects") + ); + assert_eq!( + shape_of_call("self.client.login(user)", "login"), + chained("client") + ); + assert_eq!( + shape_of_call("request.POST.get('next')", "get"), + chained("POST") + ); + assert_eq!(shape_of_call("make().get(1)", "get"), chained("make")); + assert_eq!(shape_of_call("rows[0] . get (1)", "get"), chained("rows")); + // The first `.get(` ends the chain: `.getx(` is not it. + assert_eq!(shape_of_call("a.getx(1).get(2)", "get"), chained("getx")); + // A class's own call, and a chain the line does not hold, have none. + assert_eq!(shape_of_call("self.get(1)", "get"), None); + assert_eq!(shape_of_call("cls . get(1)", "get"), None); + assert_eq!(shape_of_call("get", "get"), None); + assert_eq!(shape_of_call("selfish.get(1)", "get"), chained("selfish")); + } + + #[test] + fn from_imports_bind_their_last_word() { + let names = scan_from_imports( + "from a.b import c, d as e\nfrom .x import (\n f,\n g as h,\n)\nfrom m import * # all\nimport z\n", + ); + let mut found: Vec<(&str, &str)> = names + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + found.sort_unstable(); + assert_eq!( + found, + [("c", "a.b"), ("e", "a.b"), ("f", ".x"), ("h", ".x")] + ); + } +} diff --git a/crates/codegraph-resolve/src/r_scope.rs b/crates/codegraph-resolve/src/r_scope.rs new file mode 100644 index 00000000..0b187c77 --- /dev/null +++ b/crates/codegraph-resolve/src/r_scope.rs @@ -0,0 +1,242 @@ +//! R calls and the bindings they can name (upstream #2144, #2177, #2239). +//! +//! R looks a call's name up among functions: a plain `range(x)` is base R's +//! or a project function, never an object's method (ggplot2's ggproto Coord +//! has a `range` method); a data binding that shadows a base function +//! (`c <- data_frame(b = 3)`) is never what `c(1, 2)` calls; and testthat runs +//! each test file in an environment of its own, so a variable one test file +//! assigns is invisible to the package and to the other tests, except in the +//! `helper-*.R` and `setup-*.R` files every test sources. + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; + +use crate::types::{RefView, ResolutionContext, ResolvedRef}; + +/// Base R functions whose names data often shadows (`c <- data_frame(…)`, +/// `df <- …`, `t <- 1`; upstream `R_BASE_FUNCTIONS`). +pub const R_BASE_FUNCTIONS: [&str; 67] = [ + "c", + "t", + "q", + "df", + "dt", + "data", + "list", + "length", + "names", + "max", + "min", + "sum", + "mean", + "range", + "rev", + "sort", + "order", + "rep", + "seq", + "cat", + "print", + "paste", + "paste0", + "format", + "levels", + "factor", + "matrix", + "vector", + "table", + "scale", + "sample", + "exp", + "log", + "abs", + "all", + "any", + "which", + "nchar", + "summary", + "file", + "dir", + "identity", + "unique", + "nrow", + "ncol", + "rownames", + "colnames", + "array", + "character", + "numeric", + "integer", + "logical", + "mode", + "class", + "body", + "args", + "environment", + "search", + "diff", + "round", + "sign", + "trunc", + "var", + "sd", + "median", + "quantile", + "weights", +]; + +/// JavaScript's `\s`. +fn is_js_space(c: char) -> bool { + codegraph_core::generated_header::is_js_whitespace(c) +} + +/// An ASCII word character or `.` (`[\w.]`). +fn is_r_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '.' +} + +/// Whether `name` is written as an R name (`^[A-Za-z_.][\w.]*$`). +fn is_r_name(name: &str) -> bool { + name.chars() + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_' || first == '.') + && name.chars().all(is_r_name_char) +} + +/// Line `line` (1-based) of `file`, as stored. +fn source_line(file: &str, line: i64, context: &dyn ResolutionContext) -> Option { + let facts = context.source_facts(file)?; + let index = usize::try_from(line).ok()?.checked_sub(1)?; + facts.raw_line(index).map(str::to_string) +} + +/// Whether an R call is a plain function call (`range(x)`, `vars(a)`), not +/// one through an object (`obj$m(…)`, `self$m(…)`, `obj@m(…)`, `pkg::f(…)`): +/// the first `name(` on the call's line not inside a longer name, with no +/// `$`, `@` or `::` written before it (upstream `isBareRCall`, #2177). +pub(crate) fn is_bare_r_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { + let name = reference.reference_name.as_str(); + if reference.language != Language::R + || reference.reference_kind != EdgeKind::Calls + || !is_r_name(name) + { + return false; + } + let Some(line) = source_line(&reference.file_path, reference.line, context) else { + return false; + }; + let Some((at, _)) = line.match_indices(name).find(|(at, _)| { + !line[..*at].chars().next_back().is_some_and(is_r_name_char) + && line[at + name.len()..] + .trim_start_matches(is_js_space) + .starts_with('(') + }) else { + return false; + }; + let before = line[..at].trim_end_matches(is_js_space); + !(before.ends_with('$') || before.ends_with('@') || before.ends_with("::")) +} + +/// Whether an R binding holds a function, read from its line: `<-`, `<<-` or +/// `=` before `function` or a `\(x)` lambda (upstream `isRFunctionValue`). +fn is_r_function_value(node: &Node, context: &dyn ResolutionContext) -> bool { + let Some(line) = source_line(&node.file_path, node.start_line, context) else { + return false; + }; + let after_arrows = line.match_indices("<-").map(|(at, _)| at + 2); + let after_equals = line.match_indices('=').map(|(at, _)| at + 1); + after_arrows.chain(after_equals).any(|at| { + let rest = line[at..].trim_start_matches(is_js_space); + rest.strip_prefix("function").is_some_and(|after| { + !after + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') + }) || rest + .strip_prefix('\\') + .is_some_and(|after| after.trim_start_matches(is_js_space).starts_with('(')) + }) +} + +/// An R call of a base function's name never reaches a data binding of it +/// (upstream #2239, in `matchReference`): ggplot2's tests' `c <- +/// data_frame(b = 3)` is not what `c(1, 2)` calls. A binding whose line +/// holds a function stays. +pub fn veto_r_data_binding( + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let result = result?; + if reference.language == Language::R + && reference.reference_kind == EdgeKind::Calls + && R_BASE_FUNCTIONS.contains(&reference.reference_name.as_str()) + && context + .get_node_by_id_shared(&result.target_node_id) + .is_some_and(|target| { + matches!(target.kind, NodeKind::Variable | NodeKind::Constant) + && !is_r_function_value(&target, context) + }) + { + return None; + } + Some(result) +} + +/// Whether an R file runs in a test environment of its own: under a `tests/` +/// or `test/` folder, but no `helper-*.R` or `setup-*.R` file, which every +/// test sources (upstream #2144). +pub(crate) fn is_r_test_environment(file_path: &str) -> bool { + let in_tests = file_path.starts_with("tests/") + || file_path.starts_with("test/") + || file_path.contains("/tests/") + || file_path.contains("/test/"); + let name = file_path.rsplit('/').next().unwrap_or(file_path); + let shared = (name.starts_with("helper") || name.starts_with("setup")) + && (name.ends_with(".R") || name.ends_with(".r")); + in_tests && !shared +} + +/// The names in [`R_BASE_FUNCTIONS`] that `nodes` bind as an R variable or +/// constant: whether such a binding is what a call of its name means is read +/// from its line, which no node records. +pub fn r_base_bindings(nodes: &[Node]) -> impl Iterator + '_ { + nodes + .iter() + .filter(|node| { + node.language == Language::R + && matches!(node.kind, NodeKind::Variable | NodeKind::Constant) + && R_BASE_FUNCTIONS.contains(&node.name.as_str()) + }) + .map(|node| node.name.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_test_environment_is_a_tests_folder_but_not_its_helpers() { + for (path, expected) in [ + ("tests/testthat/test-facet.R", true), + ("pkg/tests/testthat/test-facet.R", true), + ("test/test-a.r", true), + ("tests/testthat/helper-s3.R", false), + ("tests/testthat/setup-data.r", false), + ("tests/testthat/helper-s3.Rmd", true), + ("R/geom-point.R", false), + ("contests/x.R", false), + ] { + assert_eq!(is_r_test_environment(path), expected, "{path}"); + } + } + + #[test] + fn an_r_name_is_a_word_or_dotted_name() { + assert!(is_r_name("geom_point")); + assert!(is_r_name(".onLoad")); + assert!(is_r_name("is.na")); + assert!(!is_r_name("obj$m")); + assert!(!is_r_name("pkg::f")); + assert!(!is_r_name("2x")); + } +} diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index e8130909..30c44e0e 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -10,7 +10,8 @@ use crate::alias_binding::resolve_alias_binding; use crate::framework::FrameworkResolver; use crate::import_resolver::{ - PhpImportedStaticCallResolution, is_bound_to_out_of_repo_module, is_php_include_path_ref, + PhpImportedStaticCallResolution, is_bound_to_out_of_repo_module, is_dart_import_ref, + is_js_path_import_ref, is_lua_require_ref, is_php_include_path_ref, python_module_member_is_claimed, resolve_jvm_import, resolve_php_imported_static_call, resolve_via_import, }; @@ -291,10 +292,13 @@ fn cpp_type_spelling_matches(spelled: &str, qualified: &str) -> bool { /// Pick one C++ type owner without using file proximity as namespace proof. /// An unqualified spelling may be resolved by the caller's lexical qualified -/// scope (longest segment-aligned prefix), otherwise the project must contain -/// exactly one matching owner. Equal-best candidates remain unresolved. +/// scope (longest segment-aligned prefix), or by `lexical_scope` when the +/// spelling was written somewhere else (an alias's own scope), otherwise the +/// project must contain exactly one matching owner. Equal-best candidates +/// remain unresolved. fn unique_cpp_type_owner( mut candidates: Vec>, + lexical_scope: Option<&str>, reference: &RefView, context: &dyn ResolutionContext, ) -> Option> { @@ -303,11 +307,16 @@ fn unique_cpp_type_owner( if candidates.len() == 1 { return candidates.pop(); } - let from = context.get_node_by_id_shared(&reference.from_node_id)?; - let caller_scope = from - .qualified_name - .rsplit_once("::") - .map_or("", |(scope, _)| scope); + let from; + let caller_scope = match lexical_scope { + Some(scope) => scope, + None => { + from = context.get_node_by_id_shared(&reference.from_node_id)?; + from.qualified_name + .rsplit_once("::") + .map_or("", |(scope, _)| scope) + } + }; let mut scoped = candidates .into_iter() .filter_map(|candidate| { @@ -437,6 +446,35 @@ fn js_built_ins() -> &'static BTreeSet<&'static str> { }) } +/// Languages that see the JS built-ins (upstream `isBuiltInOrExternal`'s +/// `isJsTs`): the script languages, ArkTS and, since #2128, Vue, Svelte and +/// Astro components. +fn has_js_built_ins(language: Language) -> bool { + matches!( + language, + Language::TypeScript + | Language::JavaScript + | Language::Tsx + | Language::Jsx + | Language::ArkTs + | Language::Vue + | Language::Svelte + | Language::Astro + ) +} + +/// Does the file import its own binding of a JS built-in's name +/// (`import Map from './Map.svelte'`)? Then the reference is that import, +/// not the built-in (upstream #2128). +fn imports_its_own_built_in(reference: &RefView, context: &dyn ResolutionContext) -> bool { + has_js_built_ins(reference.language) + && js_built_ins().contains(reference.reference_name.as_str()) + && context + .get_import_mappings(&reference.file_path, reference.language) + .iter() + .any(|mapping| mapping.local_name == reference.reference_name) +} + /// React hooks from React itself (`REACT_HOOKS`, `index.ts:75-78`). fn react_hooks() -> &'static BTreeSet<&'static str> { static SET: OnceLock> = OnceLock::new(); @@ -1146,17 +1184,124 @@ fn rust_method_is_in_trait_impl(facts: &SourceFacts, start_line: i64) -> bool { } } +/// A minified or bundled script, by name (`jquery.min.js`, `underscore-min.js`) +/// or by its text (upstream #2204 `isMinifiedScript`, judged once per file and +/// pass). +fn is_minified_script(file_path: &str, context: &dyn ResolutionContext) -> bool { + if !codegraph_core::generated_header::is_minifiable_script(file_path) { + return false; + } + let lower = file_path.to_ascii_lowercase(); + if [".min.js", "-min.js", ".min.mjs", "-min.mjs"] + .iter() + .any(|suffix| lower.ends_with(suffix)) + { + return true; + } + context.source_facts(file_path).is_some_and(|facts| { + facts.minified_script(|source| { + codegraph_core::generated_header::is_minified_content(file_path, source) + }) + }) +} + +/// `reference` named without the `\` of a PHP call to a function in the +/// global namespace (`\helper()`), or `None` for any other reference. +fn php_rooted_function_call(reference: &RefView) -> Option { + let name = reference.reference_name.strip_prefix('\\')?; + (reference.language == Language::Php + && reference.reference_kind == EdgeKind::Calls + && !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')) + .then(|| RefView { + reference_name: name.to_string(), + ..reference.clone() + }) +} + fn target_is_visible_across_files( target: &Node, reference: &RefView, context: &dyn ResolutionContext, ) -> bool { + // Go's `context.Context`, `http.Handler`: written through a package from + // outside the project, so nothing in it, not even the same file's method + // `Stream.Context` (upstream #2233); through one of the project's + // packages, only that package's symbol: a `job.OPCommand` result type is + // not the method `Context.OPCommand` beside it (upstream #2322). + if target.language == Language::Go + && reference.language == Language::Go + && (crate::go_packages::is_go_external_qualified(reference, context) + || !crate::go_packages::is_in_go_qualifier_package(target, reference, context)) + { + return false; + } if target.file_path == reference.file_path { return true; } if c_family_static_function_is_file_local(target, reference, context) { return false; } + // A C# type is in reach of the namespaces that can see it, and a nested + // one only from inside its owner or a type deriving from it (upstream + // #2216, #2220). + if crate::csharp_types::is_csharp_bare_type_candidate(target, reference) + && (!crate::csharp_types::is_csharp_type_visible(target, reference, context) + || !crate::csharp_types::is_csharp_nested_type_in_scope(target, reference, context)) + { + return false; + } + // An unnamed Dart `extension on X` applies only in its own library, the + // file and its parts (upstream #2237, #2386): a subclass's bare call in + // another library is no member of it, though the extension sits `on` its + // base (#2145). The extractor names it ``, so no name means it. + if crate::dart_scope::is_dart_unnamed_extension_member(target, context) + && !crate::dart_libraries::in_same_dart_library( + &reference.file_path, + &target.file_path, + context, + ) + { + return false; + } + // An R test file runs in an environment of its own (testthat): its + // top-level `c <- ggplot(…)` is not what the package's 2,843 `c(…)` calls + // mean, nor another test's. The `helper-*.R` and `setup-*.R` files are + // sourced for every test, so theirs are shared (upstream #2144). + if target.language == Language::R + && matches!(target.kind, NodeKind::Variable | NodeKind::Constant) + && crate::r_scope::is_r_test_environment(&target.file_path) + { + return false; + } + // A Lua `local` belongs to its chunk: kong's spec helpers' `local it = it` + // took busted's `it(…)` in every other spec file. Its module can still + // hand it out (`return { check = check_phase }`) to a file that names it + // through a `require` alias (upstream #2143, #2188). + if crate::lua_scope::is_lua(target.language) + && crate::name_matcher::is_lua_local(target, context) + { + return crate::lua_scope::is_lua_bare_call(reference) + && crate::lua_scope::lua_alias_target(reference, context) + .flatten() + .is_some_and(|aliased| aliased.target_node_id == target.id); + } + // A bare PHP class name is its namespace's class, or the one a `use` + // names (upstream #2235). + if !crate::php_scope::is_php_class_visible(target, reference, context) { + return false; + } + // A Scala package object's member is in scope in its package and those + // under it, or through an import: cats.laws' `Eq` is the `cats` package + // object's alias, not the `algebra` one's (upstream #2230). + if target.language == Language::Scala + && reference.language == Language::Scala + && !crate::scala_scope::is_scala_package_object_member_visible(target, reference, context) + { + return false; + } if matches!( target.language, Language::Kotlin @@ -1191,6 +1336,48 @@ fn target_is_visible_across_files( true } +/// The reference kinds that wire files and types together, which every +/// persist entry resolves and persists before the rest (upstream #1789 +/// `isPrerequisite`): a call resolved after them sees the `extends` and +/// `implements` edges they make, so a receiver typed as a subclass reaches a +/// method its base declares. +const WIRING_KINDS: [EdgeKind; 3] = [EdgeKind::Imports, EdgeKind::Extends, EdgeKind::Implements]; + +fn is_wiring_reference(reference: &UnresolvedRef) -> bool { + WIRING_KINDS.contains(&reference.reference_kind) +} + +/// `references` split into the wiring ones and the rest, each in its order. +fn wiring_first(references: Vec) -> [Vec; 2] { + let (wiring, rest): (Vec<_>, Vec<_>) = references.into_iter().partition(is_wiring_reference); + [wiring, rest] +} + +/// `into` with `from` folded in, as one pass over both would report. +fn merge_resolution(into: &mut ResolutionResult, from: ResolutionResult) { + into.stats.total += from.stats.total; + into.stats.resolved += from.stats.resolved; + into.stats.unresolved += from.stats.unresolved; + for (method, count) in from.stats.by_method { + *into.stats.by_method.entry(method).or_default() += count; + } + into.resolved.extend(from.resolved); + into.unresolved.extend(from.unresolved); +} + +fn empty_resolution() -> ResolutionResult { + ResolutionResult { + stats: ResolutionStats { + total: 0, + resolved: 0, + unresolved: 0, + by_method: std::collections::BTreeMap::new(), + }, + resolved: Vec::new(), + unresolved: Vec::new(), + } +} + /// Orchestrates reference resolution using multiple strategies /// (`ReferenceResolver`, `index.ts:199-1189`). /// @@ -1203,18 +1390,33 @@ pub struct ReferenceResolver { /// Largest file resolution reads, the project's extraction limit /// (`indexing.max_file_size`), handed to every context the resolver builds. max_file_size: u64, + /// The files whose class-head rows the contexts must not read, when the + /// rows are trusted ([`Self::trust_recorded_class_heads`]). + recorded_heads_stale: Option>>, /// Detected `FrameworkResolver` implementations (`frameworks` field, /// `index.ts:264`). Empty until [`Self::initialize`] detects react/vue/nestjs. framework_resolver_extensions: Vec>, /// Distinct symbol names known to the graph, for the fast pre-filter /// (`knownNames`, `index.ts:224`). Populated by `warm_caches`. known_names: Option>, + /// `known_names` lowercased, built the first time a language whose names + /// ignore case asks (upstream #2141 `knownLowerNames`). + known_lower_names: std::sync::OnceLock>, /// Per-file directive summaries and per-root macro timelines for C/C++ /// translation-unit macro visibility (#1838), filled on demand by the /// parallel resolver and cleared with the other resolution caches. Every /// entry is a pure function of the files, so evaluation order cannot reach /// the output. macro_visibility: std::sync::Mutex, + /// The C/C++ include graph the test-suite rule reads (upstream #2421), + /// filled on demand and cleared with the other resolution caches. + cpp_includers: std::sync::Mutex, + /// Same-owner overload sets by method id (upstream #2212), filled on + /// demand and cleared with the other resolution caches. + overload_sets: crate::overloads::OverloadSets, + /// Declared type and Scala value parameters by declaration id (upstream + /// #2123), filled on demand and cleared with the other resolution caches. + type_parameters: std::sync::Mutex, /// `this.` fn-refs whose member wasn't on the enclosing class /// itself — retried in the supertype pass once implements/extends edges /// exist (`deferredThisMemberRefs`, index.ts:214 / #808). A `Mutex` (not @@ -1237,9 +1439,14 @@ impl ReferenceResolver { Self { project_root: project_root.into(), max_file_size: codegraph_core::config::DEFAULT_MAX_FILE_SIZE, + recorded_heads_stale: None, framework_resolver_extensions: Vec::new(), known_names: None, + known_lower_names: std::sync::OnceLock::new(), macro_visibility: std::sync::Mutex::default(), + cpp_includers: std::sync::Mutex::default(), + overload_sets: std::sync::Mutex::default(), + type_parameters: std::sync::Mutex::default(), deferred_this_member_refs: std::sync::Mutex::new(Vec::new()), deferred_chain_refs: std::sync::Mutex::new(Vec::new()), } @@ -1262,6 +1469,14 @@ impl ReferenceResolver { ) -> crate::context::StoreResolutionContext<'a> { crate::context::StoreResolutionContext::new(store, &self.project_root) .with_max_file_size(self.max_file_size) + .with_recorded_class_heads(self.recorded_heads_stale.clone()) + } + + /// Read class heads from the store's class-head rows for every file but + /// `stale` in the contexts this resolver builds (W5-08): a sync whose + /// rows were current when it began passes the files it changed. + pub fn trust_recorded_class_heads(&mut self, stale: std::collections::HashSet) { + self.recorded_heads_stale = Some(Arc::new(stale)); } /// Detect the project's frameworks and populate the resolver's @@ -1276,6 +1491,19 @@ impl ReferenceResolver { !self.framework_resolver_extensions.is_empty() } + /// The names of the detected `FrameworkResolver`s, sorted: what a sync + /// compares to know when a framework came or went (W5-06). + pub fn framework_names(&self) -> Vec { + let mut names: Vec = self + .framework_resolver_extensions + .iter() + .map(|resolver| resolver.name().to_string()) + .collect(); + names.sort(); + names.dedup(); + names + } + /// Run each detected resolver's `post_extract` finalization and persist the /// returned node updates (`runPostExtract`, `index.ts:276-295`). Idempotent — /// safe after every index and every incremental sync. Returns the number of @@ -1337,6 +1565,14 @@ impl ReferenceResolver { Ok(()) } + /// Whether a framework resolver's extraction of `file_path` reads other + /// files ([`crate::framework::FrameworkResolver::extraction_reads_other_files`]). + pub fn framework_extraction_reads_other_files(&self, file_path: &str) -> bool { + self.framework_resolver_extensions + .iter() + .any(|resolver| resolver.extraction_reads_other_files(file_path)) + } + /// Collect detected framework nodes and refs without mutating the Store. This /// is the selective-sync seam for unchanged dependents: base and framework /// refs can be merged and filtered by complete source-site groups first. @@ -1369,17 +1605,28 @@ impl ReferenceResolver { ) -> FrameworkExtractionCollection { let mut collection = FrameworkExtractionCollection::default(); for relative in relative_files { - let language = codegraph_extract::detect_language_with(relative, extensions); - let has_applicable = self - .framework_resolver_extensions - .iter() - .any(|resolver| applies_to_language(resolver.as_ref(), language)); + let detected = codegraph_extract::detect_language_with(relative, extensions); + // A `.h` header may turn out C++ or Objective-C once read, so it + // is read whenever a resolver applies to any language it can be. + let candidates: &[Language] = if detected == Language::C { + &[Language::C, Language::Cpp, Language::ObjC] + } else { + std::slice::from_ref(&detected) + }; + let has_applicable = self.framework_resolver_extensions.iter().any(|resolver| { + candidates + .iter() + .any(|&language| applies_to_language(resolver.as_ref(), language)) + }); if !has_applicable { continue; } let Some(content) = read_file(relative) else { continue; }; + // The file's language as extraction and its file row see it. + let language = + codegraph_extract::detect_language_for_source(relative, &content, extensions); for resolver in &self.framework_resolver_extensions { if !applies_to_language(resolver.as_ref(), language) { continue; @@ -1408,10 +1655,23 @@ impl ReferenceResolver { pub fn warm_caches(&mut self, context: &dyn ResolutionContext) { let names: BTreeSet = context.known_node_names().into_iter().collect(); self.known_names = Some(names); + self.known_lower_names = std::sync::OnceLock::new(); *self .macro_visibility .get_mut() .unwrap_or_else(std::sync::PoisonError::into_inner) = Default::default(); + *self + .cpp_includers + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Default::default(); + self.overload_sets + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + *self + .type_parameters + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Default::default(); } /// Resolve all unresolved references (`resolveAll`, `index.ts:511-572`). @@ -1505,8 +1765,33 @@ impl ReferenceResolver { reference: &RefView, context: &dyn ResolutionContext, ) -> (Option, Option) { + // A PHP function called from the global namespace (`\helper()`) is + // `helper` (upstream #2103's form). KEEP-RUST: the extractor keeps + // the `\` in the name, upstream's records the name without it. + if let Some(rooted) = php_rooted_function_call(reference) { + return self.resolve_one_pure(&rooted, context); + } let (resolved, deferred) = self.resolve_one_pure_inner(reference, context); let resolved = self.gate_target_kind(resolved, reference, context); + // A Swift type reference never lands on an `extension X {}` node, nor + // on a nested type it cannot name bare (upstream #2107). + let resolved = + crate::swift_type_visibility::gate_swift_type_target(resolved, reference, context); + // A name a declaration around the reference declares as a type + // parameter (`def f[A]`, `class Foo`) is that parameter, whichever + // strategy chose a namesake (upstream #2123). + let resolved = crate::type_parameters::gate_type_parameter( + resolved, + reference, + context, + &self.type_parameters, + ); + // A Dart call to a parameter or local calls that, never a namesake + // (upstream #2379 `gateDartLocal`). + let resolved = crate::dart_local_scope::gate_dart_local(resolved, reference, context); + // A bare Rust name reaches only what is in scope, whichever strategy + // chose it (upstream #2214 `gateRustScope`). + let resolved = crate::rust_scope::gate_rust_scope(resolved, reference, context); let resolved = self.gate_import_locality(resolved, reference, context); let resolved = self.gate_c_macro_calls(resolved, reference, context); let resolved = self.forward_alias_binding(resolved, reference, context); @@ -1519,11 +1804,171 @@ impl ReferenceResolver { } _ => self.gate_language(resolved, reference, context), }; + let resolved = self.gate_super_self_call(resolved, reference, context); + // A Dart class whose field holds its own type (`class Node { Node? + // next; }`) names itself: a self-edge that says nothing (upstream + // #2327). + let resolved = resolved.filter(|result| { + !(reference.language == Language::Dart + && reference.reference_kind == EdgeKind::References + && result.target_node_id == reference.from_node_id) + }); let resolved = self.gate_language_visibility(resolved, reference, context); let resolved = self.gate_js_visibility(resolved, reference, context); + // A JSX tag naming a local of its component renders that local + // (upstream #2442). + let resolved = crate::jsx_tags::gate_jsx_local_tag(resolved, reference, context); (resolved, deferred) } + /// `super.didMoveToWindow()` inside an override of `didMoveToWindow` calls + /// the parent's implementation, never the method making the call + /// (upstream #2136 `gateSuperSelfCall`). Extraction keeps a `super` call + /// under the bare method name, so every strategy found the enclosing + /// method itself. Only a call written through `super` / `base` (C#) / + /// `[super …]` (Objective-C) / `parent::` (PHP) / `super().` / + /// `super(C, self).` (Python) is declined; real recursion keeps its edge. + fn gate_super_self_call( + &self, + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, + ) -> Option { + let result = result?; + if reference.reference_kind != EdgeKind::Calls + || result.target_node_id != reference.from_node_id + { + return Some(result); + } + let name = &reference.reference_name; + let name = &name[name.rfind(['.', ':']).map_or(0, |cut| cut + 1)..]; + if !crate::name_matcher::is_simple_js_identifier(name) { + return Some(result); + } + let Some(line) = usize::try_from(reference.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .and_then(|index| { + context + .source_facts(&reference.file_path) + .and_then(|facts| facts.raw_line(index).map(str::to_string)) + }) + else { + return Some(result); + }; + let via_super = Regex::new(&format!( + r"(?:(?-u:\b)(?:super|base)\s*(?:\(\s*(?:[A-Za-z0-9_.]+\s*,\s*[A-Za-z0-9_]+)?\s*\))?\s*\??\.\s*|\[\s*super\s+|(?-u:\b)parent\s*::\s*){}(?-u:\b)", + regex::escape(name) + )); + match via_super { + Ok(via_super) if via_super.is_match(&line) => None, + _ => Some(result), + } + } + + /// A test suite is not linked into the program, so no name match from a + /// non-test file reaches a symbol in one (upstream #2209): typeorm's + /// `Record` is not a test entity `Record`. Test-support code a + /// project ships (`testing/`, `fakes/`, a `*-test` module) stays in reach, + /// and so does a C or C++ file the reference's translation unit includes, + /// whatever its name (#2421). No name match from another file reaches + /// into a minified script either (#2204). Upstream checks both in + /// `isVisibleAcrossFiles`, which it applies to the name match and fuzzy's + /// survivor only, so an import still binds what it names. + fn gate_test_suite( + &self, + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, + ) -> Option { + let result = result?; + let Some(target) = context.get_node_by_id_shared(&result.target_node_id) else { + return Some(result); + }; + if target.file_path == reference.file_path { + return Some(result); + } + // A vendored minified bundle keeps mangled one-letter names, so a name + // written in another file never means one of them (upstream #2204, + // in `isVisibleAcrossFiles` beside this rule): healthchecks' `$(…)`, + // jQuery loaded as a global, went to a helper inside + // bootstrap-native.min.js. Calls inside a bundle still resolve. + if is_minified_script(&target.file_path, context) { + return None; + } + if codegraph_core::file_class::is_test_suite_path(&target.file_path) + && !codegraph_core::file_class::is_test_path(&reference.file_path) + && !self.is_included_cpp_test_suite(&target, reference, context) + { + return None; + } + // A top-level binding an ES module does not export (upstream #2162): + // sveltekit's `generate_manifest.js` keeps an unexported `resolve` + // that twenty other files' `resolve(…)` calls went to. + if !crate::name_matcher::is_name_match_reachable(&target, reference, context) { + return None; + } + // A Svelte component's instance script, or a Vue SFC's ``. +pub fn read_script_blocks(facts: &SourceFacts, svelte: bool) -> Vec { + static OPEN: OnceLock = OnceLock::new(); + static CLOSE: OnceLock = OnceLock::new(); + static MODULE: OnceLock = OnceLock::new(); + static SETUP: OnceLock = OnceLock::new(); + let open_tag = OPEN.get_or_init(|| Regex::new(r"(?i)]*)>").expect("open")); + let close_tag = CLOSE.get_or_init(|| Regex::new(r"(?i)").expect("close")); + let module = MODULE.get_or_init(|| { + Regex::new(r#"(?-u:\b)module(?-u:\b)|context\s*=\s*["']module["']"#).expect("module") + }); + let setup = SETUP.get_or_init(|| Regex::new(r"(?-u:\b)setup(?-u:\b)").expect("setup")); + let mut blocks = Vec::new(); + let mut open: Option<(i64, bool)> = None; + for index in 0..facts.line_count() { + let Some(text) = facts.raw_line(index) else { + continue; + }; + let line = i64::try_from(index).unwrap_or(i64::MAX).saturating_add(1); + if open.is_none() + && let Some(tag) = open_tag.captures(text) + { + let attrs = tag.get(1).map_or("", |attrs| attrs.as_str()); + let exported = if svelte { + module.is_match(attrs) + } else { + !setup.is_match(attrs) + }; + open = Some((line, exported)); + } + if let Some((start, exported)) = open + && close_tag.is_match(text) + { + blocks.push(ScriptBlock { + start, + end: line, + exported, + }); + open = None; + } + } + blocks +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::{ScriptBlock, read_script_blocks}; + use crate::source_facts::SourceFacts; + + fn blocks(source: &str, svelte: bool) -> Vec { + read_script_blocks(&SourceFacts::new(Arc::from(source)), svelte) + } + + #[test] + fn svelte_module_scripts_export_and_instance_scripts_do_not() { + let source = "\n\n\n"; + assert_eq!( + blocks(source, true), + [ + ScriptBlock { + start: 1, + end: 3, + exported: true + }, + ScriptBlock { + start: 4, + end: 6, + exported: false + }, + ScriptBlock { + start: 7, + end: 7, + exported: true + }, + ] + ); + } + + #[test] + fn vue_plain_scripts_export_and_setup_scripts_do_not() { + let source = "\n\n\n"; + assert_eq!( + blocks(source, false), + [ + ScriptBlock { + start: 2, + end: 4, + exported: true + }, + ScriptBlock { + start: 5, + end: 7, + exported: false + }, + ] + ); + } +} diff --git a/crates/codegraph-resolve/src/shopify_themes.rs b/crates/codegraph-resolve/src/shopify_themes.rs new file mode 100644 index 00000000..25194ef1 --- /dev/null +++ b/crates/codegraph-resolve/src/shopify_themes.rs @@ -0,0 +1,62 @@ +//! A Shopify theme's section and snippet references reach that theme's own +//! files (upstream #2396, in the shape at `b635dd46`). +//! +//! `{% render 'price' %}` renders the `snippets/price.liquid` of the theme +//! the file belongs to; `{% section 'header' %}` and a JSON template's +//! section `"type"` name a file in that theme's `sections/`. Shopify never +//! looks in another theme. The Liquid extractor records these as +//! `snippets/.liquid` and `sections/.liquid`, and the path +//! matcher took any file whose path ends with that, so in a repository +//! holding several themes (a theme tool's examples, a store's seasonal +//! themes) a theme missing a section linked to another theme's copy of it. +//! +//! From a file inside a theme +//! ([`codegraph_extract::embedded::shopify::shopify_theme_root`]), these +//! references resolve to the theme's own file or to nothing, and never fall +//! through to the generic strategies. A reference from a file in no theme +//! resolves as before. + +use codegraph_core::types::{Language, NodeKind}; +use codegraph_extract::embedded::shopify::{SHOPIFY_THEME_FOLDERS, shopify_theme_root}; + +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// The file a section or snippet reference made inside a Shopify theme names +/// (upstream `matchShopifyThemeFile`): `Some` with the theme's own +/// `sections/.liquid` or `snippets/.liquid`, or nothing when the +/// theme has none, so no other strategy may guess one; `None` when the +/// reference is no such one, or is made in no theme. +pub(crate) fn match_shopify_theme_file( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + if reference.language != Language::Liquid + || !(reference.reference_name.starts_with("sections/") + || reference.reference_name.starts_with("snippets/")) + { + return None; + } + let theme = shopify_theme_root( + &reference.file_path, + &|path| context.file_exists(path), + &SHOPIFY_THEME_FOLDERS, + )?; + let target = if theme.is_empty() { + reference.reference_name.clone() + } else { + format!("{theme}/{}", reference.reference_name) + }; + let file_name = target.rsplit('/').next().unwrap_or(&target); + let found = context + .get_nodes_by_name_shared(file_name) + .into_iter() + .find(|node| node.kind == NodeKind::File && node.file_path == target); + // The confidence the path matcher gives the same link: an exact path for + // a theme at the project root, a path tail for one in a folder. + Some(found.map(|file| ResolvedRef { + original: reference.clone(), + target_node_id: file.id.clone(), + confidence: if theme.is_empty() { 0.95 } else { 0.85 }, + resolved_by: ResolvedBy::FilePath, + })) +} diff --git a/crates/codegraph-resolve/src/snapshot_context.rs b/crates/codegraph-resolve/src/snapshot_context.rs index 53ab2e08..8d4e5a9b 100644 --- a/crates/codegraph-resolve/src/snapshot_context.rs +++ b/crates/codegraph-resolve/src/snapshot_context.rs @@ -29,6 +29,7 @@ use crate::context::{DEFAULT_CACHE_LIMIT, order_candidates}; use crate::import_resolver; +use crate::package_manifests::{ManifestScope, read_manifest_scope}; use crate::path_aliases::{AliasMap, load_project_aliases}; use crate::pathutil; use crate::source_facts::SourceFacts; @@ -178,6 +179,34 @@ struct SnapshotCaches { /// same candidates are probed from every importing file, and the tree does /// not change within a pass (upstream `fileExistsMemo`). fs_exists: BoundedFileMemo, + /// Each directory's package.json / Deno import-map scope (upstream + /// #2128 `manifestScopes`). + manifest_scopes: BoundedFileMemo>, + /// Each file's functions and methods by qualified name (W5-08). + function_scopes: BoundedFileMemo>, + /// Each file's methods by owner and name (W5-08). + file_methods: BoundedFileMemo>, + /// The name matcher's memos for the run (W5-08). + name_matcher_tables: OnceLock>, + /// The Python write index for the run (upstream #2332). + python_write_index: OnceLock>, + /// The C/C++ namespace tables for the run (upstream #2176). + cpp_namespace_tables: OnceLock>, + /// The Kotlin tables for the run (upstream #2172). + kotlin_tables: OnceLock>, + scala_tables: OnceLock>, + csharp_tables: OnceLock>, + member_tables: OnceLock>, + ruby_tables: OnceLock>, + php_tables: OnceLock>, + lua_tables: OnceLock>, + dart_tables: OnceLock>, + dart_libraries: OnceLock>, + cfml_tables: OnceLock>, + swift_tables: OnceLock>, + objc_tables: OnceLock>, + go_tables: OnceLock>, + solidity_tables: OnceLock>, #[cfg(test)] counters: SnapshotCacheCounters, } @@ -193,6 +222,26 @@ impl SnapshotCaches { import_mappings: BoundedFileMemo::new(DEFAULT_CACHE_LIMIT), re_exports: BoundedFileMemo::new(DEFAULT_CACHE_LIMIT), fs_exists: BoundedFileMemo::new(crate::context::FILE_EXISTS_MEMO_LIMIT), + manifest_scopes: BoundedFileMemo::new(DEFAULT_CACHE_LIMIT), + function_scopes: BoundedFileMemo::new(DEFAULT_CACHE_LIMIT), + file_methods: BoundedFileMemo::new(DEFAULT_CACHE_LIMIT), + name_matcher_tables: OnceLock::new(), + python_write_index: OnceLock::new(), + cpp_namespace_tables: OnceLock::new(), + kotlin_tables: OnceLock::new(), + scala_tables: OnceLock::new(), + csharp_tables: OnceLock::new(), + member_tables: OnceLock::new(), + ruby_tables: OnceLock::new(), + php_tables: OnceLock::new(), + lua_tables: OnceLock::new(), + dart_tables: OnceLock::new(), + dart_libraries: OnceLock::new(), + cfml_tables: OnceLock::new(), + swift_tables: OnceLock::new(), + objc_tables: OnceLock::new(), + go_tables: OnceLock::new(), + solidity_tables: OnceLock::new(), #[cfg(test)] counters: SnapshotCacheCounters::default(), } @@ -222,6 +271,7 @@ struct NodeSnapshot { workspace_packages: Option, go_module: Option, go_modules: Vec, + rust_crates: Arc, } /// A `Sync`, immutable [`ResolutionContext`] over a precomputed [`NodeSnapshot`] @@ -353,6 +403,10 @@ impl SnapshotResolutionContext { let workspace_packages = load_workspace_packages(&project_root); let go_module = crate::context::load_go_module_pub(&project_root); let go_modules = crate::context::load_go_modules_pub(&project_root, &all_file_paths); + let rust_crates = Arc::new(crate::rust_scope::load_rust_crates( + &project_root, + &all_file_paths, + )); Ok(Self { snapshot: Arc::new(NodeSnapshot { @@ -372,6 +426,7 @@ impl SnapshotResolutionContext { workspace_packages, go_module, go_modules, + rust_crates, }), caches: Arc::new(SnapshotCaches::new()), max_file_size: codegraph_core::config::DEFAULT_MAX_FILE_SIZE, @@ -510,6 +565,12 @@ impl ResolutionContext for SnapshotResolutionContext { }) } + fn is_indexed_file(&self, file_path: &str) -> bool { + self.snapshot + .known_file_paths + .contains(&file_path.replace('\\', "/")) + } + fn read_file(&self, file_path: &str) -> Option { self.read_file_cached(file_path) .as_deref() @@ -611,6 +672,17 @@ impl ResolutionContext for SnapshotResolutionContext { } let mut supertypes: std::collections::BTreeSet = std::collections::BTreeSet::new(); for tn in &type_nodes { + // A Swift conformance to a type the project only extends + // (SwiftUI's `View`) resolves to nothing, an extension being no + // type, yet the members its extensions add are still the + // conformer's (upstream #2107). + if language == Language::Swift { + for name in crate::swift_type_visibility::extended_conformances(tn, self).iter() { + if name != type_name { + supertypes.insert(name.clone()); + } + } + } let Some(adjacency) = self.edges.get(&tn.id) else { continue; }; @@ -655,6 +727,181 @@ impl ResolutionContext for SnapshotResolutionContext { self.snapshot.workspace_packages.clone() } + fn python_write_index(&self) -> Arc { + Arc::clone( + self.caches + .python_write_index + .get_or_init(|| Arc::new(crate::python_globals::PythonWriteIndex::build(self))), + ) + } + + fn cpp_namespace_tables(&self) -> Arc { + Arc::clone( + self.caches + .cpp_namespace_tables + .get_or_init(|| Arc::new(crate::cpp_namespaces::CppNamespaceTables::new())), + ) + } + + fn kotlin_tables(&self) -> Arc { + Arc::clone( + self.caches + .kotlin_tables + .get_or_init(|| Arc::new(crate::kotlin_scope::KotlinTables::new())), + ) + } + + fn scala_tables(&self) -> Arc { + Arc::clone( + self.caches + .scala_tables + .get_or_init(|| Arc::new(crate::scala_scope::ScalaTables::new())), + ) + } + + fn csharp_tables(&self) -> Arc { + Arc::clone( + self.caches + .csharp_tables + .get_or_init(|| Arc::new(crate::csharp_types::CsharpTables::new())), + ) + } + + fn member_tables(&self) -> Arc { + Arc::clone( + self.caches + .member_tables + .get_or_init(|| Arc::new(crate::member_types::MemberTables::new())), + ) + } + + fn ruby_tables(&self) -> Arc { + Arc::clone( + self.caches + .ruby_tables + .get_or_init(|| Arc::new(crate::ruby_scope::RubyTables::new())), + ) + } + + fn php_tables(&self) -> Arc { + Arc::clone( + self.caches + .php_tables + .get_or_init(|| Arc::new(crate::php_scope::PhpTables::new())), + ) + } + + fn lua_tables(&self) -> Arc { + Arc::clone( + self.caches + .lua_tables + .get_or_init(|| Arc::new(crate::lua_scope::LuaTables::new())), + ) + } + + fn dart_tables(&self) -> Arc { + Arc::clone( + self.caches + .dart_tables + .get_or_init(|| Arc::new(crate::dart_scope::DartTables::new())), + ) + } + + fn dart_libraries(&self) -> Arc { + Arc::clone( + self.caches + .dart_libraries + .get_or_init(|| Arc::new(crate::dart_libraries::DartLibraries::new())), + ) + } + + fn cfml_tables(&self) -> Arc { + Arc::clone( + self.caches + .cfml_tables + .get_or_init(|| Arc::new(crate::cfml_scope::CfmlTables::new())), + ) + } + + fn swift_tables(&self) -> Arc { + Arc::clone( + self.caches + .swift_tables + .get_or_init(|| Arc::new(crate::swift_type_visibility::SwiftTables::new())), + ) + } + + fn objc_tables(&self) -> Arc { + Arc::clone( + self.caches + .objc_tables + .get_or_init(|| Arc::new(crate::objc_scope::ObjcTables::new())), + ) + } + + fn go_tables(&self) -> Arc { + Arc::clone( + self.caches + .go_tables + .get_or_init(|| Arc::new(crate::go_packages::GoTables::new())), + ) + } + + fn solidity_tables(&self) -> Arc { + Arc::clone( + self.caches + .solidity_tables + .get_or_init(|| Arc::new(crate::solidity_scope::SolidityTables::new())), + ) + } + + fn enclosing_function_lines( + &self, + file_path: &str, + qualified: &str, + start: i64, + end: i64, + ) -> Option<(i64, i64)> { + self.file_callables(file_path) + .first_enclosing(qualified, start, end) + } + + fn file_callables(&self, file_path: &str) -> Arc { + self.caches.function_scopes.get_or_init(file_path, || { + Arc::new(crate::name_matcher::FileCallables::of( + &self.get_nodes_in_file_shared(file_path), + )) + }) + } + + fn file_methods(&self, file_path: &str) -> Arc { + self.caches.file_methods.get_or_init(file_path, || { + Arc::new(crate::name_matcher::FileMethods::of( + &self.get_nodes_in_file_shared(file_path), + )) + }) + } + + fn method_files(&self, name: &str) -> Option>> { + Some(crate::name_matcher::method_files_of(name, self)) + } + + fn name_matcher_tables(&self) -> Arc { + Arc::clone( + self.caches + .name_matcher_tables + .get_or_init(|| Arc::new(crate::name_matcher::NameMatcherTables::new())), + ) + } + + fn manifest_scope(&self, dir: &str) -> Arc { + // A directory's parent is another key, so the memo never re-enters + // the entry it is filling. + self.caches + .manifest_scopes + .get_or_init(dir, || Arc::new(read_manifest_scope(dir, self))) + } + fn get_go_module(&self) -> Option { self.snapshot.go_module.clone() } @@ -663,6 +910,10 @@ impl ResolutionContext for SnapshotResolutionContext { self.snapshot.go_modules.clone() } + fn get_rust_crates(&self) -> Arc { + Arc::clone(&self.snapshot.rust_crates) + } + fn get_re_exports(&self, file_path: &str, language: Language) -> Vec { self.caches .re_exports @@ -791,6 +1042,56 @@ mod tests { store } + /// W5-08: the snapshot context answers a declaration's enclosing + /// function from its per-file memo exactly as the qualified-name lookup + /// does, before and after the memo holds the file. + #[test] + fn snapshot_encloses_as_the_qualified_name_lookup_does() { + let root = temp_root("enclosing"); + let mut store = Store::open(&root.join("index.db")).expect("open"); + store.upsert_file(&file_record("a.ts", 5)).unwrap(); + store.upsert_file(&file_record("b.ts", 1)).unwrap(); + let scoped = |id: &str, kind, qualified: &str, file: &str, lines: (i64, i64), col| { + let name = qualified.rsplit("::").next().unwrap_or(qualified); + let mut n = node(id, name, kind, file, lines.0, col); + n.qualified_name = qualified.to_string(); + n.end_line = lines.1; + n + }; + store + .upsert_nodes(&[ + scoped("f-a", NodeKind::Function, "f", "a.ts", (3, 20), 0), + scoped("f-b", NodeKind::Function, "f", "a.ts", (3, 20), 4), + scoped("f-outer", NodeKind::Function, "f", "a.ts", (2, 30), 0), + scoped("m", NodeKind::Method, "C::m", "a.ts", (5, 9), 2), + scoped("c", NodeKind::Class, "C", "a.ts", (4, 10), 0), + scoped("f-other", NodeKind::Function, "f", "b.ts", (1, 100), 0), + ]) + .unwrap(); + let ctx = SnapshotResolutionContext::from_store(&store, root.to_str().unwrap()) + .expect("snapshot"); + for _ in 0..2 { + for (file, qualified, start, end) in [ + ("a.ts", "f", 6, 7), + ("a.ts", "f", 25, 26), + ("a.ts", "f", 31, 31), + ("a.ts", "C::m", 6, 6), + ("a.ts", "C", 6, 6), + ("b.ts", "f", 50, 50), + ("c.ts", "f", 1, 1), + ] { + assert_eq!( + ctx.enclosing_function_lines(file, qualified, start, end), + crate::name_matcher::enclosing_by_qualified_name( + &ctx, file, qualified, start, end + ), + "{file} {qualified} {start}..={end}" + ); + } + } + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn from_store_builds_node_lookups() { let root = temp_root("build"); @@ -834,6 +1135,9 @@ mod tests { assert!(ctx.file_exists("a.ts")); assert!(ctx.file_exists("ondisk.ts")); assert!(!ctx.file_exists("nowhere.ts")); + // Only the index's own files are indexed; the disk fallback is not. + assert!(ctx.is_indexed_file("a.ts")); + assert!(!ctx.is_indexed_file("ondisk.ts")); assert!( !ctx.file_exists(&format!( "../{}/secret.ts", diff --git a/crates/codegraph-resolve/src/solidity_scope.rs b/crates/codegraph-resolve/src/solidity_scope.rs new file mode 100644 index 00000000..5e0e5bc5 --- /dev/null +++ b/crates/codegraph-resolve/src/solidity_scope.rs @@ -0,0 +1,432 @@ +//! Solidity calls written without a receiver (upstream #2178 (a), in the +//! shape at `b635dd46`): a bare call reaches a function of the contract, +//! interface or library around it, or of one it inherits through its `is` +//! clause, or a free function; never another contract's override. +//! OpenZeppelin's `_msgSender()` in Context-derived contracts went to +//! ERC2771Context's override 83 times. +//! +//! The `is` chain is read from the declarations' heads, as upstream reads +//! them: by the names they write, every declaration of a name counted. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +use codegraph_core::generated_header::is_js_whitespace; +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::types::{RefView, ResolutionContext}; + +/// How many type names one hierarchy holds (upstream's 60). +pub const HIERARCHY_LIMIT: usize = 60; + +/// How many lines after a declaration's first its head is read from +/// (upstream reads `lines.slice(startLine - 1, startLine + 10)`). +const HEAD_LINES: i64 = 10; + +/// A hierarchy by the file and line it is read at. +type Hierarchies = HashMap<(String, i64), Arc>>; + +/// The per-pass memos of the `is` chains (upstream `SOLIDITY_SUPERS` and +/// `SOLIDITY_HIERARCHIES`). +#[derive(Debug, Default)] +pub struct SolidityTables { + supers: Mutex>>>, + hierarchies: Mutex, +} + +impl SolidityTables { + pub fn new() -> Self { + Self::default() + } +} + +/// The kinds a Solidity contract, interface, library or struct is +/// (upstream `SOLIDITY_TYPE_KINDS`). +pub fn is_solidity_type_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Class + | NodeKind::Interface + | NodeKind::Struct + | NodeKind::Module + | NodeKind::Trait + ) +} + +/// `^[A-Za-z_]\w*$`: a call's name with no receiver written into it. +fn is_bare_name(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// An ASCII word character (JavaScript's `\w`). +fn is_word_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +/// `//[^\n]*|/\*[\s\S]*?\*/`: the comments of a head. +fn comment_pattern() -> &'static Regex { + static PATTERN: OnceLock = OnceLock::new(); + PATTERN + .get_or_init(|| Regex::new(r"//[^\n]*|/\*(?s:.)*?\*/").expect("Solidity comment pattern")) +} + +/// The supertypes a Solidity declaration's head names (upstream +/// `soliditySupertypesOf`, one declaration's share): `head` is its first +/// line and the ten after it, joined by newlines. Comments are dropped, then +/// the constructor arguments of a base (`ERC165(…)`), and the head ends at +/// the body's `{`; every name the `is` clause lists follows, a dotted one by +/// its last name (`B.Pausable` is `Pausable`). KEEP-RUST: a `//` comment +/// drops only the rest of its own line; upstream joins the lines with spaces +/// first, so a comment inside a head drops the bases written below it. +pub fn written_supertypes(head: &str) -> Vec { + let text = comment_pattern().replace_all(head, " ").replace('\n', " "); + let mut depth = 0usize; + let mut kept = String::new(); + for c in text.chars() { + if c == '{' && depth == 0 { + break; + } + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + _ if depth == 0 => kept.push(c), + _ => {} + } + } + is_clause(&kept).map(listed_names).unwrap_or_default() +} + +/// What follows the first `is` of a head (`/\bis\b([\s\S]*)$/`). +fn is_clause(head: &str) -> Option<&str> { + let bytes = head.as_bytes(); + head.match_indices("is") + .find(|(at, _)| { + (*at == 0 || !is_word_byte(bytes[at - 1])) + && bytes.get(at + 2).is_none_or(|b| !is_word_byte(*b)) + }) + .map(|(at, _)| &head[at + 2..]) +} + +/// The names an `is` clause lists (`/([A-Za-z_]\w*)\s*(?=,|$)/g`): each +/// word followed by a comma or the clause's end, from its first letter or +/// underscore. +fn listed_names(clause: &str) -> Vec { + let bytes = clause.as_bytes(); + let mut names = Vec::new(); + let mut at = 0; + while at < bytes.len() { + if !is_word_byte(bytes[at]) { + at += 1; + continue; + } + let start = at; + while at < bytes.len() && is_word_byte(bytes[at]) { + at += 1; + } + let rest = clause[at..].trim_start_matches(is_js_whitespace); + if (rest.is_empty() || rest.starts_with(',')) + && let Some(first) = + clause[start..at].find(|c: char| c.is_ascii_alphabetic() || c == '_') + { + names.push(clause[start + first..at].to_string()); + } + } + names +} + +/// A declaration's head as [`written_supertypes`] reads it: its first line +/// and the ten after it, `\r` dropped, joined by newlines, from `line` (a +/// 0-based line's text). +fn head_of<'a>(start_line: i64, line: impl Fn(usize) -> Option<&'a str>) -> String { + let mut out = Vec::new(); + for number in start_line.max(1)..=start_line + HEAD_LINES { + let Some(text) = usize::try_from(number - 1).ok().and_then(&line) else { + break; + }; + out.push(text.trim_end_matches('\r')); + } + out.join("\n") +} + +/// What the Solidity declarations named `name` inherit, every one of them +/// read (upstream `soliditySupertypesOf`). +fn supertypes_of(name: &str, context: &dyn ResolutionContext) -> Arc> { + let tables = context.solidity_tables(); + if let Some(hit) = tables + .supers + .lock() + .ok() + .and_then(|memo| memo.get(name).cloned()) + { + return hit; + } + let mut supers: Vec = Vec::new(); + for decl in context.get_nodes_by_name_shared(name) { + if decl.language != Language::Solidity || !is_solidity_type_kind(decl.kind) { + continue; + } + let Some(facts) = context.source_facts(&decl.file_path) else { + continue; + }; + supers.extend(written_supertypes(&head_of(decl.start_line, |index| { + facts.raw_line(index) + }))); + } + let supers = Arc::new(supers); + if let Ok(mut memo) = tables.supers.lock() { + memo.insert(name.to_string(), Arc::clone(&supers)); + } + supers +} + +/// The names of the types around `reference` and of every type they +/// inherit, at most 60 (upstream's per-reference memo, here per line). +fn hierarchy_at(reference: &RefView, context: &dyn ResolutionContext) -> Arc> { + let tables = context.solidity_tables(); + let key = (reference.file_path.clone(), reference.line); + if let Some(hit) = tables + .hierarchies + .lock() + .ok() + .and_then(|memo| memo.get(&key).cloned()) + { + return hit; + } + let mut queue: VecDeque = context + .get_nodes_in_file_shared(&reference.file_path) + .iter() + .filter(|node| { + is_solidity_type_kind(node.kind) + && node.start_line <= reference.line + && node.end_line >= reference.line + }) + .map(|node| node.name.clone()) + .collect(); + let mut names: HashSet = HashSet::new(); + while names.len() < HIERARCHY_LIMIT { + let Some(name) = queue.pop_front() else { + break; + }; + if names.contains(&name) { + continue; + } + queue.extend(supertypes_of(&name, context).iter().cloned()); + names.insert(name); + } + let names = Arc::new(names); + if let Ok(mut memo) = tables.hierarchies.lock() { + memo.insert(key, Arc::clone(&names)); + } + names +} + +/// Whether a Solidity call is written with no receiver (upstream +/// `isReceiverLessSolidityCall`): `_msgSender()`, not `token._msgSender()`. +/// The line's first `name(` not inside a longer name is read, as upstream +/// reads it, so a modifier written without arguments (`onlyOwner`) is not +/// judged. This extractor writes a call's receiver into its name +/// (`super.f`, `SafeCast.toUint48`), so the `.` before `name(` matters only +/// when the same name is called through a receiver earlier on the line. +pub(crate) fn is_receiver_less_solidity_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if reference.language != Language::Solidity + || reference.reference_kind != EdgeKind::Calls + || !is_bare_name(&reference.reference_name) + { + return false; + } + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let Some(line) = usize::try_from(reference.line - 1) + .ok() + .and_then(|index| facts.raw_line(index)) + else { + return false; + }; + let name = reference.reference_name.as_str(); + let bytes = line.as_bytes(); + line.match_indices(name) + .find(|(at, _)| { + (*at == 0 || !(is_word_byte(bytes[at - 1]) || bytes[at - 1] == b'$')) + && line[at + name.len()..] + .trim_start_matches(is_js_whitespace) + .starts_with('(') + }) + .is_some_and(|(at, _)| !line[..at].trim_end_matches(is_js_whitespace).ends_with('.')) +} + +/// Whether a bare Solidity call can mean `node` (upstream +/// `isSolidityMemberInScope`): a function of a contract around the call or +/// of one it inherits; anything but a Solidity method (a free function) is +/// not judged. +pub(crate) fn is_solidity_member_in_scope( + node: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + solidity_method_owner(node).is_none_or(|owner| hierarchy_at(reference, context).contains(owner)) +} + +/// The type a Solidity method belongs to, by the last name of the path its +/// qualified name is under; `None` for anything else, which a bare call's +/// chain does not judge. +pub fn solidity_method_owner(node: &Node) -> Option<&str> { + if node.kind != NodeKind::Method || node.language != Language::Solidity { + return None; + } + let (path, _) = node.qualified_name.rsplit_once("::")?; + (!path.is_empty()).then(|| path.rsplit("::").next().unwrap_or(path)) +} + +/// Key prefix of the Solidity head rows (W4-SOL-01): one `project_metadata` +/// row per Solidity file with a type, written by every full index and +/// migration and kept current by sync. +pub const HEAD_ROW_KEY_PREFIX: &str = "solidity_heads:"; + +/// The `project_metadata` key of `file_path`'s head row. +pub fn head_row_key(file_path: &str) -> String { + format!("{HEAD_ROW_KEY_PREFIX}{file_path}") +} + +/// One type declaration of a Solidity file: its name and what its head +/// inherits, as [`written_supertypes`] reads it. +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct HeadEntry { + pub name: String, + pub supers: Vec, +} + +/// What the `is` chains read of a Solidity file: an entry per type it +/// declares, sorted. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeadRow { + pub types: Vec, +} + +impl HeadRow { + /// The row of a Solidity file whose text is `source` and whose nodes are + /// `nodes`. + pub fn of(source: &str, nodes: &[Node]) -> Self { + let lines: Vec<&str> = source.split('\n').collect(); + let mut types: Vec = nodes + .iter() + .filter(|node| node.language == Language::Solidity && is_solidity_type_kind(node.kind)) + .map(|node| HeadEntry { + name: node.name.clone(), + supers: written_supertypes(&head_of(node.start_line, |index| { + lines.get(index).copied() + })), + }) + .collect(); + types.sort(); + Self { types } + } + + /// Nothing for the chains to read: no type. + pub fn is_empty(&self) -> bool { + self.types.is_empty() + } + + pub fn encode(&self) -> String { + serde_json::to_string(self).expect("Solidity head row encodes") + } + + pub fn decode(value: &str) -> Option { + serde_json::from_str(value).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn supers(head: &str) -> Vec { + written_supertypes(head) + } + + #[test] + fn an_is_clause_is_read_as_written() { + assert_eq!( + supers("abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor {"), + ["Context", "ERC165", "EIP712", "Nonces", "IGovernor"] + ); + assert_eq!( + supers("contract Token is ERC20(\"Token\", \"TKN\"), Ownable(msg.sender) {"), + ["ERC20", "Ownable"] + ); + assert_eq!(supers("contract A is B.Pausable {"), ["Pausable"]); + assert_eq!( + supers( + "contract Vault is\n Ownable(msg.sender), // the owner\n B.Pausable,\n /* nonces */ Middle\n{" + ), + ["Ownable", "Pausable", "Middle"] + ); + assert_eq!( + supers("library Math {\n function add() {}\n}"), + Vec::::new() + ); + assert_eq!(supers("interface IThis is IERC165 {"), ["IERC165"]); + // A name merely starting with `is` is no clause. + assert_eq!(supers("contract isolated {"), Vec::::new()); + assert_eq!(supers("contract A is /* B, */ C {"), ["C"]); + } + + #[test] + fn a_head_row_lists_each_type_with_its_bases() { + let source = "pragma solidity ^0.8.20;\n\ncontract B is A {}\n\ninterface I {}\n"; + let node = |kind: NodeKind, name: &str, line: i64| Node { + id: format!("{}:{name}", kind.as_str()), + kind, + name: name.to_string(), + qualified_name: name.to_string(), + file_path: "x.sol".to_string(), + language: Language::Solidity, + start_line: line, + end_line: line, + start_column: 0, + end_column: 1, + docstring: None, + signature: None, + visibility: None, + is_exported: false, + is_async: false, + is_static: false, + is_abstract: false, + decorators: Vec::new(), + type_parameters: Vec::new(), + return_type: None, + updated_at: 0, + }; + let row = HeadRow::of( + source, + &[ + node(NodeKind::Interface, "I", 5), + node(NodeKind::Class, "B", 3), + node(NodeKind::Method, "m", 3), + ], + ); + assert_eq!( + row.types, + [ + HeadEntry { + name: "B".to_string(), + supers: vec!["A".to_string()], + }, + HeadEntry { + name: "I".to_string(), + supers: Vec::new(), + }, + ] + ); + assert_eq!(HeadRow::decode(&row.encode()), Some(row)); + } +} diff --git a/crates/codegraph-resolve/src/source_facts.rs b/crates/codegraph-resolve/src/source_facts.rs index 59c4a24a..23658e55 100644 --- a/crates/codegraph-resolve/src/source_facts.rs +++ b/crates/codegraph-resolve/src/source_facts.rs @@ -14,7 +14,7 @@ use crate::awaited::AwaitedIndex; use crate::object_literal::LiteralLookup; use crate::strip_comments::{CommentLang, blank_string_contents, strip_comments_for_regex}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::{Arc, Mutex, OnceLock}; /// Newline positions of a text. @@ -51,6 +51,12 @@ impl LineIndex { } } + /// How many segments `text.split('\n')` yields: one more than the + /// newlines (W5-08). + pub fn raw_line_count(&self) -> usize { + self.newlines.len() + 1 + } + /// Segment `index` of `text.split('\n')` (a `\r` before the newline is /// kept, and a final empty segment after a trailing newline exists). pub fn raw_line<'t>(&self, text: &'t str, index: usize) -> Option<&'t str> { @@ -125,6 +131,7 @@ pub struct SourceFacts { python_comment_free: OnceLock<(String, LineIndex)>, ts_blanked: OnceLock, js_sealed_module: OnceLock, + js_module_exports: OnceLock>>, js_binding_sites: OnceLock, js_local_bindings: Mutex>, node_decisions: Mutex>, @@ -136,6 +143,40 @@ pub struct SourceFacts { awaited_raw_names: OnceLock>, awaited_index: OnceLock>, rust_use_scopes: OnceLock, + /// What the file's `use` items bring in, read against the crates of the + /// pass that first asked. + rust_uses: OnceLock<( + Arc, + Arc, + )>, + python_from_imports: OnceLock>, + python_module_symbols: Mutex>>, + python_file_scan: OnceLock>, + python_name_scans: Mutex>>, + python_globals: Mutex>>, + python_plugged_modules: OnceLock>, + python_fixture_types: Mutex>>, + minified_script: OnceLock, + csharp_scopes: OnceLock>, + sfc_script_blocks: OnceLock>, + js_destructured_calls: OnceLock>, + js_object_scan: OnceLock>, + cpp_local_alias_lines: Mutex>>>, + cpp_template_parameters: Mutex>>>, + cpp_class_bases: Mutex>>>, + cpp_usings: OnceLock>>, + java_file_scope: OnceLock>, + java_heads: Mutex>>, + kotlin_file_scope: OnceLock>, + kotlin_brace_frames: OnceLock>>, + go_scope_index: OnceLock>, + scala_imports: OnceLock>, + scala_holder_package: OnceLock>, + scala_package_object_package: OnceLock>, + csharp_namespace_scope: OnceLock>, + dart_code: OnceLock>, + dart_local_scopes: OnceLock>, + erlang_imports: OnceLock>>, } impl SourceFacts { @@ -148,6 +189,7 @@ impl SourceFacts { python_comment_free: OnceLock::new(), ts_blanked: OnceLock::new(), js_sealed_module: OnceLock::new(), + js_module_exports: OnceLock::new(), js_binding_sites: OnceLock::new(), js_local_bindings: Mutex::new(HashMap::new()), node_decisions: Mutex::new(HashMap::new()), @@ -159,6 +201,35 @@ impl SourceFacts { awaited_raw_names: OnceLock::new(), awaited_index: OnceLock::new(), rust_use_scopes: OnceLock::new(), + rust_uses: OnceLock::new(), + python_from_imports: OnceLock::new(), + python_module_symbols: Mutex::new(HashMap::new()), + python_file_scan: OnceLock::new(), + python_name_scans: Mutex::new(HashMap::new()), + python_globals: Mutex::new(HashMap::new()), + python_plugged_modules: OnceLock::new(), + python_fixture_types: Mutex::new(HashMap::new()), + minified_script: OnceLock::new(), + csharp_scopes: OnceLock::new(), + sfc_script_blocks: OnceLock::new(), + js_destructured_calls: OnceLock::new(), + js_object_scan: OnceLock::new(), + cpp_local_alias_lines: Mutex::new(HashMap::new()), + cpp_template_parameters: Mutex::new(HashMap::new()), + cpp_class_bases: Mutex::new(HashMap::new()), + cpp_usings: OnceLock::new(), + java_file_scope: OnceLock::new(), + java_heads: Mutex::new(HashMap::new()), + kotlin_file_scope: OnceLock::new(), + kotlin_brace_frames: OnceLock::new(), + go_scope_index: OnceLock::new(), + scala_imports: OnceLock::new(), + scala_holder_package: OnceLock::new(), + scala_package_object_package: OnceLock::new(), + csharp_namespace_scope: OnceLock::new(), + dart_code: OnceLock::new(), + dart_local_scopes: OnceLock::new(), + erlang_imports: OnceLock::new(), } } @@ -167,13 +238,64 @@ impl SourceFacts { &self.source } + /// The file's Dart code with comments and string contents blanked, read + /// once (W4-DART-03, upstream #2376 `dartCodeOf`). + pub(crate) fn dart_code(&self) -> Arc { + Arc::clone( + self.dart_code + .get_or_init(|| Arc::new(crate::dart_chains::DartCode::of(&self.source))), + ) + } + + /// The parameters, loop variables and locals the Dart file binds, read + /// once (W4-DART-03c, upstream #2379 `fileScopes`). + pub(crate) fn dart_local_scopes(&self) -> Arc { + Arc::clone(self.dart_local_scopes.get_or_init(|| { + Arc::new(crate::dart_local_scope::DartLocalScopes::of( + &self.dart_code().text, + )) + })) + } + + /// The `-import` attributes of the Erlang file, read once (W4-ERL-01, + /// upstream #2178 `erlangImportedModule`). + pub(crate) fn erlang_imports(&self) -> Arc> { + Arc::clone( + self.erlang_imports + .get_or_init(|| Arc::new(crate::erlang_imports::erlang_imports_of(&self.source))), + ) + } + /// Whether a Rust `use` item visible at byte `offset` names `name` or is a /// glob (KEEP-RUST, P15 R4); see [`crate::rust_lexical::RustUseScopes`]. The /// file's `use` items are scanned once, on first ask. pub(crate) fn rust_use_in_scope(&self, offset: usize, name: &str) -> bool { + self.rust_use_scopes().covers(offset, name) + } + + fn rust_use_scopes(&self) -> &crate::rust_lexical::RustUseScopes { self.rust_use_scopes .get_or_init(|| crate::rust_lexical::RustUseScopes::scan(&self.source)) - .covers(offset, name) + } + + /// What the file's `use` items bring in, outside crates told apart by + /// `crates` (upstream #2214 `rustUsesOf`), read once per crate set. + pub(crate) fn rust_uses( + &self, + crates: &Arc, + ) -> Arc { + let read = || { + Arc::new(crate::rust_scope::RustUses::read( + self.rust_use_scopes(), + crates, + )) + }; + let (cached_crates, uses) = self.rust_uses.get_or_init(|| (Arc::clone(crates), read())); + if Arc::ptr_eq(cached_crates, crates) || cached_crates == crates { + Arc::clone(uses) + } else { + read() + } } /// Line index of the raw source. @@ -226,10 +348,32 @@ impl SourceFacts { /// [`Self::ts_comment_free`] with every string and template literal's /// contents blanked too (`blankStringContents(stripCommentsForRegex(…))`), - /// so line-anchored patterns see only code. + /// so line-anchored patterns see only code. The comment-free text it is + /// made from is kept only when [`Self::ts_comment_free`] was asked for. pub fn ts_blanked(&self) -> &str { - self.ts_blanked - .get_or_init(|| blank_string_contents(self.ts_comment_free().0)) + self.ts_blanked.get_or_init(|| self.make_ts_blanked()) + } + + /// `read` over [`Self::ts_blanked`]'s text without keeping it: the text + /// another reader kept, else one made for `read` and dropped after. For + /// a fact read once per file (whether it is an ES module, what it + /// exports), so a file that answers it only for other files' references + /// keeps no copy of itself. + pub fn with_ts_blanked(&self, read: impl FnOnce(&str) -> T) -> T { + match self.ts_blanked.get() { + Some(code) => read(code), + None => read(&self.make_ts_blanked()), + } + } + + fn make_ts_blanked(&self) -> String { + match self.ts_comment_free.get() { + Some((code, _)) => blank_string_contents(code), + None => blank_string_contents(&strip_comments_for_regex( + &self.source, + CommentLang::TypeScript, + )), + } } /// Where a JS/TS local binding can start in the raw source. @@ -249,6 +393,44 @@ impl SourceFacts { *self.js_sealed_module.get_or_init(|| compute(self)) } + /// The names this ES module's export lists name, or `None` when it is no + /// ES module (upstream #2162); `read` runs once. + pub fn js_module_exports( + &self, + read: impl FnOnce(&Self) -> Option>, + ) -> Option<&HashSet> { + self.js_module_exports.get_or_init(|| read(self)).as_ref() + } + + /// This file's `const { … } = f(…)` statements, or `None` when it has none + /// (upstream #2163); `read` runs once. + pub fn js_destructured_calls( + &self, + read: impl FnOnce(&Self) -> Option, + ) -> Option<&crate::destructured_calls::DestructuredFile> { + self.js_destructured_calls + .get_or_init(|| read(self)) + .as_ref() + } + + /// What the object-literal lookups read from this JS/TS file (upstream + /// #2363); `read` runs once. + pub(crate) fn js_object_scan( + &self, + read: impl FnOnce(&Self) -> crate::object_owners::JsObjectScan, + ) -> Arc { + Arc::clone(self.js_object_scan.get_or_init(|| Arc::new(read(self)))) + } + + /// A Svelte or Vue component's `