From e8bee7caf2b835c1487a658b7b095deff9194cd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 05:53:55 +0000 Subject: [PATCH] Split the finder into its stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ciach ran the server session and also collected candidates, fetched what the verdict reads, decided each finding, and spelled it: 1000 lines with five jobs. Each is now a collaborator, and Ciach keeps the session and the pipeline. - CandidateCollector: which declarations get checked — the symbol walk, the option filters, entry points and their containers. - ReferenceFetch: the server round trips — references, tokens, syntax nodes — into the SourceIndex. - Settler: from references to sorted findings. - Verdict: what becomes of one unused candidate — suppressed, report-only, or a finding — and its hints. A move, not a change: the tests and the verbose narration are the same. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011fqaUyaQtY7AkuTYBjAUJv --- lib/src/candidate_collector.dart | 292 ++++++++++ lib/src/finder.dart | 893 ++----------------------------- lib/src/reference_fetch.dart | 152 ++++++ lib/src/settler.dart | 313 +++++++++++ lib/src/verdict.dart | 204 +++++++ 5 files changed, 1005 insertions(+), 849 deletions(-) create mode 100644 lib/src/candidate_collector.dart create mode 100644 lib/src/reference_fetch.dart create mode 100644 lib/src/settler.dart create mode 100644 lib/src/verdict.dart diff --git a/lib/src/candidate_collector.dart b/lib/src/candidate_collector.dart new file mode 100644 index 0000000..abba1ab --- /dev/null +++ b/lib/src/candidate_collector.dart @@ -0,0 +1,292 @@ +import 'dart:io'; + +import 'package:ciach/src/candidates.dart'; +import 'package:ciach/src/conventions/entry_points.dart'; +import 'package:ciach/src/conventions/freezed.dart'; +import 'package:ciach/src/lsp/lsp_client.dart'; +import 'package:ciach/src/lsp/outline.dart'; +import 'package:ciach/src/lsp/semantic_tokens.dart'; +import 'package:ciach/src/models.dart'; +import 'package:ciach/src/paths.dart'; +import 'package:ciach/src/reference_fetch.dart'; +import 'package:ciach/src/source_index.dart'; +import 'package:ciach/src/symbols.dart'; +import 'package:pro_lsp/pro_lsp.dart' show DocumentSymbol, Range; + +/// Which declarations get their references checked: every document symbol +/// of a scanned file, less the kinds, visibilities, entry points and +/// conventions the options leave out. +final class CandidateCollector { + CandidateCollector({ + required this.options, + required SourceIndex sources, + required FreezedUnions freezed, + }) : _sources = sources, + _freezed = freezed; + + final FinderOptions options; + final SourceIndex _sources; + + /// Freezed-union tracking, fed as candidates are collected. + final FreezedUnions _freezed; + + late final _entryPoints = EntryPoints(options.entryPoints); + + /// Skipped as entry points this run, for `--verbose`. + final _skippedEntryPoints = <_SkippedEntryPoint>[]; + + /// Types whose member is an entry point, by `(relative path, type name)`: + /// the generated call that reaches `MyPlugin.registerWith` names `MyPlugin` + /// too, so the type is not a candidate either. + final _entryPointContainers = {}; + + void _report(String message) => options.onProgress?.call(message); + + /// The declarations of the open file [path] worth checking. + Future> collect( + LspClient client, + String path, + String rootPath, + ) async { + final uri = File(path).uri; + final (symbols, outline, tokens) = await ( + client.documentSymbol(uri), + client.outline(uri), + semanticTokensOrEmpty(client, _sources, path), + ).wait; + _sources.cacheSemanticTokens(path, tokens); + final relativePath = relativePosix(path, rootPath); + final out = []; + _collect( + uri, + path, + relativePath, + symbols, + null, + null, + false, + _OutlineIndex(outline), + out, + ); + return _withoutEntryPointContainers(out, relativePath); + } + + /// One line per skipped entry point, except the ubiquitous `main`. + void reportSkipped() { + if (options.onProgress == null) { + return; + } + _skippedEntryPoints.sort((a, b) { + final byFile = a.path.compareTo(b.path); + return byFile != 0 ? byFile : a.line.compareTo(b.line); + }); + for (final skipped in _skippedEntryPoints) { + if (skipped.name == 'main') { + continue; + } + _report( + 'Skipped ${skipped.path}:${skipped.line} ${skipped.name}: ' + '${skipped.reason}.', + ); + } + } + + /// [candidates] less the types a member entry point lives in; see + /// [_entryPointContainers]. Their other members stay candidates. + List _withoutEntryPointContainers( + List candidates, + String relativePath, + ) { + if (_entryPointContainers.isEmpty) { + return candidates; + } + final kept = []; + for (final candidate in candidates) { + final symbol = candidate.symbol; + final rule = + candidate.container == null && typeLikeKinds.contains(symbol.kind) + ? _entryPointContainers[DeclKey(relativePath, symbol.name)] + : null; + if (rule == null) { + kept.add(candidate); + continue; + } + _skippedEntryPoints.add(( + path: relativePath, + line: symbol.selectionRange.start.line + 1, + name: symbol.name, + reason: 'declares the entry point ${rule.name}', + )); + } + return kept; + } + + /// Recursively walks the symbol tree, keeping only symbols worth checking, + /// and records the enclosing type name as their container. + /// + /// [parentIsEnum] marks children of an enum declaration so their enum values + /// are remapped to the `enum-value` kind. + void _collect( + Uri uri, + String path, + String relativePath, + List symbols, + String? container, + Candidate? containerCandidate, + bool parentIsEnum, + _OutlineIndex outlines, + List out, + ) { + // A field statement's doc comment, annotations and modifiers sit on its + // first declarator, so a later one (`b` in `@override final int a, b;`) + // reads that statement's instead of its own, which are empty. + var statementMetadata = const []; + for (final symbol in symbols) { + final outline = outlines[symbol]; + if (outline == null) { + _report( + 'Skipped $relativePath:${symbol.selectionRange.start.line + 1} ' + "${symbol.name}: the analysis server's outline has no entry for it.", + ); + continue; + } + final ownMetadata = _sources.leadingMetadata(path, outline); + final isField = outline.element.kind == .field; + final continuesStatement = + isField && outline.range.start == outline.codeRange.start; + final leadingMetadata = continuesStatement + ? statementMetadata + : ownMetadata; + statementMetadata = isField && !continuesStatement + ? ownMetadata.toList() + : const []; + _freezed.noteIfAnnotated(path, symbol, leadingMetadata); + final candidate = Candidate( + uri: uri, + path: path, + symbol: symbol, + outline: outline, + container: container, + containerSymbol: containerCandidate?.symbol, + containerOutline: containerCandidate?.outline, + isEnumValue: parentIsEnum && symbol.kind == .enum$, + isPreventInstantiationCtor: symbol.isPreventInstantiationMarker( + symbols, + ), + ); + if (_shouldConsider(relativePath, candidate, leadingMetadata)) { + out.add(candidate); + } + final isTypeLike = typeLikeKinds.contains(symbol.kind); + _collect( + uri, + path, + relativePath, + symbol.children ?? const [], + isTypeLike ? symbol.name : container, + isTypeLike ? candidate : containerCandidate, + symbol.kind == .enum$, + outlines, + out, + ); + } + } + + /// Whether [candidate] should have its references checked. + bool _shouldConsider( + String relativePath, + Candidate candidate, + Iterable leadingMetadata, + ) { + final symbol = candidate.symbol; + final container = candidate.container; + if (!options.kinds.contains( + symbol.reportedKind( + parentIsEnum: candidate.isEnumValue, + isExtensionType: candidate.isExtensionType, + ), + )) { + return false; + } + // Called by a framework or tool, with no source reference to find. + if (_entryPoints.match(relativePath, symbol, container) case final rule?) { + _skippedEntryPoints.add(( + path: relativePath, + line: symbol.selectionRange.start.line + 1, + name: rule.name, + reason: rule.reason, + )); + if (container != null) { + _entryPointContainers.putIfAbsent( + DeclKey(relativePath, container), + () => rule, + ); + } + return false; + } + if (symbol.kind == .namespace && + !candidate.isExtension && + !candidate.isExtensionType) { + return false; + } + if (!isPrivateName(symbol.name) && !options.includePublic) { + return false; + } + if (options.skipOperators && symbol.isOperator) { + return false; + } + // Always skipped (no flag): implicit-call syntax is unresolvable, like + // operators. + if (symbol.isCallMethod) { + return false; + } + // Already represented by the header's constructor symbol. + if (symbol.isPrimaryConstructorBody) { + return false; + } + + if (options.skipOverrides && + leadingMetadata.any((t) => t.isAnnotationNamed('override'))) { + return false; + } + // Symbols reachable from native code / reflection are not really unused. + if (leadingMetadata.any( + (t) => t.type == 'string' && t.text.contains('vm:entry-point'), + )) { + return false; + } + return true; + } +} + +/// A skipped entry point: root-relative POSIX path, one-based line, the name +/// as the rule spells it, and why. +typedef _SkippedEntryPoint = ({ + String path, + int line, + String name, + String reason, +}); + +/// Outline nodes by document symbol: a symbol's `range` is its node's +/// `codeRange`. +final class _OutlineIndex { + _OutlineIndex(Outline root) + : _byCodeRange = { + for (final node in root.descendants) _key(node.codeRange): node, + }; + + final Map<_RangeKey, Outline> _byCodeRange; + + Outline? operator [](DocumentSymbol symbol) => + _byCodeRange[_key(symbol.range)]; + + static _RangeKey _key(Range range) => ( + range.start.line, + range.start.character, + range.end.line, + range.end.character, + ); +} + +typedef _RangeKey = (int, int, int, int); diff --git a/lib/src/finder.dart b/lib/src/finder.dart index f57f16b..c8042c4 100644 --- a/lib/src/finder.dart +++ b/lib/src/finder.dart @@ -11,29 +11,19 @@ import 'dart:async'; import 'dart:io'; +import 'package:ciach/src/candidate_collector.dart'; import 'package:ciach/src/candidates.dart'; import 'package:ciach/src/concurrency.dart'; -import 'package:ciach/src/conventions/entry_points.dart'; -import 'package:ciach/src/conventions/flutter_widgets.dart'; import 'package:ciach/src/conventions/freezed.dart'; -import 'package:ciach/src/conventions/serialization.dart'; -import 'package:ciach/src/cross_library_refs.dart'; import 'package:ciach/src/file_discovery.dart'; import 'package:ciach/src/lsp/lsp_client.dart'; -import 'package:ciach/src/lsp/outline.dart'; -import 'package:ciach/src/lsp/semantic_tokens.dart'; import 'package:ciach/src/models.dart'; -import 'package:ciach/src/overrides.dart'; import 'package:ciach/src/paths.dart'; import 'package:ciach/src/reference_classifier.dart'; -import 'package:ciach/src/remove_safety.dart'; +import 'package:ciach/src/reference_fetch.dart'; +import 'package:ciach/src/settler.dart'; import 'package:ciach/src/source_index.dart'; -import 'package:ciach/src/superclasses.dart'; -import 'package:ciach/src/symbols.dart'; -import 'package:ciach/src/syntax_rules.dart'; -import 'package:path/path.dart' as p; -import 'package:pro_lsp/pro_lsp.dart' - show DocumentSymbol, Location, Position, Range, SelectionRange; +import 'package:ciach/src/verdict.dart'; /// Finds declarations that are never referenced by driving the Dart analysis /// server over LSP. @@ -42,11 +32,12 @@ import 'package:pro_lsp/pro_lsp.dart' /// `textDocument/references` query is issued at the declaration's name, with /// `includeDeclaration: false`. An empty result means the declaration is unused. /// -/// `Ciach` owns the pipeline (discover → collect → check references → report); -/// the semantic pieces live in collaborators: [ReferenceClassifier] decides -/// used/unused, [RemoveSafety] flags findings that can't be auto-removed, and -/// the `conventions/` rules ([EntryPoints], [FreezedUnions], serialization and -/// Flutter widgets) keep framework-driven declarations alive. +/// `Ciach` owns the server session and the pipeline (discover → collect → +/// fetch → settle); each stage is a collaborator: [CandidateCollector] decides +/// what to check, [ReferenceFetch] asks the server, [Settler] turns the answers +/// into findings with [ReferenceClassifier] deciding used/unused, [Verdict] +/// deciding how a dead candidate is reported, and the `conventions/` rules +/// keeping framework-driven declarations alive. class Ciach { /// Creates a finder that runs with the given [options]. Ciach(this.options); @@ -61,40 +52,30 @@ class Ciach { /// Freezed-union tracking, fed as candidates are collected. final _freezed = FreezedUnions(); - late final _entryPoints = EntryPoints(options.entryPoints); - - /// Skipped as entry points this run, for `--verbose`. - final _skippedEntryPoints = <_SkippedEntryPoint>[]; - - /// Types whose member is an entry point, by `(relative path, type name)`: - /// the generated call that reaches `MyPlugin.registerWith` names `MyPlugin` - /// too, so the type is not a candidate either. - final _entryPointContainers = {}; - late final _classifier = ReferenceClassifier( _sources, unusedUnionMembers: options.unusedUnionMembers, ); - /// Advisory note attached to a sole, zero-parameter private constructor - /// (`Foo._();`) — the classic prevent-instantiation marker. Such a - /// constructor is still reported (and removable) like any other dead code, - /// but the note points at the idiomatic alternative. - static const _preventInstantiationHint = - 'looks like a prevent-instantiation constructor — for a ' - 'non-instantiable static-only class, prefer `abstract final class`'; - - static const _primaryConstructorHint = - 'primary constructor — declared in the class header, so it cannot be ' - 'removed without removing the class'; + late final _collector = CandidateCollector( + options: options, + sources: _sources, + freezed: _freezed, + ); - static const _declaringParameterHint = - 'declaring parameter of the primary constructor — removing it changes ' - 'the constructor signature at every call site'; + late final _fetch = ReferenceFetch(options: options, sources: _sources); - static const _overriddenHint = - 'overridden by a declaration --remove will not delete — that override ' - 'would be left overriding nothing'; + late final _settler = Settler( + options: options, + sources: _sources, + freezed: _freezed, + classifier: _classifier, + verdict: Verdict( + options: options, + sources: _sources, + classifier: _classifier, + ), + ); void _report(String message) => options.onProgress?.call(message); @@ -134,11 +115,8 @@ class Ciach { dartExecutable: options.dartExecutable, ); - final unused = []; - final docOnly = []; - var recoveredReferences = const []; - var declarationsChecked = 0; - + final Settled settled; + final int declarationsChecked; try { if (analysisRoot != rootPath) { _report( @@ -165,523 +143,49 @@ class Ciach { files, options.concurrency, (path) => opened.contains(path) - ? _collectCandidatesFor(client, path, rootPath) + ? _collector.collect(client, path, rootPath) : Future.value(const []), ); final candidates = [for (final list in perFile) ...list]; declarationsChecked = candidates.length; - _reportSkippedEntryPoints(); + _collector.reportSkipped(); // Phase 2: check references for every candidate through a single global // pool, so the server stays saturated instead of stalling between files. - // Progress is reported per file: each file's remaining count is tracked - // and a line is emitted as soon as its last declaration is checked. Files - // with no candidates are already counted as done. _report('Checking references for $declarationsChecked declaration(s)…'); - final refsByCandidate = await _checkReferences( + final refsByCandidate = await _fetch.references( client, candidates, - files.length, - rootPath, + totalFiles: files.length, + rootPath: rootPath, ); + await _fetch.semanticTokensFor(client, refsByCandidate); + await _fetch.selectionRanges(client, candidates, refsByCandidate); - await _fetchSemanticTokensFor(client, refsByCandidate); - await _fetchSelectionRanges(client, candidates, refsByCandidate); - - // Phase 3: a secondary check that confirms apparently-unreferenced - // members are actually unused before they are reported. - final crossLib = await _recoverCrossLibraryRefs( + // Phase 3: settle the verdicts. + settled = await _settler.settle( client, candidates, refsByCandidate, + scannedPaths: files.where(opened.contains).toSet(), + rootPath: rootPath, + analysisRoot: analysisRoot, ); - - final statuses = [ - for (var i = 0; i < candidates.length; i++) - _classifier.classify(candidates[i], refsByCandidate[i], crossLib), - ]; - - recoveredReferences = _recoveredWarnings( - candidates, - refsByCandidate, - crossLib, - rootPath, - analysisRoot, - ); - - // A deser-only union arm reads zero references but is a live serialization - // member. - final freezedUnionArms = _freezed.deserializationOnlyArms( - candidates, - statuses, - _sources, - ); - - // Names of classes flagged unused, per file. A whole dead class is - // removed as one node, taking its own constructor(s) with it, so those - // constructors must not also be reported (or removed) on their own. - final deadClassNames = >{}; - for (var i = 0; i < candidates.length; i++) { - final candidate = candidates[i]; - if (statuses[i] == .unused && candidate.symbol.kind == .class$) { - deadClassNames - .putIfAbsent(candidate.path, () => {}) - .add(candidate.symbol.name); - } - } - - final safety = await RemoveSafety.analyze( - _sources, - candidates, - statuses, - refsByCandidate, - deadClassNames, - SuperclassChecks(client).needsConstructorArguments, - ); - - final reported = { - for (var i = 0; i < candidates.length; i++) - if (statuses[i] == .unused && - !_isSuppressed( - candidates[i], - i, - freezedUnionArms, - deadClassNames, - safety, - )) - i, - }; - - // Phase 4: couple a dead member's overrides to its removal, or let one - // that has to stay block it. - final scannedPaths = files.where(opened.contains).toSet(); - final overridden = await _coupleOverrides( - client, - candidates, - reported, - scannedPaths, - rootPath, - ); - - for (var i = 0; i < candidates.length; i++) { - final candidate = candidates[i]; - final refs = refsByCandidate[i]; - switch (statuses[i]) { - case .unused: - if (!reported.contains(i)) { - break; - } - final isClass = candidate.symbol.kind == .class$; - final overrides = overridden[i]; - final blockedByOverride = overrides?.blocked ?? false; - unused.add( - _toUnused( - candidate, - rootPath, - coupledRemovals: isClass - ? _sources.pairedStateRemovals( - candidate, - refs, - candidates, - refsByCandidate, - rootPath, - ) - : overrides?.removals ?? const [], - removalBlocked: - _isRemovalBlocked(candidate, refs, safety) || - blockedByOverride, - hint: - _hintFor(candidate) ?? - (blockedByOverride ? _overriddenHint : null), - ), - ); - case .docOnly: - docOnly.add(_toUnused(candidate, rootPath)); - case .used: - break; - } - } } finally { await client.dispose(); } - unused.sort(_byLocation); - docOnly.sort(_byLocation); stopwatch.stop(); return .new( - unused: unused, - docOnly: docOnly, + unused: settled.unused, + docOnly: settled.docOnly, filesScanned: files.length, declarationsChecked: declarationsChecked, elapsed: stopwatch.elapsed, - recoveredReferences: recoveredReferences, - ); - } - - /// The overrides to delete along with each reported dead member, by - /// candidate index. Members with nothing to say are left out. - Future> _coupleOverrides( - LspClient client, - List candidates, - Set reported, - Set scannedPaths, - String rootPath, - ) async { - final members = [ - for (final index in reported) - if (_canBeOverridden(candidates[index])) index, - ]; - if (members.isEmpty) { - return const {}; - } - _report('Checking ${members.length} dead member(s) for overrides…'); - final overrides = OverrideRemovals( - client, - _sources, - scannedPaths: scannedPaths, - rootPath: rootPath, - ); - final results = await mapPooled( - members, - options.concurrency, - (index) => overrides.of(candidates[index]), - ); - final byCandidate = {}; - var coupled = 0; - var blocked = 0; - for (var i = 0; i < members.length; i++) { - final result = results[i]; - if (result.removals.isEmpty && !result.blocked) { - continue; - } - byCandidate[members[i]] = result; - coupled += result.removals.length; - if (result.blocked) { - blocked++; - } - } - if (coupled > 0) { - _report( - 'Coupling $coupled override(s) to the dead member(s) they implement.', - ); - } - if (blocked > 0) { - _report( - '$blocked dead member(s) are overridden where --remove cannot ' - 'follow; left in place.', - ); - } - return byCandidate; - } - - /// Whether [candidate] is a member a subclass could override. A declaring - /// parameter is never removed anyway. - bool _canBeOverridden(Candidate candidate) => switch (candidate.symbol.kind) { - .method || .property || .field => - candidate.container != null && - !candidate.isExtensionMember && - !_isHeaderDeclaration(candidate), - _ => false, - }; - - /// One warning per declaration the secondary check kept alive: it had no - /// reported references outside itself, yet a use resolved back to it. - List _recoveredWarnings( - List candidates, - List> refsByCandidate, - CrossLibraryReferences crossLib, - String rootPath, - String analysisRoot, - ) { - final warnings = []; - for (var i = 0; i < candidates.length; i++) { - final candidate = candidates[i]; - if (_classifier.externalRefs(candidate, refsByCandidate[i]).isNotEmpty || - candidate.symbol.kind == .class$ || - candidate.isExtension) { - continue; - } - final usage = crossLib.recoveredUsage(candidate); - if (usage == null) { - continue; - } - final start = candidate.symbol.selectionRange.start; - warnings.add( - RecoveredReference( - name: candidate.symbol.declarationName(candidate.container), - container: candidate.container, - filePath: relativePosix(candidate.path, rootPath), - line: start.line + 1, - column: start.character + 1, - usageFilePath: relativeUsagePosix(usage.path, rootPath, analysisRoot), - usageLine: usage.line + 1, - usageColumn: usage.character + 1, - ), - ); - } - warnings.sort((a, b) { - final byFile = a.filePath.compareTo(b.filePath); - if (byFile != 0) { - return byFile; - } - final byLine = a.line.compareTo(b.line); - return byLine != 0 ? byLine : a.column.compareTo(b.column); - }); - return warnings; - } - - /// Queries `textDocument/references` for every candidate through one global - /// pool, reporting `[done/total]` progress as each file's last query lands. - Future>> _checkReferences( - LspClient client, - List candidates, - int totalFiles, - String rootPath, - ) { - final remainingPerFile = {}; - for (final candidate in candidates) { - remainingPerFile.update(candidate.path, (n) => n + 1, ifAbsent: () => 1); - } - var filesDone = totalFiles - remainingPerFile.length; - - return mapPooled(candidates, options.concurrency, (candidate) async { - // The server would answer for an unnamed extension's `on` type. - final refs = candidate.isUnnamedExtension - ? const [] - : await client.references( - candidate.uri, - candidate.symbol.selectionRange.start, - ); - if (remainingPerFile.update(candidate.path, (n) => n - 1) == 0) { - filesDone++; - _report( - '[$filesDone/$totalFiles] ' - '${p.relative(candidate.path, from: rootPath)}', - ); - } - return refs; - }); - } - - /// Fetches the semantic tokens of every referenced file that has none yet. - Future _fetchSemanticTokensFor( - LspClient client, - List> refsByCandidate, - ) async { - final paths = { - for (final refs in refsByCandidate) - for (final loc in refs) - if (!_sources.hasSemanticTokens(SourceIndex.pathOf(loc.uri))) - SourceIndex.pathOf(loc.uri), - }; - if (paths.isEmpty) { - return; - } - _report('Fetching tokens for ${paths.length} referenced file(s)…'); - await mapPooled(paths.toList(), options.concurrency, (path) async { - _sources.cacheSemanticTokens( - path, - await _semanticTokensOrEmpty(client, path), - ); - }); - } - - /// Fetches the selection ranges the structural checks need, one request per - /// file. - Future _fetchSelectionRanges( - LspClient client, - List candidates, - List> refsByCandidate, - ) async { - final positionsByPath = >{}; - void add(String path, Position position) => - positionsByPath.putIfAbsent(path, () => {}).add(position); - - for (var i = 0; i < candidates.length; i++) { - final candidate = candidates[i]; - final kind = candidate.symbol.kind; - final isEnumType = kind == .enum$ && !candidate.isEnumValue; - if (isEnumType || kind == .class$) { - for (final loc in refsByCandidate[i]) { - add(SourceIndex.pathOf(loc.uri), loc.range.start); - } - } - if ((kind == .constructor || kind == .field) && - candidate.containerOutline != null) { - add(candidate.path, candidate.symbol.selectionRange.start); - } - if (isEnumType) { - for (final token in _sources.valuesTokensIn(candidate)) { - add(candidate.path, token.start); - } - } - if (kind == .constructor) { - if (_sources.redirectProbePosition(candidate) case final position?) { - add(candidate.path, position); - } - } - } - if (positionsByPath.isEmpty) { - return; - } - _report('Fetching syntax nodes in ${positionsByPath.length} file(s)…'); - await mapPooled(positionsByPath.entries.toList(), options.concurrency, ( - entry, - ) async { - final MapEntry(key: path, value: positions) = entry; - final ordered = positions.toList(); - List ranges; - try { - ranges = await client.selectionRanges(File(path).uri, ordered); - } on Object { - return; // a position with no answer reads as "not the special shape" - } - for (var i = 0; i < ordered.length; i++) { - if (ranges[i] case final range?) { - _sources.cacheSelectionRange(path, ordered[i], range); - } - } - }); - } - - /// The semantic tokens of [path], or an empty list if the server has none. - /// A file without tokens reads as all code, which only keeps declarations. - Future> _semanticTokensOrEmpty( - LspClient client, - String path, - ) async { - try { - return await client.semanticTokens(File(path).uri, _sources.lines(path)); - } on Object { - return const []; - } - } - - /// Runs the secondary definition check for the candidates with no reference - /// outside their own span — the potential false positives. - Future _recoverCrossLibraryRefs( - LspClient client, - List candidates, - List> refsByCandidate, - ) { - final emptyRefNames = { - for (var i = 0; i < candidates.length; i++) - if (_classifier - .externalRefs(candidates[i], refsByCandidate[i]) - .isEmpty && - candidates[i].symbol.kind != .class$ && - !candidates[i].isExtension) ...[ - _simpleName(candidates[i].symbol.name), - // An unnamed constructor is spelled by the class name at an - // ordinary `Foo(…)` site but as `new` at a dot-shorthand one - // (`.new(…)`), so probe for both spellings. - candidates[i].symbol.declarationName(candidates[i].container), - ], - }; - if (emptyRefNames.isNotEmpty) { - _report('Recovering cross-library references…'); - } - return CrossLibraryReferences.resolve( - client: client, - sources: _sources, - candidates: candidates, - emptyRefNames: emptyRefNames, - concurrency: options.concurrency, + recoveredReferences: settled.recovered, ); } - /// The last-segment name — `bar` for a constructor reported as `Foo.bar` — - /// which is the identifier a usage site spells. - static String _simpleName(String name) => - name.contains('.') ? name.split('.').last : name; - - String? _hintFor(Candidate candidate) { - if (_isHeaderDeclaration(candidate)) { - return candidate.symbol.kind == .constructor - ? _primaryConstructorHint - : _declaringParameterHint; - } - return candidate.isPreventInstantiationCtor - ? _preventInstantiationHint - : null; - } - - /// See [StructuralChecks.isDeclaredInTypeHeader]. - bool _isHeaderDeclaration(Candidate candidate) => - switch (candidate.symbol.kind) { - .constructor || .field => _sources.isDeclaredInTypeHeader(candidate), - _ => false, - }; - - /// Whether an unused [candidate] should be silently suppressed (never - /// reported): a live freezed-union arm, an exempt `toJson` hook, a - /// constructor removed with its already-dead class, an extension or its - /// members (see [RemoveSafety.deadExtensions]), or an enum value reached - /// only through `.values` iteration. - bool _isSuppressed( - Candidate candidate, - int index, - Set freezedUnionArms, - Map> deadClassNames, - RemoveSafety safety, - ) { - if (freezedUnionArms.contains(index)) { - return true; - } - if (!options.reportToJson && isToJsonHook(candidate)) { - return true; - } - if (_isRemovedWithDeadClass(candidate, deadClassNames)) { - return true; - } - // Used through its members, never by name. - if (candidate.isExtension && - !safety.deadExtensions.contains(candidate.key)) { - return true; - } - final containerKey = candidate.containerKey; - if (containerKey == null) { - return false; - } - if (candidate.isExtensionMember && - safety.deadExtensions.contains(containerKey)) { - return true; - } - return candidate.isEnumValue && - safety.enumValuesIterated.contains(containerKey); - } - - /// Whether a dead [candidate] is real but must *not* be auto-removed, because - /// doing so would break the build: - /// - /// * a class kept dead only by type patterns under `--unused-union-members` - /// (never constructed, only matched): deleting a sealed member and its - /// scattered `case`s is a source rewrite this tool won't attempt; - /// * an enum value whose removal would empty a still-referenced enum; - /// * the last constructor of a live class with `final` fields or - /// super-constructor forwarding; - /// * a primary constructor or one of its declaring parameters. - /// - /// Each is surfaced so a human can act on it, but the remover leaves it — and - /// anything coupled to it — entirely alone. - bool _isRemovalBlocked( - Candidate candidate, - List refs, - RemoveSafety safety, - ) { - final containerKey = candidate.containerKey; - return (candidate.symbol.kind == .class$ && - options.unusedUnionMembers && - _classifier.isPatternMatchedClass(candidate, refs)) || - (candidate.isEnumValue && - containerKey != null && - safety.emptiedEnums.contains(containerKey)) || - (candidate.symbol.kind == .constructor && - containerKey != null && - safety.blockedCtorClasses.contains(containerKey)) || - _isHeaderDeclaration(candidate); - } - /// Opens [path] in the server. `false` if the file cannot be read. bool _openFile(LspClient client, String path) { final content = SourceIndex.readFile(path); @@ -692,313 +196,4 @@ class Ciach { _sources.cacheLines(path, content.split('\n')); return true; } - - /// One line per skipped entry point, except the ubiquitous `main`. - void _reportSkippedEntryPoints() { - if (options.onProgress == null) { - return; - } - _skippedEntryPoints.sort((a, b) { - final byFile = a.path.compareTo(b.path); - return byFile != 0 ? byFile : a.line.compareTo(b.line); - }); - for (final skipped in _skippedEntryPoints) { - if (skipped.name == 'main') { - continue; - } - _report( - 'Skipped ${skipped.path}:${skipped.line} ${skipped.name}: ' - '${skipped.reason}.', - ); - } - } - - /// The declarations of the open file [path] worth checking. - Future> _collectCandidatesFor( - LspClient client, - String path, - String rootPath, - ) async { - final uri = File(path).uri; - final (symbols, outline, tokens) = await ( - client.documentSymbol(uri), - client.outline(uri), - _semanticTokensOrEmpty(client, path), - ).wait; - _sources.cacheSemanticTokens(path, tokens); - final relativePath = relativePosix(path, rootPath); - final out = []; - _collectCandidates( - uri, - path, - relativePath, - symbols, - null, - null, - false, - _OutlineIndex(outline), - out, - ); - return _withoutEntryPointContainers(out, relativePath); - } - - /// [candidates] less the types a member entry point lives in; see - /// [_entryPointContainers]. Their other members stay candidates. - List _withoutEntryPointContainers( - List candidates, - String relativePath, - ) { - if (_entryPointContainers.isEmpty) { - return candidates; - } - final kept = []; - for (final candidate in candidates) { - final symbol = candidate.symbol; - final rule = - candidate.container == null && typeLikeKinds.contains(symbol.kind) - ? _entryPointContainers[DeclKey(relativePath, symbol.name)] - : null; - if (rule == null) { - kept.add(candidate); - continue; - } - _skippedEntryPoints.add(( - path: relativePath, - line: symbol.selectionRange.start.line + 1, - name: symbol.name, - reason: 'declares the entry point ${rule.name}', - )); - } - return kept; - } - - /// Recursively walks the symbol tree, keeping only symbols worth checking, - /// and records the enclosing type name as their container. - /// - /// [parentIsEnum] marks children of an enum declaration so their enum values - /// are remapped to the `enum-value` kind. - void _collectCandidates( - Uri uri, - String path, - String relativePath, - List symbols, - String? container, - Candidate? containerCandidate, - bool parentIsEnum, - _OutlineIndex outlines, - List out, - ) { - // A field statement's doc comment, annotations and modifiers sit on its - // first declarator, so a later one (`b` in `@override final int a, b;`) - // reads that statement's instead of its own, which are empty. - var statementMetadata = const []; - for (final symbol in symbols) { - final outline = outlines[symbol]; - if (outline == null) { - _report( - 'Skipped $relativePath:${symbol.selectionRange.start.line + 1} ' - "${symbol.name}: the analysis server's outline has no entry for it.", - ); - continue; - } - final ownMetadata = _sources.leadingMetadata(path, outline); - final isField = outline.element.kind == .field; - final continuesStatement = - isField && outline.range.start == outline.codeRange.start; - final leadingMetadata = continuesStatement - ? statementMetadata - : ownMetadata; - statementMetadata = isField && !continuesStatement - ? ownMetadata.toList() - : const []; - _freezed.noteIfAnnotated(path, symbol, leadingMetadata); - final candidate = Candidate( - uri: uri, - path: path, - symbol: symbol, - outline: outline, - container: container, - containerSymbol: containerCandidate?.symbol, - containerOutline: containerCandidate?.outline, - isEnumValue: parentIsEnum && symbol.kind == .enum$, - isPreventInstantiationCtor: symbol.isPreventInstantiationMarker( - symbols, - ), - ); - if (_shouldConsider(relativePath, candidate, leadingMetadata)) { - out.add(candidate); - } - final isTypeLike = typeLikeKinds.contains(symbol.kind); - _collectCandidates( - uri, - path, - relativePath, - symbol.children ?? const [], - isTypeLike ? symbol.name : container, - isTypeLike ? candidate : containerCandidate, - symbol.kind == .enum$, - outlines, - out, - ); - } - } - - /// Whether [candidate] should have its references checked. - bool _shouldConsider( - String relativePath, - Candidate candidate, - Iterable leadingMetadata, - ) { - final symbol = candidate.symbol; - final container = candidate.container; - if (!options.kinds.contains( - symbol.reportedKind( - parentIsEnum: candidate.isEnumValue, - isExtensionType: candidate.isExtensionType, - ), - )) { - return false; - } - // Called by a framework or tool, with no source reference to find. - if (_entryPoints.match(relativePath, symbol, container) case final rule?) { - _skippedEntryPoints.add(( - path: relativePath, - line: symbol.selectionRange.start.line + 1, - name: rule.name, - reason: rule.reason, - )); - if (container != null) { - _entryPointContainers.putIfAbsent( - DeclKey(relativePath, container), - () => rule, - ); - } - return false; - } - if (symbol.kind == .namespace && - !candidate.isExtension && - !candidate.isExtensionType) { - return false; - } - if (!isPrivateName(symbol.name) && !options.includePublic) { - return false; - } - if (options.skipOperators && symbol.isOperator) { - return false; - } - // Always skipped (no flag): implicit-call syntax is unresolvable, like - // operators. - if (symbol.isCallMethod) { - return false; - } - // Already represented by the header's constructor symbol. - if (symbol.isPrimaryConstructorBody) { - return false; - } - - if (options.skipOverrides && - leadingMetadata.any((t) => t.isAnnotationNamed('override'))) { - return false; - } - // Symbols reachable from native code / reflection are not really unused. - if (leadingMetadata.any( - (t) => t.type == 'string' && t.text.contains('vm:entry-point'), - )) { - return false; - } - return true; - } - - UnusedDeclaration _toUnused( - Candidate candidate, - String rootPath, { - List coupledRemovals = const [], - bool removalBlocked = false, - String? hint, - }) { - final symbol = candidate.symbol; - // An unnamed extension's selection range is its `on` type. - final start = candidate.isUnnamedExtension - ? symbol.range.start - : symbol.selectionRange.start; - final name = symbol.declarationName(candidate.container); - // `extension on T` is no name to qualify members by. - final container = _isInUnnamedExtension(candidate) - ? null - : candidate.container; - return .new( - name: name, - kind: symbol.reportedKind( - parentIsEnum: candidate.isEnumValue, - isExtensionType: candidate.isExtensionType, - ), - filePath: relativePosix(candidate.path, rootPath), - // LSP positions are zero-based; report them one-based for humans. - line: start.line + 1, - column: start.character + 1, - isPrivate: isPrivateName(name), - container: container, - isEnumValue: candidate.isEnumValue, - range: symbol.declarationRange, - fullRange: candidate.outline.range.toDeclarationRange, - coupledRemovals: coupledRemovals, - removalBlocked: removalBlocked, - hint: hint, - ); - } - - bool _isInUnnamedExtension(Candidate candidate) => - candidate.containerOutline?.element.isUnnamedExtension ?? false; - - /// Whether [candidate] goes with an already-dead class's own declaration — - /// any constructor, or a declaring parameter — so a single removal is not - /// reported twice. - bool _isRemovedWithDeadClass( - Candidate candidate, - Map> deadClassNames, - ) => - (candidate.symbol.kind == .constructor || - _isHeaderDeclaration(candidate)) && - (deadClassNames[candidate.path]?.contains(candidate.container) ?? false); - - static int _byLocation(UnusedDeclaration a, UnusedDeclaration b) { - final byFile = a.filePath.compareTo(b.filePath); - if (byFile != 0) { - return byFile; - } - final byLine = a.line.compareTo(b.line); - return byLine != 0 ? byLine : a.column.compareTo(b.column); - } -} - -/// A skipped entry point: root-relative POSIX path, one-based line, the name -/// as the rule spells it, and why. -typedef _SkippedEntryPoint = ({ - String path, - int line, - String name, - String reason, -}); - -/// Outline nodes by document symbol: a symbol's `range` is its node's -/// `codeRange`. -final class _OutlineIndex { - _OutlineIndex(Outline root) - : _byCodeRange = { - for (final node in root.descendants) _key(node.codeRange): node, - }; - - final Map<_RangeKey, Outline> _byCodeRange; - - Outline? operator [](DocumentSymbol symbol) => - _byCodeRange[_key(symbol.range)]; - - static _RangeKey _key(Range range) => ( - range.start.line, - range.start.character, - range.end.line, - range.end.character, - ); } - -typedef _RangeKey = (int, int, int, int); diff --git a/lib/src/reference_fetch.dart b/lib/src/reference_fetch.dart new file mode 100644 index 0000000..5049015 --- /dev/null +++ b/lib/src/reference_fetch.dart @@ -0,0 +1,152 @@ +import 'dart:io'; + +import 'package:ciach/src/candidates.dart'; +import 'package:ciach/src/concurrency.dart'; +import 'package:ciach/src/lsp/lsp_client.dart'; +import 'package:ciach/src/lsp/semantic_tokens.dart'; +import 'package:ciach/src/models.dart'; +import 'package:ciach/src/source_index.dart'; +import 'package:ciach/src/syntax_rules.dart'; +import 'package:path/path.dart' as p; +import 'package:pro_lsp/pro_lsp.dart' show Location, Position, SelectionRange; + +/// The server round trips the verdict runs on: every candidate's references, +/// and the tokens and syntax nodes the structural checks read, cached in the +/// [SourceIndex]. +final class ReferenceFetch { + ReferenceFetch({required this.options, required SourceIndex sources}) + : _sources = sources; + + final FinderOptions options; + final SourceIndex _sources; + + void _report(String message) => options.onProgress?.call(message); + + /// Queries `textDocument/references` for every candidate through one global + /// pool, reporting `[done/total]` progress as each file's last query lands. + Future>> references( + LspClient client, + List candidates, { + required int totalFiles, + required String rootPath, + }) { + final remainingPerFile = {}; + for (final candidate in candidates) { + remainingPerFile.update(candidate.path, (n) => n + 1, ifAbsent: () => 1); + } + var filesDone = totalFiles - remainingPerFile.length; + + return mapPooled(candidates, options.concurrency, (candidate) async { + // The server would answer for an unnamed extension's `on` type. + final refs = candidate.isUnnamedExtension + ? const [] + : await client.references( + candidate.uri, + candidate.symbol.selectionRange.start, + ); + if (remainingPerFile.update(candidate.path, (n) => n - 1) == 0) { + filesDone++; + _report( + '[$filesDone/$totalFiles] ' + '${p.relative(candidate.path, from: rootPath)}', + ); + } + return refs; + }); + } + + /// Fetches the semantic tokens of every referenced file that has none yet. + Future semanticTokensFor( + LspClient client, + List> refsByCandidate, + ) async { + final paths = { + for (final refs in refsByCandidate) + for (final loc in refs) + if (!_sources.hasSemanticTokens(SourceIndex.pathOf(loc.uri))) + SourceIndex.pathOf(loc.uri), + }; + if (paths.isEmpty) { + return; + } + _report('Fetching tokens for ${paths.length} referenced file(s)…'); + await mapPooled(paths.toList(), options.concurrency, (path) async { + _sources.cacheSemanticTokens( + path, + await semanticTokensOrEmpty(client, _sources, path), + ); + }); + } + + /// Fetches the selection ranges the structural checks need, one request per + /// file. + Future selectionRanges( + LspClient client, + List candidates, + List> refsByCandidate, + ) async { + final positionsByPath = >{}; + void add(String path, Position position) => + positionsByPath.putIfAbsent(path, () => {}).add(position); + + for (var i = 0; i < candidates.length; i++) { + final candidate = candidates[i]; + final kind = candidate.symbol.kind; + final isEnumType = kind == .enum$ && !candidate.isEnumValue; + if (isEnumType || kind == .class$) { + for (final loc in refsByCandidate[i]) { + add(SourceIndex.pathOf(loc.uri), loc.range.start); + } + } + if ((kind == .constructor || kind == .field) && + candidate.containerOutline != null) { + add(candidate.path, candidate.symbol.selectionRange.start); + } + if (isEnumType) { + for (final token in _sources.valuesTokensIn(candidate)) { + add(candidate.path, token.start); + } + } + if (kind == .constructor) { + if (_sources.redirectProbePosition(candidate) case final position?) { + add(candidate.path, position); + } + } + } + if (positionsByPath.isEmpty) { + return; + } + _report('Fetching syntax nodes in ${positionsByPath.length} file(s)…'); + await mapPooled(positionsByPath.entries.toList(), options.concurrency, ( + entry, + ) async { + final MapEntry(key: path, value: positions) = entry; + final ordered = positions.toList(); + List ranges; + try { + ranges = await client.selectionRanges(File(path).uri, ordered); + } on Object { + return; // a position with no answer reads as "not the special shape" + } + for (var i = 0; i < ordered.length; i++) { + if (ranges[i] case final range?) { + _sources.cacheSelectionRange(path, ordered[i], range); + } + } + }); + } +} + +/// The semantic tokens of [path], or an empty list if the server has none. +/// A file without tokens reads as all code, which only keeps declarations. +Future> semanticTokensOrEmpty( + LspClient client, + SourceIndex sources, + String path, +) async { + try { + return await client.semanticTokens(File(path).uri, sources.lines(path)); + } on Object { + return const []; + } +} diff --git a/lib/src/settler.dart b/lib/src/settler.dart new file mode 100644 index 0000000..e80f479 --- /dev/null +++ b/lib/src/settler.dart @@ -0,0 +1,313 @@ +import 'package:ciach/src/candidates.dart'; +import 'package:ciach/src/concurrency.dart'; +import 'package:ciach/src/conventions/flutter_widgets.dart'; +import 'package:ciach/src/conventions/freezed.dart'; +import 'package:ciach/src/cross_library_refs.dart'; +import 'package:ciach/src/lsp/lsp_client.dart'; +import 'package:ciach/src/models.dart'; +import 'package:ciach/src/overrides.dart'; +import 'package:ciach/src/paths.dart'; +import 'package:ciach/src/reference_classifier.dart'; +import 'package:ciach/src/remove_safety.dart'; +import 'package:ciach/src/source_index.dart'; +import 'package:ciach/src/superclasses.dart'; +import 'package:ciach/src/symbols.dart'; +import 'package:ciach/src/verdict.dart'; +import 'package:collection/collection.dart'; +import 'package:pro_lsp/pro_lsp.dart' show Location; + +/// What a run reports, sorted by location. +typedef Settled = ({ + List unused, + List docOnly, + List recovered, +}); + +/// From references to findings: settles each candidate's verdict — classifies +/// it, applies the conventions and remove-safety, couples overrides — and +/// builds the sorted report. +final class Settler { + Settler({ + required this.options, + required SourceIndex sources, + required FreezedUnions freezed, + required ReferenceClassifier classifier, + required Verdict verdict, + }) : _sources = sources, + _freezed = freezed, + _classifier = classifier, + _verdict = verdict; + + final FinderOptions options; + final SourceIndex _sources; + final FreezedUnions _freezed; + final ReferenceClassifier _classifier; + final Verdict _verdict; + + void _report(String message) => options.onProgress?.call(message); + + /// The findings for [candidates], from the server's [refsByCandidate]. + Future settle( + LspClient client, + List candidates, + List> refsByCandidate, { + required Set scannedPaths, + required String rootPath, + required String analysisRoot, + }) async { + final superclasses = SuperclassChecks(client); + final overrides = OverrideRemovals( + client, + _sources, + scannedPaths: scannedPaths, + rootPath: rootPath, + ); + + // A secondary check that confirms apparently-unreferenced members are + // actually unused before they are reported. + final crossLib = await _recoverCrossLibraryRefs( + client, + candidates, + refsByCandidate, + ); + + final statuses = [ + for (var i = 0; i < candidates.length; i++) + _classifier.classify(candidates[i], refsByCandidate[i], crossLib), + ]; + + final recovered = _recoveredWarnings( + candidates, + refsByCandidate, + crossLib, + rootPath, + analysisRoot, + ); + + // A deser-only union arm reads zero references but is a live serialization + // member. + final freezedUnionArms = _freezed.deserializationOnlyArms( + candidates, + statuses, + _sources, + ); + + // Names of classes flagged unused, per file. A whole dead class is + // removed as one node, taking its own constructor(s) with it, so those + // constructors must not also be reported (or removed) on their own. + final deadClassNames = >{}; + for (var i = 0; i < candidates.length; i++) { + final candidate = candidates[i]; + if (statuses[i] == .unused && candidate.symbol.kind == .class$) { + deadClassNames + .putIfAbsent(candidate.path, () => {}) + .add(candidate.symbol.name); + } + } + + final safety = await RemoveSafety.analyze( + _sources, + candidates, + statuses, + refsByCandidate, + deadClassNames, + superclasses.needsConstructorArguments, + ); + + final reported = { + for (var i = 0; i < candidates.length; i++) + if (statuses[i] == .unused && + !_verdict.isSuppressed( + candidates[i], + i, + freezedUnionArms, + deadClassNames, + safety, + )) + i, + }; + + // Couple a dead member's overrides to its removal, or let one that has to + // stay block it. + final overridden = await _coupleOverrides(candidates, reported, overrides); + + final unused = []; + final docOnly = []; + for (var i = 0; i < candidates.length; i++) { + final candidate = candidates[i]; + final refs = refsByCandidate[i]; + switch (statuses[i]) { + case .unused: + if (!reported.contains(i)) { + break; + } + final isClass = candidate.symbol.kind == .class$; + final overrides = overridden[i]; + final blockedByOverride = overrides?.blocked ?? false; + unused.add( + _verdict.finding( + candidate, + rootPath, + coupledRemovals: isClass + ? _sources.pairedStateRemovals( + candidate, + refs, + candidates, + refsByCandidate, + rootPath, + ) + : overrides?.removals ?? const [], + removalBlocked: + _verdict.isRemovalBlocked(candidate, refs, safety) || + blockedByOverride, + hint: + _verdict.hintFor(candidate) ?? + (blockedByOverride ? Verdict.overriddenHint : null), + ), + ); + case .docOnly: + docOnly.add(_verdict.finding(candidate, rootPath)); + case .used: + break; + } + } + + return ( + unused: unused.sorted(compareByLocation), + docOnly: docOnly.sorted(compareByLocation), + recovered: recovered, + ); + } + + /// The overrides to delete along with each reported dead member, by + /// candidate index. Members with nothing to say are left out. + Future> _coupleOverrides( + List candidates, + Set reported, + OverrideRemovals overrides, + ) async { + final members = [ + for (final index in reported) + if (_verdict.canBeOverridden(candidates[index])) index, + ]; + if (members.isEmpty) { + return const {}; + } + _report('Checking ${members.length} dead member(s) for overrides…'); + final results = await mapPooled( + members, + options.concurrency, + (index) => overrides.of(candidates[index]), + ); + final byCandidate = {}; + var coupled = 0; + var blocked = 0; + for (var i = 0; i < members.length; i++) { + final result = results[i]; + if (result.removals.isEmpty && !result.blocked) { + continue; + } + byCandidate[members[i]] = result; + coupled += result.removals.length; + if (result.blocked) { + blocked++; + } + } + if (coupled > 0) { + _report( + 'Coupling $coupled override(s) to the dead member(s) they implement.', + ); + } + if (blocked > 0) { + _report( + '$blocked dead member(s) are overridden where --remove cannot ' + 'follow; left in place.', + ); + } + return byCandidate; + } + + /// One warning per declaration the secondary check kept alive: it had no + /// reported references outside itself, yet a use resolved back to it. + List _recoveredWarnings( + List candidates, + List> refsByCandidate, + CrossLibraryReferences crossLib, + String rootPath, + String analysisRoot, + ) { + final warnings = []; + for (var i = 0; i < candidates.length; i++) { + final candidate = candidates[i]; + if (_classifier.externalRefs(candidate, refsByCandidate[i]).isNotEmpty || + candidate.symbol.kind == .class$ || + candidate.isExtension) { + continue; + } + final usage = crossLib.recoveredUsage(candidate); + if (usage == null) { + continue; + } + final start = candidate.symbol.selectionRange.start; + warnings.add( + RecoveredReference( + name: candidate.symbol.declarationName(candidate.container), + container: candidate.container, + filePath: relativePosix(candidate.path, rootPath), + line: start.line + 1, + column: start.character + 1, + usageFilePath: relativeUsagePosix(usage.path, rootPath, analysisRoot), + usageLine: usage.line + 1, + usageColumn: usage.character + 1, + ), + ); + } + warnings.sort((a, b) { + final byFile = a.filePath.compareTo(b.filePath); + if (byFile != 0) { + return byFile; + } + final byLine = a.line.compareTo(b.line); + return byLine != 0 ? byLine : a.column.compareTo(b.column); + }); + return warnings; + } + + /// Runs the secondary definition check for the candidates with no reference + /// outside their own span — the potential false positives. + Future _recoverCrossLibraryRefs( + LspClient client, + List candidates, + List> refsByCandidate, + ) { + final emptyRefNames = { + for (var i = 0; i < candidates.length; i++) + if (_classifier + .externalRefs(candidates[i], refsByCandidate[i]) + .isEmpty && + candidates[i].symbol.kind != .class$ && + !candidates[i].isExtension) ...[ + _simpleName(candidates[i].symbol.name), + // An unnamed constructor is spelled by the class name at an + // ordinary `Foo(…)` site but as `new` at a dot-shorthand one + // (`.new(…)`), so probe for both spellings. + candidates[i].symbol.declarationName(candidates[i].container), + ], + }; + if (emptyRefNames.isNotEmpty) { + _report('Recovering cross-library references…'); + } + return CrossLibraryReferences.resolve( + client: client, + sources: _sources, + candidates: candidates, + emptyRefNames: emptyRefNames, + concurrency: options.concurrency, + ); + } + + /// The last-segment name — `bar` for a constructor reported as `Foo.bar` — + /// which is the identifier a usage site spells. + static String _simpleName(String name) => + name.contains('.') ? name.split('.').last : name; +} diff --git a/lib/src/verdict.dart b/lib/src/verdict.dart new file mode 100644 index 0000000..d6a1c43 --- /dev/null +++ b/lib/src/verdict.dart @@ -0,0 +1,204 @@ +import 'package:ciach/src/candidates.dart'; +import 'package:ciach/src/conventions/serialization.dart'; +import 'package:ciach/src/models.dart'; +import 'package:ciach/src/paths.dart'; +import 'package:ciach/src/reference_classifier.dart'; +import 'package:ciach/src/remove_safety.dart'; +import 'package:ciach/src/source_index.dart'; +import 'package:ciach/src/symbols.dart'; +import 'package:ciach/src/syntax_rules.dart'; +import 'package:pro_lsp/pro_lsp.dart' show Location; + +/// What becomes of one unused candidate: silently suppressed, reported but +/// report-only, or a finding — and how that finding is spelled. +final class Verdict { + Verdict({ + required this.options, + required SourceIndex sources, + required ReferenceClassifier classifier, + }) : _sources = sources, + _classifier = classifier; + + final FinderOptions options; + final SourceIndex _sources; + final ReferenceClassifier _classifier; + + /// Advisory note attached to a sole, zero-parameter private constructor + /// (`Foo._();`) — the classic prevent-instantiation marker. Such a + /// constructor is still reported (and removable) like any other dead code, + /// but the note points at the idiomatic alternative. + static const _preventInstantiationHint = + 'looks like a prevent-instantiation constructor — for a ' + 'non-instantiable static-only class, prefer `abstract final class`'; + + static const _primaryConstructorHint = + 'primary constructor — declared in the class header, so it cannot be ' + 'removed without removing the class'; + + static const _declaringParameterHint = + 'declaring parameter of the primary constructor — removing it changes ' + 'the constructor signature at every call site'; + + static const overriddenHint = + 'overridden by a declaration --remove will not delete — that override ' + 'would be left overriding nothing'; + + /// Whether an unused [candidate] should be silently suppressed (never + /// reported): a live freezed-union arm, an exempt `toJson` hook, a + /// constructor removed with its already-dead class, an extension or its + /// members (see [RemoveSafety.deadExtensions]), or an enum value reached + /// only through `.values` iteration. + bool isSuppressed( + Candidate candidate, + int index, + Set freezedUnionArms, + Map> deadClassNames, + RemoveSafety safety, + ) { + if (freezedUnionArms.contains(index)) { + return true; + } + if (!options.reportToJson && isToJsonHook(candidate)) { + return true; + } + if (_isRemovedWithDeadClass(candidate, deadClassNames)) { + return true; + } + // Used through its members, never by name. + if (candidate.isExtension && + !safety.deadExtensions.contains(candidate.key)) { + return true; + } + final containerKey = candidate.containerKey; + if (containerKey == null) { + return false; + } + if (candidate.isExtensionMember && + safety.deadExtensions.contains(containerKey)) { + return true; + } + return candidate.isEnumValue && + safety.enumValuesIterated.contains(containerKey); + } + + /// Whether a dead [candidate] is real but must *not* be auto-removed, because + /// doing so would break the build: + /// + /// * a class kept dead only by type patterns under `--unused-union-members` + /// (never constructed, only matched): deleting a sealed member and its + /// scattered `case`s is a source rewrite this tool won't attempt; + /// * an enum value whose removal would empty a still-referenced enum; + /// * the last constructor of a live class with `final` fields or + /// super-constructor forwarding; + /// * a primary constructor or one of its declaring parameters. + /// + /// Each is surfaced so a human can act on it, but the remover leaves it — and + /// anything coupled to it — entirely alone. + bool isRemovalBlocked( + Candidate candidate, + List refs, + RemoveSafety safety, + ) { + final containerKey = candidate.containerKey; + return (candidate.symbol.kind == .class$ && + options.unusedUnionMembers && + _classifier.isPatternMatchedClass(candidate, refs)) || + (candidate.isEnumValue && + containerKey != null && + safety.emptiedEnums.contains(containerKey)) || + (candidate.symbol.kind == .constructor && + containerKey != null && + safety.blockedCtorClasses.contains(containerKey)) || + _isHeaderDeclaration(candidate); + } + + /// Whether [candidate] is a member a subclass could override. A declaring + /// parameter is never removed anyway. + bool canBeOverridden(Candidate candidate) => switch (candidate.symbol.kind) { + .method || .property || .field => + candidate.container != null && + !candidate.isExtensionMember && + !_isHeaderDeclaration(candidate), + _ => false, + }; + + String? hintFor(Candidate candidate) { + if (_isHeaderDeclaration(candidate)) { + return candidate.symbol.kind == .constructor + ? _primaryConstructorHint + : _declaringParameterHint; + } + return candidate.isPreventInstantiationCtor + ? _preventInstantiationHint + : null; + } + + UnusedDeclaration finding( + Candidate candidate, + String rootPath, { + List coupledRemovals = const [], + bool removalBlocked = false, + String? hint, + }) { + final symbol = candidate.symbol; + // An unnamed extension's selection range is its `on` type. + final start = candidate.isUnnamedExtension + ? symbol.range.start + : symbol.selectionRange.start; + final name = symbol.declarationName(candidate.container); + // `extension on T` is no name to qualify members by. + final container = _isInUnnamedExtension(candidate) + ? null + : candidate.container; + return .new( + name: name, + kind: symbol.reportedKind( + parentIsEnum: candidate.isEnumValue, + isExtensionType: candidate.isExtensionType, + ), + filePath: relativePosix(candidate.path, rootPath), + // LSP positions are zero-based; report them one-based for humans. + line: start.line + 1, + column: start.character + 1, + isPrivate: isPrivateName(name), + container: container, + isEnumValue: candidate.isEnumValue, + range: symbol.declarationRange, + fullRange: candidate.outline.range.toDeclarationRange, + coupledRemovals: coupledRemovals, + removalBlocked: removalBlocked, + hint: hint, + ); + } + + /// See [StructuralChecks.isDeclaredInTypeHeader]. + bool _isHeaderDeclaration(Candidate candidate) => + switch (candidate.symbol.kind) { + .constructor || .field => _sources.isDeclaredInTypeHeader(candidate), + _ => false, + }; + + bool _isInUnnamedExtension(Candidate candidate) => + candidate.containerOutline?.element.isUnnamedExtension ?? false; + + /// Whether [candidate] goes with an already-dead class's own declaration — + /// any constructor, or a declaring parameter — so a single removal is not + /// reported twice. + bool _isRemovedWithDeadClass( + Candidate candidate, + Map> deadClassNames, + ) => + (candidate.symbol.kind == .constructor || + _isHeaderDeclaration(candidate)) && + (deadClassNames[candidate.path]?.contains(candidate.container) ?? false); +} + +/// File, then line, then column. +int compareByLocation(UnusedDeclaration a, UnusedDeclaration b) { + final byFile = a.filePath.compareTo(b.filePath); + if (byFile != 0) { + return byFile; + } + final byLine = a.line.compareTo(b.line); + return byLine != 0 ? byLine : a.column.compareTo(b.column); +}