Skip to content

feat: LSP references, deep symbols, smart completions, user hover#7

Merged
humancto merged 2 commits into
mainfrom
phase-1-developer-experience
Apr 11, 2026
Merged

feat: LSP references, deep symbols, smart completions, user hover#7
humancto merged 2 commits into
mainfrom
phase-1-developer-experience

Conversation

@humancto
Copy link
Copy Markdown
Owner

Summary

Phase 1 (Developer Experience) — first batch of LSP improvements.

  • Context-aware module completions: typing math. now only shows math members instead of all 200+ members from every module
  • Hover for user-defined symbols: functions show full signature (fn greet(name: String, age: Int) -> String), variables show mutability/type, structs show fields, types show variants, interfaces show methods
  • Deep go-to-definition: finds params, locals, for-loop vars, catch vars inside function bodies — not just top-level symbols
  • textDocument/references: find all references to any identifier with proper word-boundary checking
  • Null hover for unknown symbols: returns null instead of generic fallback (correct LSP behavior)

Test plan

  • 820 cargo tests pass (14 LSP-specific, 8 new)
  • forge run examples/hello.fg passes
  • forge run examples/functional.fg passes
  • Manual: open a .fg file in VS Code with Forge LSP, verify hover/completions/references work

…tions, and user-defined hover

- Module completions now filter to the specific module typed before the dot
- Hover shows signatures for user-defined functions, variables, structs, types, and interfaces
- Go-to-definition finds params, locals, and nested symbols inside function bodies
- Add textDocument/references support with word-boundary matching
- Return null hover for unknown symbols instead of generic fallback
- 8 new tests (14 total LSP tests)
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 55b8d95f7c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lsp/mod.rs
}
// Recurse into body
for inner in body {
collect_symbols_from_stmt(inner, line, symbols);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Track real nested symbol lines for go-to-definition

This recursion reuses the parent line for every nested statement, so locals/loop vars/catch vars are recorded as if they were declared on the enclosing function line. get_definition then builds ranges from that incorrect line, which can jump to the function header (or column 0 if the name is not on that line) instead of the actual declaration whenever the local is declared on a later line.

Useful? React with 👍 / 👎.

Comment thread src/lsp/mod.rs
return serde_json::Value::Null;
// Try deep symbol search first (includes params, locals inside functions)
let deep = collect_all_symbols(&text);
if let Some(symbol) = deep.iter().find(|s| s.name == word) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve definitions by scope instead of first name match

Definition lookup now picks the first symbol with a matching name in the flattened deep-symbol list, without considering lexical scope or cursor location. In files with repeated local names (for example i in multiple functions), go-to-definition on a later occurrence will incorrectly jump to an unrelated earlier declaration, making navigation unreliable in common code patterns.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

@humancto humancto left a comment

Choose a reason for hiding this comment

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

Review: Phase 1 LSP Improvements

Nice batch of DX improvements — context-aware completions, user-defined symbol hover, deep go-to-definition, and references are all solid additions. The test coverage is good (8 new tests) and CI is green on Linux/macOS/Clippy/Format/Integration. The hover output format (showing full signatures with types) is particularly well done.

Should fix before merge

  1. ImplBlock missing from recursive traversal — Both collect_symbols_from_stmt and hover_from_stmt don't handle Stmt::ImplBlock, so methods defined in impl/give blocks are invisible to deep symbols, go-to-definition, and hover. This is the only Stmt variant with a body that's not covered.

  2. Word boundary checks need explicit parentheses — The before_ok/after_ok expressions in get_references rely on implicit && > || precedence. While technically correct, this is a maintenance hazard. Add parens or extract a helper.

  3. Missing CHANGELOG.md entry — Required by project guidelines for user-facing changes.

Worth noting (non-blocking)

  • Inner symbol line numbers are approximate — Locals inside function bodies inherit the parent function's line number (AST limitation: body: Vec<Stmt> isn't Spanned). Go-to-definition for inner locals will jump to the function line, not the declaration. Fine for now, but worth a doc comment.
  • References is single-file — LSP spec expects workspace-wide search. Acceptable for v1.
  • context.includeDeclaration ignored — Declaration is always included. Minor spec gap.
  • Security Audit CI failure appears pre-existing (not caused by this PR's changes).

Generated by Claude Code

Comment thread src/lsp/mod.rs Outdated
Comment on lines +720 to +726
let before_ok = abs_col == 0
|| !line_content.as_bytes()[abs_col - 1].is_ascii_alphanumeric()
&& line_content.as_bytes()[abs_col - 1] != b'_';
let after_pos = abs_col + word.len();
let after_ok = after_pos >= line_content.len()
|| !line_content.as_bytes()[after_pos].is_ascii_alphanumeric()
&& line_content.as_bytes()[after_pos] != b'_';
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Bug (operator precedence): These boundary checks rely on && binding tighter than || in Rust to be correct. While the logic does evaluate correctly today, it's a readability trap — most readers will misread it as (abs_col == 0 || !is_alpha) && != '_', which would be wrong.\n\nPlease add explicit parentheses to make the intent unambiguous:\n\nrust\nlet before_ok = abs_col == 0\n || (!line_content.as_bytes()[abs_col - 1].is_ascii_alphanumeric()\n && line_content.as_bytes()[abs_col - 1] != b'_');\nlet after_ok = after_pos >= line_content.len()\n || (!line_content.as_bytes()[after_pos].is_ascii_alphanumeric()\n && line_content.as_bytes()[after_pos] != b'_');\n\n\nOr better yet, extract a small helper like fn is_word_char(b: u8) -> bool { b.is_ascii_alphanumeric() || b == b'_' } and simplify to:\nrust\nlet before_ok = abs_col == 0 || !is_word_char(line_content.as_bytes()[abs_col - 1]);\nlet after_ok = after_pos >= line_content.len() || !is_word_char(line_content.as_bytes()[after_pos]);\n


Generated by Claude Code

Comment thread src/lsp/mod.rs
Comment on lines +763 to 894
fn collect_symbols_from_stmt(stmt: &Stmt, line: usize, symbols: &mut Vec<DocumentSymbolInfo>) {
match stmt {
Stmt::FnDef {
name, params, body, ..
} => {
symbols.push(DocumentSymbolInfo {
name: name.clone(),
kind: 12,
line,
});
// Add parameters as variable symbols
for param in params {
symbols.push(DocumentSymbolInfo {
name: param.name.clone(),
kind: 13,
line,
});
}
// Recurse into body
for inner in body {
collect_symbols_from_stmt(inner, line, symbols);
}
}
Stmt::Let { name, .. } => {
symbols.push(DocumentSymbolInfo {
name: name.clone(),
kind: 13,
line,
});
}
Stmt::StructDef { name, .. } => {
symbols.push(DocumentSymbolInfo {
name: name.clone(),
kind: 23,
line,
});
}
Stmt::TypeDef { name, .. } => {
symbols.push(DocumentSymbolInfo {
name: name.clone(),
kind: 10,
line,
});
}
Stmt::InterfaceDef { name, .. } => {
symbols.push(DocumentSymbolInfo {
name: name.clone(),
kind: 11,
line,
});
}
Stmt::PromptDef { name, .. } => {
symbols.push(DocumentSymbolInfo {
name: name.clone(),
kind: 12,
line,
});
}
Stmt::AgentDef { name, .. } => {
symbols.push(DocumentSymbolInfo {
name: name.clone(),
kind: 5,
line,
});
}
Stmt::For {
var, var2, body, ..
} => {
symbols.push(DocumentSymbolInfo {
name: var.clone(),
kind: 13,
line,
});
if let Some(v2) = var2 {
symbols.push(DocumentSymbolInfo {
name: v2.clone(),
kind: 13,
line,
});
}
for s in body {
collect_symbols_from_stmt(s, line, symbols);
}
}
Stmt::TryCatch {
try_body,
catch_var,
catch_body,
} => {
for s in try_body {
collect_symbols_from_stmt(s, line, symbols);
}
symbols.push(DocumentSymbolInfo {
name: catch_var.clone(),
kind: 13,
line,
});
for s in catch_body {
collect_symbols_from_stmt(s, line, symbols);
}
}
Stmt::If {
then_body,
else_body,
..
} => {
for s in then_body {
collect_symbols_from_stmt(s, line, symbols);
}
if let Some(eb) = else_body {
for s in eb {
collect_symbols_from_stmt(s, line, symbols);
}
}
}
Stmt::While { body, .. }
| Stmt::Loop { body, .. }
| Stmt::Spawn { body }
| Stmt::SafeBlock { body }
| Stmt::TimeoutBlock { body, .. }
| Stmt::RetryBlock { body, .. }
| Stmt::ScheduleBlock { body, .. }
| Stmt::WatchBlock { body, .. } => {
for s in body {
collect_symbols_from_stmt(s, line, symbols);
}
}
_ => {}
}
}

fn get_hover(uri: &str, line: usize, character: usize) -> serde_json::Value {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Missing Stmt::ImplBlock traversal: collect_symbols_from_stmt doesn't recurse into Stmt::ImplBlock { methods, .. }. This means methods defined via impl/give blocks won't be found by deep go-to-definition or references. Since ImplBlock.methods is a Vec<Stmt> (containing FnDef entries), it should be straightforward to add:\n\nrust\nStmt::ImplBlock { methods, .. } => {\n for m in methods {\n collect_symbols_from_stmt(m, line, symbols);\n }\n}\n\n\nSame gap exists in hover_from_stmt below.


Generated by Claude Code

Comment thread src/lsp/mod.rs
Comment on lines +767 to +784
} => {
symbols.push(DocumentSymbolInfo {
name: name.clone(),
kind: 12,
line,
});
// Add parameters as variable symbols
for param in params {
symbols.push(DocumentSymbolInfo {
name: param.name.clone(),
kind: 13,
line,
});
}
// Recurse into body
for inner in body {
collect_symbols_from_stmt(inner, line, symbols);
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Inaccurate line numbers for inner symbols: When recursing into function bodies, the parent's line is propagated to all inner statements (params, locals, nested lets). Since body: Vec<Stmt> doesn't carry Spanned wrappers, there's no line info for inner stmts.\n\nThis means go-to-definition for a local variable inside a function will jump to the function definition line rather than the variable's actual declaration line. symbol_range() then does lines.get(line).find(name) on the function's line, which will fail for inner locals (returning column 0).\n\nI understand this is an AST limitation (body stmts aren't Spanned), but it's worth calling out. Consider at minimum adding a doc comment noting this known limitation, so future contributors don't spend time debugging it.


Generated by Claude Code

Comment thread src/lsp/mod.rs
}

/// Find all references to a symbol in the document (text-based search with word boundaries).
fn get_references(uri: &str, line: usize, character: usize) -> Vec<serde_json::Value> {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Nit (spec compliance): Two minor LSP spec deviations to be aware of:\n\n1. context.includeDeclaration (from ReferenceParams) is not checked — the declaration is always included. Most clients default to true so this is fine in practice, but it's a gap.\n2. This is single-file only. The LSP spec expects textDocument/references to search across the workspace. Totally acceptable for a v1, but worth noting in a comment or the PR description so it doesn't get forgotten.


Generated by Claude Code

Comment thread src/lsp/mod.rs
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Missing CHANGELOG entry: Per the project's CLAUDE.md guidelines: "Every PR that ships user-facing changes must have an entry under [Unreleased]." This PR adds 5 user-facing LSP features and should have a corresponding CHANGELOG.md update under ### Added.


Generated by Claude Code

…ity, CHANGELOG

- Add ImplBlock handling to collect_symbols_from_stmt and hover_from_stmt
  so methods in impl/give blocks appear in go-to-def and hover
- Extract is_ident_byte helper in get_references to clarify operator
  precedence in word boundary checks
- Add [Unreleased] CHANGELOG entries for LSP improvements
Copy link
Copy Markdown
Owner Author

@humancto humancto left a comment

Choose a reason for hiding this comment

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

Re-review: All feedback addressed ✓

All three items from my previous review are resolved in 8216c28:

  1. Word boundary clarity — Extracted is_ident_byte closure, boundary checks are now clean one-liners. Much easier to read.
  2. ImplBlock traversal — Added to both collect_symbols_from_stmt and hover_from_stmt. Methods in impl/give blocks are now reachable.
  3. CHANGELOG entry — Added under [Unreleased] > ### Added with all four user-facing features listed.

CI status

  • Linux, macOS, Clippy, Format, Integration Tests, Backend Parity: all green
  • Windows: failing, but this PR only touches src/lsp/mod.rs (unit tests use in-memory doc store, no platform-specific paths) and CHANGELOG.md. This was already in-progress/unresolved during my first review — likely pre-existing or flaky. Worth investigating separately but should not block this PR.
  • Security Audit: failing (pre-existing, same as first review)

LGTM. Ship it.


Generated by Claude Code

@humancto humancto merged commit 2e957ed into main Apr 11, 2026
7 of 9 checks passed
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.

1 participant