Skip to content

Serve pull diagnostics and unify push/pull/CLI diagnostic sets - #329

Open
abilliontokens wants to merge 3 commits into
Hessesian:mainfrom
abilliontokens:fix/pull-diagnostics-for-omp
Open

abilliontokens wants to merge 3 commits into
Hessesian:mainfrom
abilliontokens:fix/pull-diagnostics-for-omp

Conversation

@abilliontokens

Copy link
Copy Markdown

Pull-based clients (oh-my-pi's "LSP diagnostics") always saw a clean bill: the server advertised no diagnosticProvider, so textDocument/diagnostic answered method_not_found. Worse, each diagnostics surface hand-assembled its own check list, and they had drifted — the CLI never ran unused-import or missing-package.

What changed

  • textDocument/diagnostic (pull) now served: diagnosticProvider capability + handler returning the same set the push path publishes, working for never-opened files via live_doc_or_parse/disk fallback.
  • One shared coordinator (src/features/diagnostics.rs): didOpen, republish, debounced-didChange, pull, and CLI all funnel through semantic_diagnostics / full_diagnostics.
  • Cold-start race handled: omp spawns the server on the first request, so pull waits bounded for the workspace scan before answering instead of returning syntax-only. Deadline is 18s — deliberately inside omp's 20s default request timeout, so a slow scan yields a fast fallback answer rather than a client-side timeout.
  • when split into a doc-based core (when_diagnostics_with_doc) so pull doesn't depend on a stored live tree.
  • CLI parity: diagnose runs unused-import + missing-package with --only support; both skip the index build.
  • Docs (docs/features.md), CHANGELOG.md (Unreleased), CLI help text updated.

Verification

  • cargo build, cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings clean.
  • New tests: coordinator unit tests, 2 CLI parity tests, smoke_pull_diagnostics_matches_push.
  • Full suite: 2024 unit + all integration green except the 2 pre-existing JAR smoke tests, which need the kmp-jar-indexer sidecar artifact CI fetches (absent in a bare checkout — documented in ci.yml).
  • End-to-end against the installed exe over stdio, cold spawn: error fixture returns both connect: expected 2 argument(s), found 1 and Unused import (4.3s silo-rooted); the real WildernessSkull.kt returns a genuinely empty full report.

Notes

  • Covers syntax + arity, nullability, when exhaustiveness, and import hygiene — still no full type checking (documented); deeper errors need Gradle/CI.
  • I also replaced my local omp install (~/.omp/lsp/kmp-lsp/kmp-lsp.exe, debug build) with this branch's binary to verify; that step is outside this PR.

Pull clients (oh-my-pi LSP diagnostics) always saw a clean bill: no
diagnosticProvider was advertised, so textDocument/diagnostic answered
method_not_found. Pull now serves the same set the push path publishes
(syntax, call-arg, nullable, when, missing-import, unused-import,
missing-package) through one shared coordinator, waits bounded (18s,
inside omp's default request timeout) for a cold-start workspace scan
before answering, and works for never-opened files. kmp-lsp diagnose
also runs unused-import and missing-package now (previously LSP-only),
with --only support for both.
…atterns crawl scoping

missing-import flagged kotlin.* top-level functions (lazy, repeat, with)
whenever no stdlib JAR/sources were indexed while a same-named workspace
declaration made them look importable. They are now exempt as a language
fact in resolvable_via_default_import, mirroring the hardcoded
default-import type list. The Tier-1 promotion regression test now probes
with runCatching (outside the name-level list) so it still guards the
promote-before-read path.

workspace.json gains sourceJarPatterns: substring filters on
group.artifact limiting which Gradle sources JARs get parsed (absent =
unscoped). The global cache holds every project on the machine; a
full-cache warmup plateaued at ~3.4GB RSS.
nullable-dot diagnostic attributed receivers via file-wide name scan,
so a same-named declaration in another function (tile: CoordGrid?
parameter vs val tile = free.removeAt(...)) poisoned every use.
Simple-identifier receivers now resolve through the lexically visible
declaration; unresolvable visible locals suppress instead of guessing.

call-arg diagnostic flagged calls kotlinc accepts through overridden
declarations' defaults (verified by compiling: Player.spotanim drops
PathingEntity.spotanim defaults, yet 3-arg calls compile). When the
resolved signature rejects, same-name members on the receiver's or
enclosing scope's hierarchy chain are tested for acceptance first.
@Hessesian

Copy link
Copy Markdown
Owner

Great find ! thanks a lot for PR, I'll review it as soon as possible

@Hessesian Hessesian left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the pull-diagnostics work is solid and the false positives you chased down are real. I checked the PR against AGENTS.md, docs/agent-reference.md and docs/architecture/unified-resolution-strategy.md. Inline comments have the details; the overall suggestion is below.

Please split this into separate PRs

The title covers pull diagnostics, but the PR carries five independent changes. They have very different risk levels, and bundling them means the good parts wait on the contentious ones.

  1. Pull diagnostics + shared coordinator (diagnostics.rs, capability, handler, when_diagnostics_with_doc, workspace handler call sites). This is close to mergeable. Two things to fix: route cli/run.rs through the coordinator too (the description says the CLI funnels through it, but it still hand-assembles its own list), and reconsider the hardcoded 18 s wait.
  2. Stdlib default-import function exemption. Fine in principle; derive the name set from the existing tables in src/stdlib.rs instead of a second hand-maintained list.
  3. sourceJarPatterns. A new config feature, deserves its own PR and discussion. Reuse parse_workspace_data rather than adding a parallel loader.
  4. Nullable cross-function leak. Real bug, and the ScopedLocal three-way outcome is the right idea. But the implementation re-parses source lines with string matching, where the project rule is to work on the CST and the codebase already has a scope-correct CST walker for exactly this problem. See the inline comment for a much smaller route.
  5. Call-arg override tolerance. Real false positive, but the fix reads indexer.definitions / indexer.files by bare name with no reachability filter, and half of it (the unqualified path) is unreachable in production by the PR's own test comment.

For 4 and 5, some context: resolution logic in this repo is being consolidated so that features stop growing their own private resolvers (docs/architecture/unified-resolution-strategy.md). #331 is actively reworking the same functions in resolver/infer.rs that this PR widens to pub(crate). I would rather land those two fixes on top of the shared CST/resolver primitives than add two new feature-local resolvers that then need migrating.

Conventions (apply across the PR)

  • No abbreviated names (AGENTS.md): recv, recv_raw, recv_col, recv_base, ty, eq, idx, occ_text, pos, diags, resp, and single-letter closure parameters (c, b, i).
  • Numbered section comments inside a function body (// 1., // 2., // 3. in scoped_variable_type) signal that each phase wants to be a named helper.
  • Search before you write (agent-reference §12): several new helpers duplicate existing ones; each is flagged inline.

What is already good: tests live in companion *_tests.rs files, every fix has a test plus a negative guard, node kinds use the KIND_* constants, no unwrap() in production code, and the CHANGELOG and docs are updated. CI is green on all three platforms.

Happy to help with the CST-based version of 4 if useful.

}

/// Core: type of `name` as declared visibly at `use_line`/`use_byte_col`.
fn scoped_variable_type(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function and its helpers (is_value_declaration, is_value_or_param_declaration, assignment_eq_pos, bare_call_callee, find_ident_col) decide which declaration is in scope by string-matching source lines. local_scope_occurrences has already done a CST walk at this point; its result is then reduced to line numbers and the declaration is re-guessed from text.

There is an existing scope-correct CST walker for this exact problem: resolve_declared_type_from_cst(point, var_name, source) in src/resolver/infer.rs. Its doc comment describes this bug (several functions with same-named parameters, a whole-file scan cannot prefer the one in scope). The nullable diagnostic simply does not call it yet.

It cannot be used as-is, for two reasons:

  • It returns Option<String>, so "found an unannotated declaration in scope" and "found nothing" both come back as None. Your ScopedLocal enum is the right shape for that outcome and should move onto the walker.
  • It drops the ? from a nullable type (type_from_nullable), which this diagnostic needs.

Suggested route: extend that walker to return a three-way outcome (carrying the declaration node when there is no annotation, and preserving nullability), run infer_expr_type on the initializer node for the unannotated case, and call it from resolve_receiver before infer_receiver_type. That keeps the fix and removes most of the ~340 lines added here.

}
// `name:` form, whole word, not a `::` callable reference.
let pattern = format!("{name}:");
let mut search_from = 0;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible panic: search_from = pos + 1 followed by line[search_from..] slices in the middle of a UTF-8 character when name starts with a non-ASCII character and the first match is rejected. Example: a local named über used as a bound reference, über::run — after_ok is false, the loop advances one byte into ü, and the slice panics. find_ident_col below already uses line.get(search_from..)?; the same is needed here (or advance by the matched character's length).

/// Type of an unannotated `val`/`var` from the initializer tables, filtered
/// to the visible declaration's own line — never another same-named
/// declaration's entry.
fn scoped_initializer_type(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things here:

  • The receiver position is recovered by searching the declaration line for = and then for the identifier text (assignment_eq_pos, find_ident_col), and bare_call_callee parses the right-hand side by counting parentheses. With the declaration node in hand, the initializer is a CST child and infer_expr_type (src/indexer/infer/expr_type.rs) already infers its type, including method and field hops.
  • The method_match and field_match blocks are near copies of each other (same eq / recv_col / scoped_variable_type match). If this stays, the shared part wants to be one helper.


/// Reduce a type string to a bare class name for hierarchy/member lookup:
/// drop generics, outer qualifiers, and a trailing `?`.
fn bare_class_name(type_name: &str) -> String {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicates existing StrExt helpers: dotted_ident_prefix().last_segment() plus strip_nullable() — the same chain resolve_method_return_type_substituted uses. The same inline chain is also repeated in scoped_initializer_type in the nullable file.

/// Lexically enclosing scope types for an unqualified call, nearest first:
/// extension-receiver types of enclosing `fun Receiver.name` declarations
/// and names of enclosing classes/objects (implicit `this` scopes).
fn enclosing_scope_types(call_node: &tree_sitter::Node, bytes: &[u8]) -> Vec<String> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three concerns:

  • The comment on tolerance_accepts_bare_call_in_extension_scope says the end-to-end version of this shape "passes vacuously — unqualified resolution already declines to diagnose". If so, this unqualified path cannot change any production result, and the function plus its direct-call test should be dropped until there is a case that needs it.
  • node.parent() in a loop: tree-sitter's parent() is O(depth), not a pointer dereference (agent-reference §18a). This cost a 217 s rename once.
  • Existing helpers cover both lookups: enclosing_class_name (src/indexer/infer/chain.rs) and enclosing_extension_receiver_at (src/parser.rs).

Small bug as well: the dedupe checks types.contains(&base) but pushes bare_class_name(&base), so the check compares against the un-normalised form.

Comment thread src/cli/run.rs
}
}
} else {
// No index needed: `unused-import` is a pure CST walk and

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says the CLI funnels through semantic_diagnostics / full_diagnostics, but run_diagnose still calls each feature itself, and the unused-import / missing-package block now exists twice (once per branch). DIAGNOSTIC_NAMES is kept in sync with the coordinator only by its doc comment.

That is the drift this PR sets out to remove (agent-reference §17: "every caller must remember to do X"). Suggestion: have the coordinator own the list of checks with their names and whether each needs the index, so --only filtering, the LSP paths and DIAGNOSTIC_NAMES all derive from one place.

Comment thread src/resolver/imports.rs
/// calls it unqualified without an import will no longer be told about it.
/// Names mirror the project's own curated stdlib inventory (`TOP_LEVEL_FUNS`
/// / `SCOPE_FUNS` in `src/stdlib.rs`), not a guess at the whole stdlib.
fn is_default_import_function(name: &str) -> bool {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says these names mirror TOP_LEVEL_FUNS / SCOPE_FUNS in src/stdlib.rs. A hand-kept mirror will drift; please derive the check from those tables (a small stdlib::is_default_import_function(name) over the two slices) so there is one inventory.

Comment thread src/workspace_json.rs
/// is not present — callers index every discovered sources JAR (unscoped).
/// Returns `Some(patterns)` when the key is present, including an explicitly
/// empty list (which parses no sources JARs at all).
pub(crate) fn load_source_jar_patterns(workspace_root: &Path) -> Option<Vec<String>> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_workspace_data just below already does the exists / read / parse / log-warning sequence and returns the full WorkspaceData. This can be parse_workspace_data(workspace_root)?.source_jar_patterns.

Separately: index_sources_jars now reads workspace.json from disk on every call. The other workspace.json settings are resolved once at the workspace-config level and passed in; this one should follow the same route.

Comment thread src/backend/handlers.rs
&self,
params: DocumentDiagnosticParams,
) -> Result<DocumentDiagnosticReportResult> {
const SCAN_WAIT_DEADLINE: std::time::Duration = std::time::Duration::from_secs(18);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 18 s figure is tuned to one client's default request timeout and hardcoded in the server. Other pull clients will have different timeouts, and an editor that pulls on every change would block for up to 18 s per request during a scan. Could this be a much shorter default with an initialization option to raise it? A notification-based wait on scan completion would also be preferable to a 50 ms poll loop, but that can be a follow-up.

Comment thread src/resolver/infer.rs
/// supertype-walk fallback itself went missing from this file before it was
/// added back (see `find_method_return_type_via_supertypes`'s callers).
fn resolve_method_return_type_substituted(
pub(crate) fn resolve_method_return_type_substituted(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and find_field_type_in_class_impl are internal steps of the string-engine inference, widened to pub(crate) so a feature can drive them directly. #331 is changing how these hops carry a type's origin, so a new external caller here will need migrating. Going through infer_expr_type / the Resolver trait instead would avoid exposing them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants