diff --git a/architecture/07-implementation-roadmap.md b/architecture/07-implementation-roadmap.md index 4ce0c7b..6adbf77 100644 --- a/architecture/07-implementation-roadmap.md +++ b/architecture/07-implementation-roadmap.md @@ -54,6 +54,8 @@ diagnostics. - compiler-backed LSP diagnostics, related labels, quick fixes, document symbols, direct-call parameter inlay hints, and dependency-free same-Bubble snapshots; +- compiler-resolved definition navigation for namespace-scope functions and + direct calls within those snapshots; - validated canonical binary/library scaffolding through `pop new` and `pop initialize`. diff --git a/architecture/08.1-closed-design-questions.md b/architecture/08.1-closed-design-questions.md index a53c9e4..85cfa2c 100644 --- a/architecture/08.1-closed-design-questions.md +++ b/architecture/08.1-closed-design-questions.md @@ -368,11 +368,13 @@ language server consumes a version-coupled compiler tooling projection. ADR namespace-scope document symbols for immutable open-Module snapshots. ADR 0090 adds structured related diagnostics, current compiler quick fixes, direct-call parameter hints, and conservative same-Bubble analysis for dependency-free -Packages. The projection carries typed identities and source spans, never -compiler arenas, HIR/MIR values, CLI text, or string-based resolution. -Completion, signature help, cross-Bubble navigation, references, rename, -formatting, semantic tokens, incremental edits, and complete -Workspace/dependency analysis remain separately reviewed work. +Packages. ADR 0092 adds definition navigation for namespace-scope functions and +resolved direct calls inside those exact snapshots. The projection carries +typed identities and source spans, never compiler arenas, HIR/MIR values, CLI +text, or string-based resolution. Completion, signature help, local/member or +cross-Bubble navigation, references, rename, formatting, semantic tokens, +incremental edits, and complete Workspace/dependency analysis remain +separately reviewed work. ### 1. Compiler implementation language and repository layout @@ -757,6 +759,11 @@ mismatch. Target objects stay opaque and documentation stays separate. See ADR boundary first, then enables conventional same-Bubble analysis only when that Bubble has no unresolved dependencies. Nested Packages never merge. See [ADR 0090](./decisions/0090-rich-private-editor-analysis.md). +- Definition navigation joins compiler-projected occurrences and declarations + only by `SymbolIdentity`. It is initially limited to namespace-scope + functions and resolved direct calls inside one dependency-free Bubble + snapshot. Paths only present locations. See + [ADR 0092](./decisions/0092-private-semantic-definition-navigation.md). - `pop new` and `pop initialize` create only validated canonical binary or library Package scaffolds. They never rewrite identities, overwrite protected entries, initialize version control, or download dependencies. See diff --git a/architecture/21-cli-tooling-and-code-units.md b/architecture/21-cli-tooling-and-code-units.md index 8c07429..db1e6eb 100644 --- a/architecture/21-cli-tooling-and-code-units.md +++ b/architecture/21-cli-tooling-and-code-units.md @@ -594,11 +594,18 @@ the nearest ancestor Package manifest selects conventional same-Bubble Modules when that Bubble has no unresolved dependency edge. Nested Packages remain distinct; an outer Workspace or editor folder never merges their visibility. +ADR 0092 adds `textDocument/definition` for namespace-scope function +declarations and statically resolved direct calls in that same immutable +Bubble snapshot. The compiler projects exact occurrence spans and +`SymbolIdentity`; the adapter joins by that identity and uses paths only to +present the destination URI. Unsupported, unresolved, indirect, or +dependency-owned uses return no destination. + The adapter is a private executable protocol boundary, not the public `Pop.Lsp` API and not a re-export of `Pop.Rpc`. Completion, signature help, -cross-Bubble navigation, references, rename, formatting, semantic tokens, -incremental text edits, complete Workspace/dependency analysis, and public -transport types require their separately reviewed schemas. +local/member or cross-Bubble navigation, references, rename, formatting, +semantic tokens, incremental text edits, complete Workspace/dependency +analysis, and public transport types require their separately reviewed schemas. Editor extensions launch the server directly and consume structured LSP data; they may invoke `pop` commands for explicit user actions but never scrape CLI human output to synthesize language-server results. diff --git a/architecture/decisions/0092-private-semantic-definition-navigation.md b/architecture/decisions/0092-private-semantic-definition-navigation.md new file mode 100644 index 0000000..89ab458 --- /dev/null +++ b/architecture/decisions/0092-private-semantic-definition-navigation.md @@ -0,0 +1,111 @@ +# ADR 0092: Private Semantic Definition Navigation + +- Status: accepted +- Date: 2026-07-18 +- Supersedes: none +- Amends: ADR 0089, ADR 0090 + +## Context + +ADRs 0089 and 0090 authorize a version-coupled compiler tooling projection and +conservative same-Bubble Package snapshots. The private language server can +therefore prove which declaration a direct call selects, but it discards that +identity after producing parameter inlay hints. Editors cannot navigate even +between two Modules that the same compiler query has already resolved. + +Definition navigation cannot be recovered safely from spelling, namespaces, +filenames, numeric IDs, or CLI output. Complete Workspace dependency loading, +public source and syntax schemas, local binding indexes, references, and rename +remain separate work. + +## Decision + +The private compiler tooling projection may expose resolved definition +occurrences. Each occurrence contains: + +- the exact source selection span; +- the resolved `SymbolIdentity`; and +- no syntax, resolver, HIR, or MIR value. + +The projection initially covers namespace-scope function declarations and +statically resolved direct function calls inside one analyzed Bubble. A +declaration name is an occurrence of its own identity. Unresolved, indirect, +referenced-dependency, method, member, local, and parameter uses do not produce +an occurrence in this slice. + +The private language server implements LSP 3.17 +`textDocument/definition`. It joins an occurrence to a declaration by +`SymbolIdentity`, returns the declaration selection range, and returns `null` +when either side is absent. The request reads the current immutable document +snapshot, checks cancellation, and uses UTF-16 positions. A definition in +another source-owned Module of the same selected Bubble may return that +Module's file URI. + +One Bubble analysis snapshot owns a deterministic map from session `FileId` +values to source URIs and text. An already open Module keeps its session +`FileId`; a closed Module receives a deterministic snapshot-local ID. File +paths and URIs select and present source inputs only. They never establish +symbol identity, merge visibility, or replace the Item → Module → Bubble → +Package → Workspace hierarchy. + +The existing ADR 0090 restrictions remain active. Package snapshots are used +only for conventionally discovered Bubbles without unresolved dependency +edges. Dependency references, sibling Bubbles, nested Packages, and editor +workspace folders are never guessed or merged. Complete Workspace snapshots +must later reuse the Package resolver and locked dependency graph rather than +extend this filesystem bootstrap heuristically. + +References, rename, completion, signature help, local/member navigation, +cross-Bubble navigation, public `Pop.Syntax`/`Pop.Lsp` schemas, and incremental +range edits remain outside this decision. + +## Consequences + +- Same-Module and same-Bubble direct calls gain exact definition navigation. +- Navigation reuses compiler resolution instead of building a competing editor + index. +- The snapshot retains enough source identity to present cross-Module + locations while semantic identity remains path-independent. +- Unsupported or incomplete analysis fails closed with `null`. + +## Alternatives considered + +### Search names in open text + +Rejected because spelling cannot prove overload selection, visibility, +shadowing, or Bubble identity. + +### Navigate by namespace and filename + +Rejected because neither value is semantic identity and Modules do not derive +identity from directory layout. + +### Expose resolver or HIR nodes to the server + +Rejected because compiler-private arenas and IR are unstable ownership +boundaries and are not public tooling schemas. + +### Implement references and rename together + +Rejected because multi-file edits, dependency indexes, locals, members, stale +snapshot handling, and atomic verification require a broader contract. + +## Required conformance tests + +- a declaration name and a same-Module direct call navigate to the exact + declaration selection; +- a call in one Module navigates to a declaration in a sibling Module of the + same dependency-free Bubble; +- UTF-16 request and result positions remain exact around non-BMP text; +- unresolved, indirect, dependency-bearing, stale, and closed snapshots do not + fabricate a destination; +- two Packages or Bubbles with the same namespace never merge; +- the compiler projection joins occurrences and declarations only by + `SymbolIdentity`; and +- advertised LSP capabilities exactly match the implemented request. + +## Documents/components affected + +CLI/tooling architecture, implementation roadmap, closed design decisions, +compiler driver tooling projections, private language-server snapshots and +transport, official editor extensions, and architecture conformance tests. diff --git a/crates/compiler/driver/src/api.rs b/crates/compiler/driver/src/api.rs index 923a1ca..5e491f0 100644 --- a/crates/compiler/driver/src/api.rs +++ b/crates/compiler/driver/src/api.rs @@ -157,6 +157,7 @@ pub struct FrontEndResult { pub(crate) retained_metadata: Result, pub(crate) checked_documentation: Vec, pub(crate) tooling_declarations: Vec, + pub(crate) tooling_definition_occurrences: Vec, pub(crate) tooling_inlay_hints: Vec, } @@ -226,6 +227,25 @@ pub enum ToolingDeclarationKind { Enum, } +/// Compiler-resolved source occurrence for private definition navigation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ToolingDefinitionOccurrence { + pub(crate) identity: SymbolIdentity, + pub(crate) selection_span: SourceSpan, +} + +impl ToolingDefinitionOccurrence { + #[must_use] + pub const fn identity(&self) -> SymbolIdentity { + self.identity + } + + #[must_use] + pub const fn selection_span(&self) -> SourceSpan { + self.selection_span + } +} + /// Compiler-proven parameter name attached to one direct-call argument. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ToolingInlayHint { @@ -1240,6 +1260,11 @@ impl FrontEndResult { &self.tooling_declarations } + #[must_use] + pub fn tooling_definition_occurrences(&self) -> &[ToolingDefinitionOccurrence] { + &self.tooling_definition_occurrences + } + #[must_use] pub fn tooling_inlay_hints(&self) -> &[ToolingInlayHint] { &self.tooling_inlay_hints diff --git a/crates/compiler/driver/src/front_end.rs b/crates/compiler/driver/src/front_end.rs index 3d1f8ac..057b9d0 100644 --- a/crates/compiler/driver/src/front_end.rs +++ b/crates/compiler/driver/src/front_end.rs @@ -565,6 +565,8 @@ pub fn analyze_bubble(input: FrontEndBubbleInput) -> FrontEndResult { ) }); let tooling_inlay_hints = hir.as_ref().map_or_else(Vec::new, tooling_inlay_hints); + let tooling_definition_occurrences = + tooling_definition_occurrences(input.bubble, hir.as_ref(), &tooling_declarations); sort_diagnostics(&mut diagnostics); FrontEndResult { hir, @@ -590,10 +592,46 @@ pub fn analyze_bubble(input: FrontEndBubbleInput) -> FrontEndResult { retained_metadata, checked_documentation, tooling_declarations, + tooling_definition_occurrences, tooling_inlay_hints, } } +fn tooling_definition_occurrences( + bubble: BubbleId, + hir: Option<&HirBubble>, + declarations: &[ToolingDeclaration], +) -> Vec { + let mut occurrences = declarations + .iter() + .filter(|declaration| declaration.kind() == ToolingDeclarationKind::Function) + .map(|declaration| ToolingDefinitionOccurrence { + identity: declaration.identity(), + selection_span: declaration.selection_span(), + }) + .collect::>(); + if let Some(hir) = hir { + occurrences.extend( + hir.functions() + .iter() + .chain(hir.methods().iter().map(pop_hir::HirMethod::function)) + .flat_map(pop_hir::hir_source_calls) + .map(|call| ToolingDefinitionOccurrence { + identity: SymbolIdentity::new(bubble, call.target()), + selection_span: call.callee_span(), + }), + ); + } + occurrences.sort_by_key(|occurrence| { + ( + occurrence.selection_span.file(), + occurrence.selection_span.range().start(), + occurrence.identity, + ) + }); + occurrences +} + fn tooling_inlay_hints(hir: &HirBubble) -> Vec { let owners = hir .functions() diff --git a/crates/compiler/driver/tests/front_end_pipeline.rs b/crates/compiler/driver/tests/front_end_pipeline.rs index 111336f..d441b06 100644 --- a/crates/compiler/driver/tests/front_end_pipeline.rs +++ b/crates/compiler/driver/tests/front_end_pipeline.rs @@ -6,6 +6,43 @@ use pop_hir::{HirCallDispatch, HirDeclarationKind, HirExpressionKind, HirStateme use pop_mir::{MirDeclarationKind, MirVerificationError, lower_hir_bubble}; use pop_source::SourceFile; +#[test] +fn tooling_definition_occurrences_preserve_overload_identity_and_callee_span() { + let text = "namespace Main\nfunction choose(value: Int): Int\n return value\nend\nfunction choose(value: String): String\n return value\nend\nfunction run(): String\n return choose(\"selected\")\nend\n"; + let source = SourceFile::new(FileId::from_raw(0), "src/navigation.pop", text).expect("source"); + let result = analyze_bubble(FrontEndBubbleInput::new( + BubbleId::from_raw(7), + NamespaceId::from_raw(0), + Vec::new(), + vec![FrontEndModule::new(ModuleId::from_raw(0), source)], + )); + assert!( + result.diagnostics().is_empty(), + "{}", + result.diagnostic_snapshot() + ); + + let call_start = text.rfind("choose").expect("call"); + let call = result + .tooling_definition_occurrences() + .iter() + .find(|occurrence| occurrence.selection_span().range().start().to_usize() == call_start) + .expect("resolved call occurrence"); + assert_eq!( + call.selection_span().range().end().to_usize(), + call_start + "choose".len(), + "the occurrence covers the callee only" + ); + let target = result + .tooling_declarations() + .iter() + .find(|declaration| declaration.identity() == call.identity()) + .expect("selected declaration identity"); + let signature = &text[target.signature_span().range().start().to_usize() + ..target.signature_span().range().end().to_usize()]; + assert_eq!(signature, "function choose(value: String): String"); +} + #[test] fn explicit_generic_functions_records_and_unions_reach_concrete_mir() { let source = SourceFile::new( diff --git a/crates/compiler/hir/src/ir.rs b/crates/compiler/hir/src/ir.rs index 2f77d81..03c8887 100644 --- a/crates/compiler/hir/src/ir.rs +++ b/crates/compiler/hir/src/ir.rs @@ -3693,6 +3693,7 @@ pub fn hir_referenced_call_instances(function: &HirFunction) -> Vec<(SymbolIdent #[derive(Clone, Debug, Eq, PartialEq)] pub struct HirSourceCall { target: SymbolId, + callee_span: SourceSpan, arguments: Vec, } @@ -3702,6 +3703,11 @@ impl HirSourceCall { self.target } + #[must_use] + pub const fn callee_span(&self) -> SourceSpan { + self.callee_span + } + #[must_use] pub fn arguments(&self) -> &[SourceSpan] { &self.arguments @@ -3718,6 +3724,7 @@ pub fn hir_source_calls(function: &HirFunction) -> Vec { .filter_map(|call| match call.target { HirCollectedCallTarget::Direct(target) => Some(HirSourceCall { target, + callee_span: call.callee_span, arguments: call.source_arguments, }), _ => None, @@ -3753,6 +3760,7 @@ pub fn hir_direct_data_references(function: &HirFunction) -> (Vec, Vec< struct HirCollectedCall { target: HirCollectedCallTarget, arguments: Vec, + callee_span: SourceSpan, source_arguments: Vec, } @@ -3933,6 +3941,7 @@ fn collect_statement_calls(statements: &[HirStatement], calls: &mut Vec { let target = match dispatch { @@ -4170,6 +4181,7 @@ fn collect_expression_calls(expression: &HirExpression, calls: &mut Vec, pub(crate) arguments: Vec, + pub(crate) callee_span: SourceSpan, pub(crate) span: SourceSpan, } @@ -5544,6 +5557,11 @@ impl HirCall { &self.arguments } + #[must_use] + pub const fn callee_span(&self) -> SourceSpan { + self.callee_span + } + #[must_use] pub const fn span(&self) -> SourceSpan { self.span @@ -5872,6 +5890,7 @@ pub enum HirExpressionKind { is_async: bool, type_arguments: Vec, arguments: Vec, + callee_span: SourceSpan, }, InterfaceUpcast { value: Box, diff --git a/crates/compiler/hir/src/lowering.rs b/crates/compiler/hir/src/lowering.rs index 807679b..d7ac479 100644 --- a/crates/compiler/hir/src/lowering.rs +++ b/crates/compiler/hir/src/lowering.rs @@ -990,6 +990,7 @@ fn lower_call(call: &TypedCall, interface_slots: &HirInterfaceSlotMap) -> HirCal .map(|argument| lower_expression(argument, interface_slots)), ) .collect(), + callee_span: call.callee_span(), span: call.span(), }; } @@ -1014,6 +1015,7 @@ fn lower_call(call: &TypedCall, interface_slots: &HirInterfaceSlotMap) -> HirCal .map(|argument| lower_expression(argument, interface_slots)), ) .collect(), + callee_span: call.callee_span(), span: call.span(), }; } @@ -1037,6 +1039,7 @@ fn lower_call(call: &TypedCall, interface_slots: &HirInterfaceSlotMap) -> HirCal .map(|argument| lower_expression(argument, interface_slots)), ) .collect(), + callee_span: call.callee_span(), span: call.span(), }; } @@ -1053,6 +1056,7 @@ fn lower_call(call: &TypedCall, interface_slots: &HirInterfaceSlotMap) -> HirCal .iter() .map(|argument| lower_expression(argument, interface_slots)) .collect(), + callee_span: call.callee_span(), span: call.span(), } } @@ -1548,7 +1552,7 @@ fn lower_expression( | TypedExpressionKind::DirectMethodCall { .. } | TypedExpressionKind::InterfaceMethodCall { .. } | TypedExpressionKind::BuiltinInterfaceMethodCall { .. }) => { - lower_call_expression(call, interface_slots) + lower_call_expression(call, expression.span(), interface_slots) } TypedExpressionKind::InterfaceUpcast { value, interface } => { HirExpressionKind::InterfaceUpcast { @@ -1626,6 +1630,7 @@ fn lower_expression( fn lower_call_expression( call: &TypedExpressionKind, + expression_span: SourceSpan, interface_slots: &HirInterfaceSlotMap, ) -> HirExpressionKind { match call { @@ -1642,12 +1647,14 @@ fn lower_call_expression( .iter() .map(|argument| lower_expression(argument, interface_slots)) .collect(), + callee_span: expression_span, }, TypedExpressionKind::DirectCall { function, is_async, type_arguments, arguments, + callee_span, } => HirExpressionKind::Call { dispatch: HirCallDispatch::Direct { function: *function, @@ -1658,12 +1665,14 @@ fn lower_call_expression( .iter() .map(|argument| lower_expression(argument, interface_slots)) .collect(), + callee_span: *callee_span, }, TypedExpressionKind::ReferencedCall { function, is_async, type_arguments, arguments, + callee_span, } => HirExpressionKind::Call { dispatch: HirCallDispatch::Referenced { function: *function, @@ -1674,6 +1683,7 @@ fn lower_call_expression( .iter() .map(|argument| lower_expression(argument, interface_slots)) .collect(), + callee_span: *callee_span, }, TypedExpressionKind::IndirectCall { callee, @@ -1689,6 +1699,7 @@ fn lower_call_expression( .iter() .map(|argument| lower_expression(argument, interface_slots)) .collect(), + callee_span: expression_span, }, TypedExpressionKind::DirectMethodCall { method, @@ -1707,6 +1718,7 @@ fn lower_call_expression( .map(|argument| lower_expression(argument, interface_slots)), ) .collect(), + callee_span: expression_span, }, TypedExpressionKind::InterfaceMethodCall { interface, @@ -1729,6 +1741,7 @@ fn lower_call_expression( .map(|argument| lower_expression(argument, interface_slots)), ) .collect(), + callee_span: expression_span, }, TypedExpressionKind::BuiltinInterfaceMethodCall { interface, @@ -1750,6 +1763,7 @@ fn lower_call_expression( .map(|argument| lower_expression(argument, interface_slots)), ) .collect(), + callee_span: expression_span, }, _ => unreachable!("call lowering accepts only typed call expressions"), } diff --git a/crates/compiler/hir/src/tests.rs b/crates/compiler/hir/src/tests.rs index 3cddafe..0e53970 100644 --- a/crates/compiler/hir/src/tests.rs +++ b/crates/compiler/hir/src/tests.rs @@ -409,6 +409,7 @@ fn bubble_verifier_rejects_direct_call_argument_and_result_spoofing() { }, type_arguments: Vec::new(), arguments: Vec::new(), + callee_span: span, span, }), span, @@ -423,6 +424,7 @@ fn bubble_verifier_rejects_direct_call_argument_and_result_spoofing() { }, type_arguments: Vec::new(), arguments: vec![string_expression(string, span)], + callee_span: span, }, type_id: string, span, @@ -497,6 +499,7 @@ fn bubble_verifier_rejects_indirect_call_argument_and_result_spoofing() { }, type_arguments: Vec::new(), arguments: Vec::new(), + callee_span: span, span, }), span, @@ -511,6 +514,7 @@ fn bubble_verifier_rejects_indirect_call_argument_and_result_spoofing() { }, type_arguments: Vec::new(), arguments: vec![string_expression(string, span)], + callee_span: span, }, type_id: string, span, @@ -674,6 +678,7 @@ fn bubble_verifier_checks_receiver_method_signatures_against_class_schema() { dispatch: HirCallDispatch::DirectMethod { method }, type_arguments: Vec::new(), arguments: vec![string_expression(string, span)], + callee_span: span, span, }), span, @@ -689,6 +694,7 @@ fn bubble_verifier_checks_receiver_method_signatures_against_class_schema() { string_expression(string, span), string_expression(string, span), ], + callee_span: span, }, type_id: string, span, @@ -1236,6 +1242,7 @@ fn interface_verifier_rejects_wrong_slots_mappings_arguments_and_results() { parameter_expression(0, interface_type, span), string_expression(string, span), ], + callee_span: span, }, type_id: integer, span, diff --git a/crates/compiler/hir/src/verification.rs b/crates/compiler/hir/src/verification.rs index 3993e3b..c328800 100644 --- a/crates/compiler/hir/src/verification.rs +++ b/crates/compiler/hir/src/verification.rs @@ -3742,6 +3742,7 @@ impl Verifier<'_> { is_async, type_arguments, arguments, + .. } => { self.verify_call( dispatch, diff --git a/crates/compiler/types/src/body_checking.rs b/crates/compiler/types/src/body_checking.rs index 9fc1697..d932820 100644 --- a/crates/compiler/types/src/body_checking.rs +++ b/crates/compiler/types/src/body_checking.rs @@ -1214,6 +1214,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async: signature.is_async(), type_arguments: resolved_arguments, arguments: checked_arguments, + callee_span: callee.span(), span, }, results: result_types, diff --git a/crates/compiler/types/src/call_checking.rs b/crates/compiler/types/src/call_checking.rs index eda9821..9690bd5 100644 --- a/crates/compiler/types/src/call_checking.rs +++ b/crates/compiler/types/src/call_checking.rs @@ -382,7 +382,13 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { ); if resolution.symbols().len() > 1 { return self - .check_exact_source_overload(&name, resolution.symbols(), arguments, span) + .check_exact_source_overload( + &name, + resolution.symbols(), + arguments, + callee.span(), + span, + ) .map(CheckedInvocation::Call); } if let Some(symbol) = resolution.symbol() @@ -390,7 +396,14 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { && !signature.type_parameters().is_empty() { return self - .check_inferred_generic_call(symbol, &signature, arguments, expected, span) + .check_inferred_generic_call( + symbol, + &signature, + arguments, + expected, + callee.span(), + span, + ) .map(CheckedInvocation::Call); } } @@ -457,6 +470,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { } typed_arguments.push(typed); } + let callee_span = callee.span(); let dispatch = self.call_dispatch(callee); let results = self.call_result_types(is_async, results)?; Some(CheckedInvocation::Call(CheckedCall { @@ -465,6 +479,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async, type_arguments: Vec::new(), arguments: typed_arguments, + callee_span, span, }, results, @@ -1908,6 +1923,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { name: &str, symbols: &[pop_foundation::SymbolId], arguments: &[ExpressionSyntax], + callee_span: SourceSpan, span: SourceSpan, ) -> Option { let candidates = symbols @@ -1982,6 +1998,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async: signature.is_async(), type_arguments: Vec::new(), arguments: typed_arguments, + callee_span, span, }, results, @@ -1994,6 +2011,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { signature: &crate::ResolvedFunctionSignature, arguments: &[ExpressionSyntax], expected: Option, + callee_span: SourceSpan, span: SourceSpan, ) -> Option { if signature.parameters().len() != arguments.len() { @@ -2128,6 +2146,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async: signature.is_async(), type_arguments: inferred_type_arguments, arguments: typed_arguments, + callee_span, span, }, results, @@ -3255,6 +3274,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async: false, type_arguments: Vec::new(), arguments: typed_arguments, + callee_span: span, span, }, results: result_types.clone(), @@ -3307,6 +3327,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { arguments, expected, span, + span, )?; let symbolic = inferred .call @@ -3330,6 +3351,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async: false, type_arguments: Vec::new(), arguments: inferred.call.arguments, + callee_span: inferred.call.callee_span, span, }, results: instance_method.results().to_vec(), @@ -3344,6 +3366,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async: false, type_arguments: Vec::new(), arguments: inferred.call.arguments, + callee_span: inferred.call.callee_span, span, }, results: instance_method.results().to_vec(), @@ -3416,6 +3439,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async: false, type_arguments: Vec::new(), arguments: Vec::new(), + callee_span: span, span, }, results: vec![result], @@ -3534,6 +3558,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async: false, type_arguments: Vec::new(), arguments: typed_arguments, + callee_span: span, span, }, results: method.results().to_vec(), @@ -3581,6 +3606,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async: false, type_arguments: Vec::new(), arguments: typed_arguments, + callee_span: span, span, }, results: method.results().to_vec(), @@ -3606,6 +3632,7 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async, type_arguments, arguments, + callee_span, span, } = checked.call; let kind = match dispatch { @@ -3618,12 +3645,14 @@ impl<'resolver, 'index> BodyChecker<'resolver, 'index> { is_async, type_arguments, arguments, + callee_span, }, TypedCallDispatch::Referenced { function } => TypedExpressionKind::ReferencedCall { function, is_async, type_arguments, arguments, + callee_span, }, TypedCallDispatch::DirectMethod { method, receiver } => { TypedExpressionKind::DirectMethodCall { diff --git a/crates/compiler/types/src/typed_body.rs b/crates/compiler/types/src/typed_body.rs index fff0416..115df3a 100644 --- a/crates/compiler/types/src/typed_body.rs +++ b/crates/compiler/types/src/typed_body.rs @@ -304,6 +304,7 @@ pub struct TypedCall { pub(crate) is_async: bool, pub(crate) type_arguments: Vec, pub(crate) arguments: Vec, + pub(crate) callee_span: SourceSpan, pub(crate) span: SourceSpan, } @@ -328,6 +329,11 @@ impl TypedCall { &self.arguments } + #[must_use] + pub const fn callee_span(&self) -> SourceSpan { + self.callee_span + } + #[must_use] pub const fn span(&self) -> SourceSpan { self.span @@ -698,12 +704,14 @@ pub enum TypedExpressionKind { is_async: bool, type_arguments: Vec, arguments: Vec, + callee_span: SourceSpan, }, ReferencedCall { function: SymbolIdentity, is_async: bool, type_arguments: Vec, arguments: Vec, + callee_span: SourceSpan, }, StandardCall { function: StandardFunctionId, diff --git a/crates/tools/architecture-tests/src/tests.rs b/crates/tools/architecture-tests/src/tests.rs index 24d01a0..b8d6d28 100644 --- a/crates/tools/architecture-tests/src/tests.rs +++ b/crates/tools/architecture-tests/src/tests.rs @@ -379,9 +379,13 @@ fn private_language_server_uses_compiler_queries_without_cli_scraping() { assert!(implementation.contains("declaration.declaration_span().file() == source.id()")); assert!(implementation.contains("reanalyze_open_documents(")); assert!(implementation.contains("document.scope == *scope")); + assert!(implementation.contains("tooling_definition_occurrences()")); + assert!(implementation.contains("occurrence.identity() == declaration.identity()")); let transport = read_required(root.join("crates/tools/language-server/src/transport.rs")); assert!(transport.contains("\"codeActionProvider\": true")); assert!(transport.contains("\"inlayHintProvider\": true")); + assert!(transport.contains("\"definitionProvider\": true")); + assert!(transport.contains("\"textDocument/definition\"")); assert!(transport.contains("ConnectionAction::Replies")); } diff --git a/crates/tools/language-server/README.md b/crates/tools/language-server/README.md index 84d43dd..f61af42 100644 --- a/crates/tools/language-server/README.md +++ b/crates/tools/language-server/README.md @@ -14,6 +14,8 @@ The implemented bootstrap slice owns: warning metadata, and current safe source fixes; - checked-documentation hover and document symbols; - compiler-proven direct-call parameter inlay hints; +- compiler-resolved definition navigation for namespace-scope functions and + direct calls in the current same-Bubble snapshot; - conservative same-Bubble analysis for dependency-free conventional Packages; - atomic reanalysis and diagnostic republication for affected open Modules, using a retained Package/Bubble scope without republishing same-named target @@ -23,14 +25,14 @@ The implemented bootstrap slice owns: The `pop-language-server` executable exposes that engine through a bounded LSP 3.17 JSON-RPC stdio adapter. It advertises full-text document synchronization, compiler diagnostics, checked-documentation hover, document symbols, code -actions, and inlay hints. The +actions, inlay hints, and definition navigation. The initialization `locale` selects presentation first; when absent, `POP_LANGUAGE`, tool configuration, the system locale, and English follow ADR 0088 precedence. -The current crate does not implement completion, signature help, cross-Bubble -navigation, references, rename, formatting, semantic tokens, incremental text -edits, complete Workspace/dependency analysis, or public syntax values. Those -surfaces depend on reviewed schemas in the independently +The current crate does not implement completion, signature help, local/member +or cross-Bubble navigation, references, rename, formatting, semantic tokens, +incremental text edits, complete Workspace/dependency analysis, or public +syntax values. Those surfaces depend on reviewed schemas in the independently installed `Pop.Rpc`, `Pop.Syntax`, and `Pop.Lsp` Packages. The private compiler syntax tree and query handles must not be exported as a shortcut. diff --git a/crates/tools/language-server/src/lib.rs b/crates/tools/language-server/src/lib.rs index 7ab5465..7e176ed 100644 --- a/crates/tools/language-server/src/lib.rs +++ b/crates/tools/language-server/src/lib.rs @@ -10,7 +10,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use pop_documentation::{XmlFragment, XmlNode}; -use pop_driver::{FrontEndBubbleInput, FrontEndModule, ToolingDeclarationKind, analyze_bubble}; +use pop_driver::{ + FrontEndBubbleInput, FrontEndModule, FrontEndResult, ToolingDeclarationKind, analyze_bubble, +}; use pop_foundation::{ BubbleId, Diagnostic, DiagnosticCategory, DiagnosticSeverity, FileId, FixApplicability, ModuleId, NamespaceId, TextRange, TextSize, @@ -215,6 +217,24 @@ pub struct InlayHint { label: String, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Definition { + uri: DocumentUri, + range: ProtocolRange, +} + +impl Definition { + #[must_use] + pub const fn uri(&self) -> &DocumentUri { + &self.uri + } + + #[must_use] + pub const fn range(&self) -> ProtocolRange { + self.range + } +} + impl ProtocolDiagnostic { #[must_use] pub fn code(&self) -> &str { @@ -436,6 +456,7 @@ struct OpenDocument { version: DocumentVersion, analysis: DocumentAnalysis, declarations: Vec, + definitions: Vec, inlay_hints: Vec, } @@ -465,6 +486,23 @@ struct AnalyzedDeclaration { summary: Option, } +struct AnalyzedDefinitionOccurrence { + selection: TextRange, + target: Definition, +} + +struct AnalysisSnapshotInput { + bubble: FrontEndBubbleInput, + sources: BTreeMap, +} + +struct AnalyzedDocument { + analysis: DocumentAnalysis, + declarations: Vec, + definitions: Vec, + inlay_hints: Vec, +} + pub struct LanguageServer { session: LanguageServerSession, documents: BTreeMap, @@ -544,7 +582,7 @@ impl LanguageServer { detail: error.to_string(), } })?; - let (analysis, declarations, inlay_hints) = analyze_document( + let analyzed = analyze_document( self.session, &self.documents, &source, @@ -562,9 +600,10 @@ impl LanguageServer { source, scope: scope.clone(), version, - analysis: analysis.clone(), - declarations, - inlay_hints, + analysis: analyzed.analysis.clone(), + declarations: analyzed.declarations, + definitions: analyzed.definitions, + inlay_hints: analyzed.inlay_hints, }, ); let updates = match self.reanalyze_open_documents(&scope, cancellation) { @@ -622,7 +661,7 @@ impl LanguageServer { uri: uri.clone(), detail: error.to_string(), })?; - let (analysis, declarations, inlay_hints) = analyze_document( + let analyzed = analyze_document( self.session, &self.documents, &source, @@ -637,9 +676,10 @@ impl LanguageServer { source, scope: scope.clone(), version, - analysis: analysis.clone(), - declarations, - inlay_hints, + analysis: analyzed.analysis.clone(), + declarations: analyzed.declarations, + definitions: analyzed.definitions, + inlay_hints: analyzed.inlay_hints, }, ) .expect("the changed document was open"); @@ -736,6 +776,36 @@ impl LanguageServer { .collect() } + /// Returns the compiler-selected namespace function definition. + /// + /// # Errors + /// + /// Rejects unknown documents, cancellation, or invalid UTF-16 positions. + pub fn definition( + &self, + uri: &DocumentUri, + position: ProtocolPosition, + cancellation: &CancellationToken, + ) -> Result, LanguageServerError> { + cancellation + .check() + .map_err(|_| LanguageServerError::Cancelled)?; + let document = self + .documents + .get(uri) + .ok_or_else(|| LanguageServerError::DocumentNotOpen { uri: uri.clone() })?; + let Some(offset) = source_offset(document.source.text(), position) else { + return Ok(None); + }; + Ok(document + .definitions + .iter() + .find(|definition| { + definition.selection.start() <= offset && offset < definition.selection.end() + }) + .map(|definition| definition.target.clone())) + } + /// Returns compiler-proven direct-call parameter hints in one range. /// /// # Errors @@ -850,15 +920,16 @@ impl LanguageServer { }) .collect::, _>>()?; let mut updates = Vec::with_capacity(analyzed.len()); - for (uri, (analysis, declarations, inlay_hints)) in analyzed { + for (uri, analyzed) in analyzed { let document = self .documents .get_mut(&uri) .expect("analyzed document remains open"); - document.analysis = analysis.clone(); - document.declarations = declarations; - document.inlay_hints = inlay_hints; - updates.push((uri, analysis)); + document.analysis = analyzed.analysis.clone(); + document.declarations = analyzed.declarations; + document.definitions = analyzed.definitions; + document.inlay_hints = analyzed.inlay_hints; + updates.push((uri, analyzed.analysis)); } Ok(updates) } @@ -922,22 +993,24 @@ fn analyze_document( source: &SourceFile, version: DocumentVersion, cancellation: &CancellationToken, -) -> Result<(DocumentAnalysis, Vec, Vec), LanguageServerError> { +) -> Result { cancellation .check() .map_err(|_| LanguageServerError::Cancelled)?; - let input = package_analysis_input(open_documents, source).unwrap_or_else(|| { - FrontEndBubbleInput::new( - BubbleId::from_raw(0), - NamespaceId::from_raw(0), - Vec::new(), - vec![FrontEndModule::new( - ModuleId::from_raw(source.id().raw()), - source.clone(), - )], - ) - }); - let result = analyze_bubble(input); + let input = + package_analysis_input(open_documents, source).unwrap_or_else(|| AnalysisSnapshotInput { + bubble: FrontEndBubbleInput::new( + BubbleId::from_raw(0), + NamespaceId::from_raw(0), + Vec::new(), + vec![FrontEndModule::new( + ModuleId::from_raw(source.id().raw()), + source.clone(), + )], + ), + sources: BTreeMap::from([(source.id(), source.clone())]), + }); + let result = analyze_bubble(input.bubble); cancellation .check() .map_err(|_| LanguageServerError::Cancelled)?; @@ -978,7 +1051,8 @@ fn analyze_document( .and_then(|fragment| documentation_summary(fragment)), } }) - .collect(); + .collect::>(); + let definitions = analyzed_definitions(&result, &input.sources, source.id()); let inlay_hints = result .tooling_inlay_hints() .iter() @@ -992,15 +1066,43 @@ fn analyze_document( }) }) .collect(); - Ok(( - DocumentAnalysis { + Ok(AnalyzedDocument { + analysis: DocumentAnalysis { file: source.id(), version, diagnostics, }, declarations, + definitions, inlay_hints, - )) + }) +} + +fn analyzed_definitions( + result: &FrontEndResult, + sources: &BTreeMap, + active: FileId, +) -> Vec { + result + .tooling_definition_occurrences() + .iter() + .filter(|occurrence| occurrence.selection_span().file() == active) + .filter_map(|occurrence| { + let declaration = result + .tooling_declarations() + .iter() + .find(|declaration| occurrence.identity() == declaration.identity())?; + let destination = sources.get(&declaration.selection_span().file())?; + Some(AnalyzedDefinitionOccurrence { + selection: occurrence.selection_span().range(), + target: Definition { + uri: DocumentUri::new(Arc::::from(destination.path())).ok()?, + range: protocol_range(destination.text(), declaration.selection_span().range()) + .ok()?, + }, + }) + }) + .collect() } fn active_analysis( @@ -1018,24 +1120,33 @@ fn active_analysis( fn package_analysis_input( open_documents: &BTreeMap, active: &SourceFile, -) -> Option { +) -> Option { let selection = package_analysis_selection(active)?; let mut modules = Vec::new(); + let mut sources = BTreeMap::new(); let mut implicit_main = None; for (index, relative) in selection.bubble.modules().iter().enumerate() { let path = selection.root.join(relative); let module = ModuleId::from_raw(u32::try_from(index).ok()?); - let file = if relative == &selection.relative_active { - active.id() + let uri = file_uri(&path)?; + let (file, text) = if relative == &selection.relative_active { + (active.id(), Arc::::from(active.text())) + } else if let Some(document) = open_document(open_documents, &uri) { + ( + document.source.id(), + Arc::::from(document.source.text()), + ) } else { - FileId::from_raw(u32::MAX.checked_sub(u32::try_from(index).ok()?)?) + ( + FileId::from_raw(u32::MAX.checked_sub(u32::try_from(index).ok()?)?), + fs::read_to_string(&path).ok().map(Arc::::from)?, + ) }; - let text = open_document_text(open_documents, &path) - .or_else(|| fs::read_to_string(&path).ok().map(Arc::::from))?; - let source = SourceFile::new(file, Arc::::from(file_uri(&path)?), text).ok()?; + let source = SourceFile::new(file, Arc::::from(uri), text).ok()?; if selection.bubble.kind() == BubbleKind::Binary && relative == selection.bubble.root() { implicit_main = Some(module); } + sources.insert(file, source.clone()); modules.push(FrontEndModule::new(module, source)); } let input = FrontEndBubbleInput::new( @@ -1044,11 +1155,12 @@ fn package_analysis_input( Vec::new(), modules, ); - Some(if let Some(module) = implicit_main { + let bubble = if let Some(module) = implicit_main { input.with_implicit_main_entry(module) } else { input - }) + }; + Some(AnalysisSnapshotInput { bubble, sources }) } fn analysis_scope(source: &SourceFile) -> AnalysisScope { @@ -1180,15 +1292,14 @@ fn relative_pop_path(root: &Path, path: &Path) -> Option { ) } -fn open_document_text( - documents: &BTreeMap, - path: &Path, -) -> Option> { - let uri = file_uri(path)?; +fn open_document<'a>( + documents: &'a BTreeMap, + uri: &str, +) -> Option<&'a OpenDocument> { documents .iter() .find(|(candidate, _)| candidate.as_str() == uri) - .map(|(_, document)| Arc::::from(document.source.text())) + .map(|(_, document)| document) } fn file_uri_path(uri: &str) -> Option { diff --git a/crates/tools/language-server/src/transport.rs b/crates/tools/language-server/src/transport.rs index 8349f46..0206261 100644 --- a/crates/tools/language-server/src/transport.rs +++ b/crates/tools/language-server/src/transport.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use serde_json::{Value, json}; use crate::{ - DocumentAnalysis, DocumentSymbol, DocumentUri, DocumentVersion, Hover, InlayHint, + Definition, DocumentAnalysis, DocumentSymbol, DocumentUri, DocumentVersion, Hover, InlayHint, LanguageServer, LanguageServerError, ProtocolDiagnostic, ProtocolPosition, ProtocolQuickFix, ProtocolRange, }; @@ -164,6 +164,9 @@ impl Connection { self.close(params) } "textDocument/hover" if self.lifecycle == Lifecycle::Running => self.hover(id, params), + "textDocument/definition" if self.lifecycle == Lifecycle::Running => { + self.definition(id, params) + } "textDocument/documentSymbol" if self.lifecycle == Lifecycle::Running => { self.document_symbols(id, params) } @@ -226,6 +229,7 @@ impl Connection { "positionEncoding": "utf-16", "textDocumentSync": 1, "hoverProvider": true, + "definitionProvider": true, "documentSymbolProvider": true, "codeActionProvider": true, "inlayHintProvider": true @@ -391,6 +395,47 @@ impl Connection { } } + fn definition( + &self, + id: Option, + params: Value, + ) -> Result { + let Some(id) = id else { + return Ok(ConnectionAction::None); + }; + let params: TextDocumentPositionParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return Ok(ConnectionAction::Reply(error_response( + &id, + -32602, + &format!("Invalid params: {error}"), + ))); + } + }; + let uri = match DocumentUri::new(params.text_document.uri) { + Ok(uri) => uri, + Err(error) => { + return Ok(ConnectionAction::Reply(error_response( + &id, + -32602, + &error.to_string(), + ))); + } + }; + let position = ProtocolPosition::new(params.position.line, params.position.character); + let server = self.server.as_ref().expect("running server"); + match server.definition(&uri, position, &CancellationToken::new()) { + Ok(definition) => Ok(ConnectionAction::Reply(success_response( + &id, + &definition.map_or(Value::Null, |definition| protocol_definition(&definition)), + ))), + Err(error) => Ok(ConnectionAction::Reply(language_server_error( + server, &id, &error, + )?)), + } + } + fn code_actions( &self, id: Option, @@ -812,6 +857,13 @@ fn protocol_hover(hover: &Hover) -> Value { }) } +fn protocol_definition(definition: &Definition) -> Value { + json!({ + "uri": definition.uri().as_str(), + "range": protocol_range(definition.range()) + }) +} + fn protocol_document_symbol(symbol: &DocumentSymbol) -> Value { json!({ "name": symbol.name(), diff --git a/crates/tools/language-server/tests/documents.rs b/crates/tools/language-server/tests/documents.rs index 7f902b7..6886f42 100644 --- a/crates/tools/language-server/tests/documents.rs +++ b/crates/tools/language-server/tests/documents.rs @@ -129,6 +129,174 @@ fn document_symbols_are_compiler_indexed_and_utf16_positioned() { assert_eq!(symbols[1].kind(), "function"); } +#[test] +fn definition_uses_the_compiler_selected_function_identity() { + let mut server = LanguageServer::initialize(Some("en")).expect("server"); + let uri = DocumentUri::new("file:///workspace/definition.pop").expect("URI"); + server + .open( + uri.clone(), + DocumentVersion::new(1), + "namespace Example\nfunction one(): Int\n return 1\nend\nfunction value(): Int\n return one()\nend\n", + &CancellationToken::new(), + ) + .expect("open definition source"); + + let definition = server + .definition( + &uri, + ProtocolPosition::new(5, 12), + &CancellationToken::new(), + ) + .expect("definition query") + .expect("resolved definition"); + assert_eq!(definition.uri(), &uri); + assert_eq!(definition.range().start(), ProtocolPosition::new(1, 9)); + assert_eq!(definition.range().end(), ProtocolPosition::new(1, 12)); + + assert!( + server + .definition(&uri, ProtocolPosition::new(0, 0), &CancellationToken::new(),) + .expect("empty definition") + .is_none() + ); +} + +#[test] +fn definition_crosses_modules_only_inside_the_selected_bubble() { + let root = std::env::temp_dir().join(format!("PopLspDefinition{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write( + root.join("bubble.toml"), + "[package]\nname = \"Studio.Navigation\"\nversion = \"0.1.0\"\nedition = \"2026\"\n", + ) + .unwrap(); + let active = "namespace Studio.Navigation\nfunction value(): Int\n return helper()\nend\n"; + let helper = "namespace Studio.Navigation\nfunction helper(): Int\n return 42\nend\n"; + std::fs::write(root.join("src/lib.pop"), active).unwrap(); + std::fs::write(root.join("src/helper.pop"), helper).unwrap(); + let active_uri = + DocumentUri::new(format!("file://{}", root.join("src/lib.pop").display())).unwrap(); + let helper_uri = + DocumentUri::new(format!("file://{}", root.join("src/helper.pop").display())).unwrap(); + let mut server = LanguageServer::initialize(Some("en")).unwrap(); + server + .open( + active_uri.clone(), + DocumentVersion::new(1), + active, + &CancellationToken::new(), + ) + .unwrap(); + + let definition = server + .definition( + &active_uri, + ProtocolPosition::new(2, 12), + &CancellationToken::new(), + ) + .expect("definition query") + .expect("sibling definition"); + assert_eq!(definition.uri(), &helper_uri); + assert_eq!(definition.range().start(), ProtocolPosition::new(1, 9)); + assert_eq!(definition.range().end(), ProtocolPosition::new(1, 15)); + + assert!(server.close(&active_uri)); + assert!(matches!( + server.definition( + &active_uri, + ProtocolPosition::new(2, 12), + &CancellationToken::new(), + ), + Err(LanguageServerError::DocumentNotOpen { .. }) + )); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn definition_does_not_fabricate_dependency_or_sibling_bubble_identity() { + let root = std::env::temp_dir().join(format!("PopLspDependency{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write( + root.join("bubble.toml"), + "[package]\nname = \"Studio.Consumer\"\nversion = \"0.1.0\"\nedition = \"2026\"\n\n[dependencies]\nShared = \"1.0\"\n", + ) + .unwrap(); + let active = "namespace Studio.Consumer\nfunction value(): Int\n return helper()\nend\n"; + std::fs::write(root.join("src/lib.pop"), active).unwrap(); + std::fs::write( + root.join("src/helper.pop"), + "namespace Studio.Consumer\nfunction helper(): Int\n return 42\nend\n", + ) + .unwrap(); + let uri = DocumentUri::new(format!("file://{}", root.join("src/lib.pop").display())).unwrap(); + let mut server = LanguageServer::initialize(Some("en")).unwrap(); + let analysis = server + .open( + uri.clone(), + DocumentVersion::new(1), + active, + &CancellationToken::new(), + ) + .unwrap(); + assert!( + analysis + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code() == "POP1002"), + "dependency-bearing Packages must remain standalone until the resolver snapshot exists" + ); + assert!( + server + .definition( + &uri, + ProtocolPosition::new(2, 12), + &CancellationToken::new(), + ) + .expect("definition query") + .is_none() + ); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn definition_positions_count_utf16_units_without_widening_the_occurrence() { + let mut server = LanguageServer::initialize(Some("en")).expect("server"); + let uri = DocumentUri::new("file:///workspace/unicode-definition.pop").expect("URI"); + server + .open( + uri.clone(), + DocumentVersion::new(1), + "namespace Example\nfunction one(): Int\n return 1\nend\nfunction choose(label: String, value: Int): Int\n return value\nend\nfunction value(): Int\n return choose(\"😀\", one())\nend\n", + &CancellationToken::new(), + ) + .expect("open Unicode definition source"); + + let definition = server + .definition( + &uri, + ProtocolPosition::new(8, 25), + &CancellationToken::new(), + ) + .expect("definition query") + .expect("nested definition after non-BMP text"); + assert_eq!(definition.range().start(), ProtocolPosition::new(1, 9)); + + assert!( + server + .definition( + &uri, + ProtocolPosition::new(8, 20), + &CancellationToken::new(), + ) + .expect("argument position") + .is_none(), + "the string argument must not be treated as part of the callee occurrence" + ); +} + #[test] fn malformed_documentation_is_diagnosed_and_never_enters_hover() { let mut server = LanguageServer::initialize(Some("en")).expect("server"); diff --git a/crates/tools/language-server/tests/transport.rs b/crates/tools/language-server/tests/transport.rs index ce30d06..8f87bb8 100644 --- a/crates/tools/language-server/tests/transport.rs +++ b/crates/tools/language-server/tests/transport.rs @@ -92,6 +92,10 @@ fn stdio_session_negotiates_utf16_and_publishes_localized_diagnostics() { messages[0]["result"]["capabilities"]["inlayHintProvider"], true ); + assert_eq!( + messages[0]["result"]["capabilities"]["definitionProvider"], + true + ); let publication = messages .iter() .find(|message| message["method"] == "textDocument/publishDiagnostics") @@ -256,6 +260,36 @@ fn stdio_hover_and_document_symbols_use_compiler_results() { assert_eq!(symbols["result"][0]["kind"], 12); } +#[test] +fn stdio_definition_returns_the_compiler_selected_location() { + let uri = "file:///workspace/navigation.pop"; + let input = session(&[ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"locale":"en","capabilities":{}}}), + json!({"jsonrpc":"2.0","method":"initialized","params":{}}), + json!({"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":uri,"languageId":"pop","version":1,"text":"namespace Example\nfunction one(): Int\n return 1\nend\nfunction value(): Int\n return one()\nend\n"}}}), + json!({"jsonrpc":"2.0","id":3,"method":"textDocument/definition","params":{"textDocument":{"uri":uri},"position":{"line":5,"character":12}}}), + json!({"jsonrpc":"2.0","id":4,"method":"textDocument/definition","params":{"textDocument":{"uri":uri},"position":{"line":0,"character":0}}}), + json!({"jsonrpc":"2.0","id":2,"method":"shutdown","params":null}), + json!({"jsonrpc":"2.0","method":"exit","params":null}), + ]); + let mut output = Vec::new(); + serve( + BufReader::new(Cursor::new(input)), + &mut output, + TransportLimits::default(), + ) + .expect("serve session"); + let messages = responses(&output); + let location = &messages.iter().find(|message| message["id"] == 3).unwrap()["result"]; + assert_eq!(location["uri"], uri); + assert_eq!(location["range"]["start"]["line"], 1); + assert_eq!(location["range"]["start"]["character"], 9); + assert_eq!( + messages.iter().find(|message| message["id"] == 4).unwrap()["result"], + Value::Null + ); +} + #[test] fn change_republishes_and_close_clears_diagnostics() { let uri = "file:///workspace/main.pop";