From 4967f29691324b90e8bf50faad9f3388beb3560a Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 08:52:02 +0200 Subject: [PATCH 01/10] Support Self:: and module-qualified doc-links Resolve doc-link paths as parsed segments rather than raw strings: `Self::` substitutes the enclosing type's path (threaded through a new walk_module_docs traversal shared by validate and collect), and module-qualified paths to freestanding functions and extern values resolve instead of being gated out by a bare-name check. The shared walk also newly validates nested-item docs and visits nested enums' associated functions. Closes #114 Closes #115 --- codegen_tests/input/doc_self_links.pyxis | 20 + .../input/qualified_links_consumer.pyxis | 7 + .../input/qualified_links_provider.pyxis | 7 + .../output/cpp/include/doc_self_links.hpp | 30 + .../cpp/include/qualified_links_consumer.hpp | 20 + .../cpp/include/qualified_links_provider.hpp | 15 + codegen_tests/output/cpp/src/_all_headers.cpp | 3 + .../output/cpp/src/doc_self_links.cpp | 10 + .../cpp/src/qualified_links_provider.cpp | 12 + codegen_tests/output/json/output.json | 863 +++++++++++------- codegen_tests/output/rust/doc_self_links.rs | 63 ++ codegen_tests/output/rust/lib.rs | 3 + .../output/rust/qualified_links_consumer.rs | 26 + .../output/rust/qualified_links_provider.rs | 12 + src/backends/json.rs | 4 +- src/semantic/doc_links.rs | 348 ++++--- src/semantic/tests/doc_links.rs | 334 ++++++- tooling/lsp/src/handlers/doc_links.rs | 54 +- tooling/lsp/src/handlers/mod.rs | 2 +- 19 files changed, 1331 insertions(+), 502 deletions(-) create mode 100644 codegen_tests/input/doc_self_links.pyxis create mode 100644 codegen_tests/input/qualified_links_consumer.pyxis create mode 100644 codegen_tests/input/qualified_links_provider.pyxis create mode 100644 codegen_tests/output/cpp/include/doc_self_links.hpp create mode 100644 codegen_tests/output/cpp/include/qualified_links_consumer.hpp create mode 100644 codegen_tests/output/cpp/include/qualified_links_provider.hpp create mode 100644 codegen_tests/output/cpp/src/doc_self_links.cpp create mode 100644 codegen_tests/output/cpp/src/qualified_links_provider.cpp create mode 100644 codegen_tests/output/rust/doc_self_links.rs create mode 100644 codegen_tests/output/rust/qualified_links_consumer.rs create mode 100644 codegen_tests/output/rust/qualified_links_provider.rs diff --git a/codegen_tests/input/doc_self_links.pyxis b/codegen_tests/input/doc_self_links.pyxis new file mode 100644 index 00000000..67b1685c --- /dev/null +++ b/codegen_tests/input/doc_self_links.pyxis @@ -0,0 +1,20 @@ +/// Test `Self::` links in type docs. +/// +/// Link to a field as [`Self::field`](Self::field), to a method as +/// [`Self::method`](Self::method), to a nested type as +/// [`Self::Nested`](Self::Nested), and to a nested type's member as +/// [`Self::Nested::nested_field`](Self::Nested::nested_field). +pub type Container { + /// A nested type. Its field is [`Self::nested_field`](Self::nested_field). + pub type Nested { + pub nested_field: u16, + }, + + /// A field. + pub field: u32, +} +impl Container { + /// A method. + #[address(0x10)] + pub fn method(&self); +} diff --git a/codegen_tests/input/qualified_links_consumer.pyxis b/codegen_tests/input/qualified_links_consumer.pyxis new file mode 100644 index 00000000..6276142c --- /dev/null +++ b/codegen_tests/input/qualified_links_consumer.pyxis @@ -0,0 +1,7 @@ +/// Test module-qualified doc-links to functions and extern values. +/// +/// See [`shared_function`](qualified_links_provider::shared_function) and +/// [`shared_extern`](qualified_links_provider::shared_extern). +pub type Consumer { + pub _marker: u8, +} diff --git a/codegen_tests/input/qualified_links_provider.pyxis b/codegen_tests/input/qualified_links_provider.pyxis new file mode 100644 index 00000000..c4d8ab02 --- /dev/null +++ b/codegen_tests/input/qualified_links_provider.pyxis @@ -0,0 +1,7 @@ +/// A module with a freestanding function. +#[address(0x100)] +pub fn shared_function(); + +/// An extern value. +#[address(0x200)] +pub extern shared_extern: *mut u32; diff --git a/codegen_tests/output/cpp/include/doc_self_links.hpp b/codegen_tests/output/cpp/include/doc_self_links.hpp new file mode 100644 index 00000000..3e037c24 --- /dev/null +++ b/codegen_tests/output/cpp/include/doc_self_links.hpp @@ -0,0 +1,30 @@ +// @generated by pyxis — do not edit +#pragma once + +#include +#include +#include "pyxis_runtime.hpp" + +namespace doc_self_links { + struct Container; + + /// Test `Self::` links in type docs. + /// + /// Link to a field as [`Self::field`](Self::field), to a method as + /// [`Self::method`](Self::method), to a nested type as + /// [`Self::Nested`](Self::Nested), and to a nested type's member as + /// [`Self::Nested::nested_field`](Self::Nested::nested_field). + struct alignas(4) Container { + /// A field. + ::std::uint32_t field; + /// A method. + void method() const; + + /// A nested type. Its field is [`Self::nested_field`](Self::nested_field). + struct Nested { + ::std::uint16_t nested_field; + }; + }; + static_assert(sizeof(Container) == 0x4); + static_assert(alignof(Container) == 4); +} // namespace doc_self_links diff --git a/codegen_tests/output/cpp/include/qualified_links_consumer.hpp b/codegen_tests/output/cpp/include/qualified_links_consumer.hpp new file mode 100644 index 00000000..3718f98d --- /dev/null +++ b/codegen_tests/output/cpp/include/qualified_links_consumer.hpp @@ -0,0 +1,20 @@ +// @generated by pyxis — do not edit +#pragma once + +#include +#include +#include "pyxis_runtime.hpp" + +namespace qualified_links_consumer { + struct Consumer; + + /// Test module-qualified doc-links to functions and extern values. + /// + /// See [`shared_function`](qualified_links_provider::shared_function) and + /// [`shared_extern`](qualified_links_provider::shared_extern). + struct alignas(1) Consumer { + ::std::uint8_t _marker; + }; + static_assert(sizeof(Consumer) == 0x1); + static_assert(alignof(Consumer) == 1); +} // namespace qualified_links_consumer diff --git a/codegen_tests/output/cpp/include/qualified_links_provider.hpp b/codegen_tests/output/cpp/include/qualified_links_provider.hpp new file mode 100644 index 00000000..a73b63c9 --- /dev/null +++ b/codegen_tests/output/cpp/include/qualified_links_provider.hpp @@ -0,0 +1,15 @@ +// @generated by pyxis — do not edit +#pragma once + +#include +#include +#include "pyxis_runtime.hpp" + +namespace qualified_links_provider { + /// An extern value. + ::std::uint32_t*& get_shared_extern(); + + /// A module with a freestanding function. + using shared_function_t = void (*)(); + extern const shared_function_t shared_function; +} // namespace qualified_links_provider diff --git a/codegen_tests/output/cpp/src/_all_headers.cpp b/codegen_tests/output/cpp/src/_all_headers.cpp index d7e9e205..6201e33b 100644 --- a/codegen_tests/output/cpp/src/_all_headers.cpp +++ b/codegen_tests/output/cpp/src/_all_headers.cpp @@ -9,6 +9,7 @@ #include "definition_body.hpp" #include "diamond_inheritance.hpp" #include "doc_comments.hpp" +#include "doc_self_links.hpp" #include "enums.hpp" #include "extern_bindings.hpp" #include "extern_values.hpp" @@ -23,6 +24,8 @@ #include "nested_items.hpp" #include "pinned.hpp" #include "pyxis_runtime.hpp" +#include "qualified_links_consumer.hpp" +#include "qualified_links_provider.hpp" #include "reexport.hpp" #include "reexport_consumer.hpp" #include "self_referential_generics.hpp" diff --git a/codegen_tests/output/cpp/src/doc_self_links.cpp b/codegen_tests/output/cpp/src/doc_self_links.cpp new file mode 100644 index 00000000..d5b9dd0b --- /dev/null +++ b/codegen_tests/output/cpp/src/doc_self_links.cpp @@ -0,0 +1,10 @@ +// @generated by pyxis — do not edit + +#include "doc_self_links.hpp" + +namespace doc_self_links { + void Container::method() const { + using fn_t = void (*)(const void*); + reinterpret_cast(0x10)(this); + } +} // namespace doc_self_links diff --git a/codegen_tests/output/cpp/src/qualified_links_provider.cpp b/codegen_tests/output/cpp/src/qualified_links_provider.cpp new file mode 100644 index 00000000..1b3a18b8 --- /dev/null +++ b/codegen_tests/output/cpp/src/qualified_links_provider.cpp @@ -0,0 +1,12 @@ +// @generated by pyxis — do not edit + +#include "qualified_links_provider.hpp" + +namespace qualified_links_provider { + using shared_function_t = void (*)(); + const shared_function_t shared_function = reinterpret_cast(0x100); + + ::std::uint32_t*& get_shared_extern() { + return *reinterpret_cast<::std::uint32_t**>(0x200); + } +} // namespace qualified_links_provider diff --git a/codegen_tests/output/json/output.json b/codegen_tests/output/json/output.json index 0c91bcc0..1007d852 100644 --- a/codegen_tests/output/json/output.json +++ b/codegen_tests/output/json/output.json @@ -3095,6 +3095,114 @@ "line": 6 } }, + "doc_self_links::Container": { + "path": "doc_self_links::Container", + "visibility": "public", + "size": 4, + "alignment": 4, + "category": "defined", + "kind": { + "type": "type", + "doc": " Test `Self::` links in type docs.\n\n Link to a field as [`Self::field`](Self::field), to a method as\n [`Self::method`](Self::method), to a nested type as\n [`Self::Nested`](Self::Nested), and to a nested type's member as\n [`Self::Nested::nested_field`](Self::Nested::nested_field).", + "fields": [ + { + "visibility": "public", + "name": "field", + "doc": " A field.", + "type_ref": { + "type": "raw", + "path": "u32" + }, + "offset": 0, + "size": 4, + "alignment": 4, + "is_base": false, + "source": { + "file_index": 12, + "line": 14 + } + } + ], + "associated_functions": [ + { + "visibility": "public", + "name": "method", + "doc": " A method.", + "body": { + "type": "address", + "address": 16 + }, + "arguments": [ + { + "type": "const_self" + } + ], + "return_type": null, + "calling_convention": "system", + "source": { + "file_index": 12, + "line": 19 + } + } + ], + "vftable": null, + "singleton": null, + "copyable": false, + "cloneable": false, + "defaultable": false, + "packed": false, + "pinned": false, + "nested_items": [ + "doc_self_links::Container::Nested" + ] + }, + "source": { + "file_index": 12, + "line": 7 + } + }, + "doc_self_links::Container::Nested": { + "path": "doc_self_links::Container::Nested", + "visibility": "public", + "size": 2, + "alignment": 2, + "category": "defined", + "kind": { + "type": "type", + "doc": " A nested type. Its field is [`Self::nested_field`](Self::nested_field).", + "fields": [ + { + "visibility": "public", + "name": "nested_field", + "doc": null, + "type_ref": { + "type": "raw", + "path": "u16" + }, + "offset": 0, + "size": 2, + "alignment": 2, + "is_base": false, + "source": { + "file_index": 12, + "line": 10 + } + } + ], + "associated_functions": [], + "vftable": null, + "singleton": null, + "copyable": false, + "cloneable": false, + "defaultable": false, + "packed": false, + "pinned": false + }, + "source": { + "file_index": 12, + "line": 9 + } + }, "enums::TestEnum": { "path": "enums::TestEnum", "visibility": "public", @@ -3114,7 +3222,7 @@ "value": 0, "doc": null, "source": { - "file_index": 12, + "file_index": 13, "line": 4 } }, @@ -3123,7 +3231,7 @@ "value": 1, "doc": null, "source": { - "file_index": 12, + "file_index": 13, "line": 5 } }, @@ -3132,7 +3240,7 @@ "value": 2, "doc": null, "source": { - "file_index": 12, + "file_index": 13, "line": 6 } } @@ -3150,7 +3258,7 @@ "return_type": null, "calling_convention": "system", "source": { - "file_index": 12, + "file_index": 13, "line": 10 } }, @@ -3173,7 +3281,7 @@ }, "calling_convention": "system", "source": { - "file_index": 12, + "file_index": 13, "line": 13 } } @@ -3185,7 +3293,7 @@ "pinned": false }, "source": { - "file_index": 12, + "file_index": 13, "line": 2 } }, @@ -3211,7 +3319,7 @@ "pinned": false }, "source": { - "file_index": 13, + "file_index": 14, "line": 7 } }, @@ -3238,7 +3346,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 13, + "file_index": 14, "line": 10 } } @@ -3253,7 +3361,7 @@ "pinned": false }, "source": { - "file_index": 13, + "file_index": 14, "line": 9 } }, @@ -3283,7 +3391,7 @@ "value": 1, "doc": null, "source": { - "file_index": 14, + "file_index": 15, "line": 33 } }, @@ -3292,7 +3400,7 @@ "value": 2, "doc": null, "source": { - "file_index": 14, + "file_index": 15, "line": 34 } } @@ -3307,7 +3415,7 @@ ] }, "source": { - "file_index": 14, + "file_index": 15, "line": 29 } }, @@ -3330,7 +3438,7 @@ "address": 20480 }, "source": { - "file_index": 14, + "file_index": 15, "line": 31 } }, @@ -3364,7 +3472,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 14, + "file_index": 15, "line": 16 } } @@ -3382,7 +3490,7 @@ ] }, "source": { - "file_index": 14, + "file_index": 15, "line": 8 } }, @@ -3405,7 +3513,7 @@ "address": 12288 }, "source": { - "file_index": 14, + "file_index": 15, "line": 13 } }, @@ -3435,7 +3543,7 @@ "value": 0, "doc": null, "source": { - "file_index": 14, + "file_index": 15, "line": 24 } }, @@ -3444,7 +3552,7 @@ "value": 1, "doc": null, "source": { - "file_index": 14, + "file_index": 15, "line": 25 } } @@ -3460,7 +3568,7 @@ ] }, "source": { - "file_index": 14, + "file_index": 15, "line": 20 } }, @@ -3483,7 +3591,7 @@ "address": 16384 }, "source": { - "file_index": 14, + "file_index": 15, "line": 22 } }, @@ -3518,7 +3626,7 @@ "address": 8192 }, "source": { - "file_index": 14, + "file_index": 15, "line": 6 } }, @@ -3538,7 +3646,7 @@ "address": 4096 }, "source": { - "file_index": 14, + "file_index": 15, "line": 3 } }, @@ -3565,7 +3673,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 15, + "file_index": 16, "line": 6 } } @@ -3585,7 +3693,7 @@ }, "calling_convention": "system", "source": { - "file_index": 15, + "file_index": 16, "line": 10 } }, @@ -3607,7 +3715,7 @@ }, "calling_convention": "system", "source": { - "file_index": 15, + "file_index": 16, "line": 12 } }, @@ -3629,7 +3737,7 @@ }, "calling_convention": "system", "source": { - "file_index": 15, + "file_index": 16, "line": 14 } } @@ -3643,7 +3751,7 @@ "pinned": false }, "source": { - "file_index": 15, + "file_index": 16, "line": 5 } }, @@ -3676,7 +3784,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 15, + "file_index": 16, "line": 48 } }, @@ -3696,7 +3804,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 15, + "file_index": 16, "line": 49 } } @@ -3723,7 +3831,7 @@ }, "calling_convention": "system", "source": { - "file_index": 15, + "file_index": 16, "line": 53 } }, @@ -3751,7 +3859,7 @@ "Y" ], "source": { - "file_index": 15, + "file_index": 16, "line": 60 } } @@ -3765,7 +3873,7 @@ "pinned": false }, "source": { - "file_index": 15, + "file_index": 16, "line": 47 } }, @@ -3834,7 +3942,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 16, + "file_index": 17, "line": 20 } } @@ -3867,7 +3975,7 @@ "return_type": null, "calling_convention": "system", "source": { - "file_index": 16, + "file_index": 17, "line": 24 } } @@ -3881,7 +3989,7 @@ "pinned": false }, "source": { - "file_index": 16, + "file_index": 17, "line": 19 } }, @@ -3908,7 +4016,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 16, + "file_index": 17, "line": 15 } }, @@ -3925,7 +4033,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 16, + "file_index": 17, "line": 16 } } @@ -3940,7 +4048,7 @@ "pinned": false }, "source": { - "file_index": 16, + "file_index": 17, "line": 14 } }, @@ -3963,7 +4071,7 @@ "value": 0, "doc": null, "source": { - "file_index": 17, + "file_index": 18, "line": 2 } } @@ -3981,7 +4089,7 @@ "return_type": null, "calling_convention": "system", "source": { - "file_index": 17, + "file_index": 18, "line": 6 } } @@ -3993,7 +4101,7 @@ "pinned": false }, "source": { - "file_index": 17, + "file_index": 18, "line": 1 } }, @@ -4026,7 +4134,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 55 } }, @@ -4043,7 +4151,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 56 } }, @@ -4064,7 +4172,7 @@ "alignment": 1, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 50 } } @@ -4079,7 +4187,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 54 } }, @@ -4112,7 +4220,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 63 } } @@ -4127,7 +4235,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 62 } }, @@ -4157,7 +4265,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 72 } } @@ -4195,7 +4303,7 @@ "return_type": null, "calling_convention": "system", "source": { - "file_index": 18, + "file_index": 19, "line": 72 } } @@ -4209,7 +4317,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 70 } }, @@ -4262,7 +4370,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 72 } } @@ -4277,7 +4385,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 72 } }, @@ -4310,7 +4418,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 27 } }, @@ -4333,7 +4441,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 28 } }, @@ -4360,7 +4468,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 29 } } @@ -4375,7 +4483,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 26 } }, @@ -4412,7 +4520,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 35 } } @@ -4427,7 +4535,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 34 } }, @@ -4463,7 +4571,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 41 } } @@ -4478,7 +4586,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 40 } }, @@ -4512,7 +4620,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 20 } }, @@ -4532,7 +4640,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 21 } } @@ -4547,7 +4655,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 19 } }, @@ -4574,7 +4682,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 47 } } @@ -4589,7 +4697,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 46 } }, @@ -4622,7 +4730,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 7 } } @@ -4637,7 +4745,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 6 } }, @@ -4670,7 +4778,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 18, + "file_index": 19, "line": 14 } } @@ -4685,7 +4793,7 @@ "pinned": false }, "source": { - "file_index": 18, + "file_index": 19, "line": 13 } }, @@ -4712,7 +4820,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 19, + "file_index": 20, "line": 8 } }, @@ -4729,7 +4837,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 19, + "file_index": 20, "line": 9 } } @@ -4744,7 +4852,7 @@ "pinned": false }, "source": { - "file_index": 19, + "file_index": 20, "line": 7 } }, @@ -4781,7 +4889,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 19, + "file_index": 20, "line": 27 } } @@ -4796,7 +4904,7 @@ "pinned": false }, "source": { - "file_index": 19, + "file_index": 20, "line": 26 } }, @@ -4835,7 +4943,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 19, + "file_index": 20, "line": 33 } } @@ -4850,7 +4958,7 @@ "pinned": false }, "source": { - "file_index": 19, + "file_index": 20, "line": 32 } }, @@ -4883,7 +4991,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 19, + "file_index": 20, "line": 15 } } @@ -4898,7 +5006,7 @@ "pinned": false }, "source": { - "file_index": 19, + "file_index": 20, "line": 14 } }, @@ -4931,7 +5039,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 19, + "file_index": 20, "line": 21 } } @@ -4946,7 +5054,7 @@ "pinned": false }, "source": { - "file_index": 19, + "file_index": 20, "line": 20 } }, @@ -5078,7 +5186,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 20, + "file_index": 21, "line": 24 } }, @@ -5099,7 +5207,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 20, + "file_index": 21, "line": 25 } } @@ -5114,7 +5222,7 @@ "pinned": false }, "source": { - "file_index": 20, + "file_index": 21, "line": 23 } }, @@ -5145,7 +5253,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 20, + "file_index": 21, "line": 4 } } @@ -5160,7 +5268,7 @@ "pinned": false }, "source": { - "file_index": 20, + "file_index": 21, "line": 3 } }, @@ -5187,7 +5295,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 20, + "file_index": 21, "line": 16 } }, @@ -5204,7 +5312,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 20, + "file_index": 21, "line": 17 } }, @@ -5221,7 +5329,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 20, + "file_index": 21, "line": 18 } } @@ -5236,7 +5344,7 @@ "pinned": false }, "source": { - "file_index": 20, + "file_index": 21, "line": 15 } }, @@ -5275,7 +5383,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 20, + "file_index": 21, "line": 10 } } @@ -5290,7 +5398,7 @@ "pinned": false }, "source": { - "file_index": 20, + "file_index": 21, "line": 9 } }, @@ -5317,7 +5425,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 21, + "file_index": 22, "line": 11 } }, @@ -5338,7 +5446,7 @@ "alignment": 1, "is_base": false, "source": { - "file_index": 21, + "file_index": 22, "line": 8 } } @@ -5353,7 +5461,7 @@ "pinned": false }, "source": { - "file_index": 21, + "file_index": 22, "line": 10 } }, @@ -5380,7 +5488,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 21, + "file_index": 22, "line": 4 } }, @@ -5397,7 +5505,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 21, + "file_index": 22, "line": 5 } }, @@ -5418,7 +5526,7 @@ "alignment": 1, "is_base": false, "source": { - "file_index": 21, + "file_index": 22, "line": 1 } } @@ -5433,7 +5541,7 @@ "pinned": false }, "source": { - "file_index": 21, + "file_index": 22, "line": 3 } }, @@ -5463,7 +5571,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 4 } }, @@ -5480,7 +5588,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 8 } } @@ -5513,7 +5621,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 12 } } @@ -5547,7 +5655,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 4 } } @@ -5561,7 +5669,7 @@ "pinned": false }, "source": { - "file_index": 22, + "file_index": 23, "line": 2 } }, @@ -5611,7 +5719,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 4 } } @@ -5626,7 +5734,7 @@ "pinned": false }, "source": { - "file_index": 22, + "file_index": 23, "line": 4 } }, @@ -5653,7 +5761,7 @@ "alignment": 8, "is_base": true, "source": { - "file_index": 22, + "file_index": 23, "line": 23 } }, @@ -5670,7 +5778,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 24 } } @@ -5704,7 +5812,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 12 } }, @@ -5735,7 +5843,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 28 } } @@ -5769,7 +5877,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 18 } }, @@ -5800,7 +5908,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 19 } } @@ -5814,7 +5922,7 @@ "pinned": false }, "source": { - "file_index": 22, + "file_index": 23, "line": 16 } }, @@ -5841,7 +5949,7 @@ "alignment": 8, "is_base": true, "source": { - "file_index": 22, + "file_index": 23, "line": 40 } }, @@ -5858,7 +5966,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 41 } } @@ -5892,7 +6000,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 12 } }, @@ -5924,7 +6032,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 28 } }, @@ -5955,7 +6063,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 45 } } @@ -5989,7 +6097,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 34 } }, @@ -6020,7 +6128,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 35 } }, @@ -6051,7 +6159,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 36 } } @@ -6065,7 +6173,7 @@ "pinned": false }, "source": { - "file_index": 22, + "file_index": 23, "line": 32 } }, @@ -6092,7 +6200,7 @@ "alignment": 8, "is_base": true, "source": { - "file_index": 22, + "file_index": 23, "line": 58 } }, @@ -6109,7 +6217,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 59 } } @@ -6143,7 +6251,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 12 } }, @@ -6175,7 +6283,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 28 } }, @@ -6207,7 +6315,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 45 } }, @@ -6238,7 +6346,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 63 } } @@ -6272,7 +6380,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 51 } }, @@ -6303,7 +6411,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 52 } }, @@ -6334,7 +6442,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 53 } }, @@ -6365,7 +6473,7 @@ }, "calling_convention": "system", "source": { - "file_index": 22, + "file_index": 23, "line": 54 } } @@ -6379,7 +6487,7 @@ "pinned": false }, "source": { - "file_index": 22, + "file_index": 23, "line": 49 } }, @@ -6429,7 +6537,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 51 } }, @@ -6469,7 +6577,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 52 } }, @@ -6509,7 +6617,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 53 } }, @@ -6549,7 +6657,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 54 } } @@ -6564,7 +6672,7 @@ "pinned": false }, "source": { - "file_index": 22, + "file_index": 23, "line": 51 } }, @@ -6614,7 +6722,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 34 } }, @@ -6654,7 +6762,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 35 } }, @@ -6694,7 +6802,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 36 } } @@ -6709,7 +6817,7 @@ "pinned": false }, "source": { - "file_index": 22, + "file_index": 23, "line": 34 } }, @@ -6759,7 +6867,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 18 } }, @@ -6799,7 +6907,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 22, + "file_index": 23, "line": 19 } } @@ -6814,7 +6922,7 @@ "pinned": false }, "source": { - "file_index": 22, + "file_index": 23, "line": 18 } }, @@ -6873,7 +6981,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 23, + "file_index": 24, "line": 31 } } @@ -6894,7 +7002,7 @@ ] }, "source": { - "file_index": 23, + "file_index": 24, "line": 6 } }, @@ -6920,7 +7028,7 @@ } }, "source": { - "file_index": 23, + "file_index": 24, "line": 29 } }, @@ -6968,7 +7076,7 @@ "value": 0, "doc": null, "source": { - "file_index": 23, + "file_index": 24, "line": 11 } }, @@ -6977,7 +7085,7 @@ "value": 1, "doc": null, "source": { - "file_index": 23, + "file_index": 24, "line": 12 } }, @@ -6986,7 +7094,7 @@ "value": 2, "doc": null, "source": { - "file_index": 23, + "file_index": 24, "line": 13 } } @@ -6999,7 +7107,7 @@ "pinned": false }, "source": { - "file_index": 23, + "file_index": 24, "line": 10 } }, @@ -7041,7 +7149,7 @@ "value": 1, "doc": null, "source": { - "file_index": 23, + "file_index": 24, "line": 25 } }, @@ -7050,7 +7158,7 @@ "value": 2, "doc": null, "source": { - "file_index": 23, + "file_index": 24, "line": 26 } } @@ -7062,7 +7170,7 @@ "pinned": false }, "source": { - "file_index": 23, + "file_index": 24, "line": 24 } }, @@ -7102,7 +7210,7 @@ "alignment": 2, "is_base": false, "source": { - "file_index": 23, + "file_index": 24, "line": 19 } } @@ -7117,7 +7225,7 @@ "pinned": false }, "source": { - "file_index": 23, + "file_index": 24, "line": 18 } }, @@ -7144,7 +7252,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 24, + "file_index": 25, "line": 10 } } @@ -7159,7 +7267,7 @@ "pinned": true }, "source": { - "file_index": 24, + "file_index": 25, "line": 9 } }, @@ -7182,7 +7290,7 @@ "value": 0, "doc": null, "source": { - "file_index": 24, + "file_index": 25, "line": 16 } }, @@ -7191,7 +7299,7 @@ "value": 1, "doc": null, "source": { - "file_index": 24, + "file_index": 25, "line": 17 } } @@ -7204,7 +7312,7 @@ "pinned": true }, "source": { - "file_index": 24, + "file_index": 25, "line": 15 } }, @@ -7227,7 +7335,7 @@ "value": 1, "doc": null, "source": { - "file_index": 24, + "file_index": 25, "line": 23 } }, @@ -7236,7 +7344,7 @@ "value": 2, "doc": null, "source": { - "file_index": 24, + "file_index": 25, "line": 24 } } @@ -7248,7 +7356,7 @@ "pinned": true }, "source": { - "file_index": 24, + "file_index": 25, "line": 22 } }, @@ -7275,7 +7383,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 24, + "file_index": 25, "line": 4 } } @@ -7290,10 +7398,88 @@ "pinned": true }, "source": { - "file_index": 24, + "file_index": 25, "line": 3 } }, + "qualified_links_consumer::Consumer": { + "path": "qualified_links_consumer::Consumer", + "visibility": "public", + "size": 1, + "alignment": 1, + "category": "defined", + "kind": { + "type": "type", + "doc": " Test module-qualified doc-links to functions and extern values.\n\n See [`shared_function`](qualified_links_provider::shared_function) and\n [`shared_extern`](qualified_links_provider::shared_extern).", + "doc_links": [ + { + "text": "qualified_links_provider::shared_function", + "target_kind": "module", + "path": "qualified_links_provider", + "anchor": "func-shared_function" + }, + { + "text": "qualified_links_provider::shared_extern", + "target_kind": "item", + "path": "qualified_links_provider::shared_extern" + } + ], + "fields": [ + { + "visibility": "public", + "name": "_marker", + "doc": null, + "type_ref": { + "type": "raw", + "path": "u8" + }, + "offset": 0, + "size": 1, + "alignment": 1, + "is_base": false, + "source": { + "file_index": 26, + "line": 6 + } + } + ], + "associated_functions": [], + "vftable": null, + "singleton": null, + "copyable": false, + "cloneable": false, + "defaultable": false, + "packed": false, + "pinned": false + }, + "source": { + "file_index": 26, + "line": 5 + } + }, + "qualified_links_provider::shared_extern": { + "path": "qualified_links_provider::shared_extern", + "visibility": "public", + "size": 0, + "alignment": 1, + "category": "defined", + "kind": { + "type": "extern_value", + "doc": " An extern value.", + "value_type": { + "type": "mut_pointer", + "inner": { + "type": "raw", + "path": "u32" + } + }, + "address": 512 + }, + "source": { + "file_index": 27, + "line": 7 + } + }, "reexport::Bundle": { "path": "reexport::Bundle", "visibility": "public", @@ -7317,7 +7503,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 25, + "file_index": 28, "line": 11 } }, @@ -7334,7 +7520,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 25, + "file_index": 28, "line": 12 } } @@ -7349,7 +7535,7 @@ "pinned": false }, "source": { - "file_index": 25, + "file_index": 28, "line": 10 } }, @@ -7376,7 +7562,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 26, + "file_index": 29, "line": 8 } } @@ -7391,7 +7577,7 @@ "pinned": false }, "source": { - "file_index": 26, + "file_index": 29, "line": 7 } }, @@ -7418,7 +7604,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 20 } }, @@ -7439,7 +7625,7 @@ "alignment": 1, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 23 } }, @@ -7462,7 +7648,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 23 } }, @@ -7485,7 +7671,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 24 } }, @@ -7508,7 +7694,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 25 } }, @@ -7529,7 +7715,7 @@ "alignment": 1, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 13 } } @@ -7544,7 +7730,7 @@ "pinned": false }, "source": { - "file_index": 27, + "file_index": 30, "line": 19 } }, @@ -7577,7 +7763,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 4 } } @@ -7592,7 +7778,7 @@ "pinned": false }, "source": { - "file_index": 27, + "file_index": 30, "line": 3 } }, @@ -7619,7 +7805,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 31 } }, @@ -7640,7 +7826,7 @@ "alignment": 1, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 34 } }, @@ -7663,7 +7849,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 34 } }, @@ -7686,7 +7872,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 35 } } @@ -7701,7 +7887,7 @@ "pinned": false }, "source": { - "file_index": 27, + "file_index": 30, "line": 30 } }, @@ -7734,7 +7920,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 27, + "file_index": 30, "line": 10 } } @@ -7749,7 +7935,7 @@ "pinned": false }, "source": { - "file_index": 27, + "file_index": 30, "line": 9 } }, @@ -7776,7 +7962,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 28, + "file_index": 31, "line": 3 } } @@ -7791,7 +7977,7 @@ "pinned": false }, "source": { - "file_index": 28, + "file_index": 31, "line": 2 } }, @@ -7842,7 +8028,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 29, + "file_index": 32, "line": 4 } }, @@ -7859,7 +8045,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 29, + "file_index": 32, "line": 8 } } @@ -7892,7 +8078,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 12 } } @@ -7926,7 +8112,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 4 } } @@ -7940,7 +8126,7 @@ "pinned": false }, "source": { - "file_index": 29, + "file_index": 32, "line": 2 } }, @@ -7990,7 +8176,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 29, + "file_index": 32, "line": 4 } } @@ -8005,7 +8191,7 @@ "pinned": false }, "source": { - "file_index": 29, + "file_index": 32, "line": 4 } }, @@ -8035,7 +8221,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 29, + "file_index": 32, "line": 18 } }, @@ -8052,7 +8238,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 29, + "file_index": 32, "line": 22 } } @@ -8085,7 +8271,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 26 } } @@ -8119,7 +8305,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 18 } } @@ -8133,7 +8319,7 @@ "pinned": false }, "source": { - "file_index": 29, + "file_index": 32, "line": 16 } }, @@ -8183,7 +8369,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 29, + "file_index": 32, "line": 18 } } @@ -8198,7 +8384,7 @@ "pinned": false }, "source": { - "file_index": 29, + "file_index": 32, "line": 18 } }, @@ -8225,7 +8411,7 @@ "alignment": 8, "is_base": true, "source": { - "file_index": 29, + "file_index": 32, "line": 37 } }, @@ -8242,7 +8428,7 @@ "alignment": 8, "is_base": true, "source": { - "file_index": 29, + "file_index": 32, "line": 39 } } @@ -8276,7 +8462,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 12 } }, @@ -8308,7 +8494,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 26 } }, @@ -8340,7 +8526,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 18 } }, @@ -8371,7 +8557,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 43 } } @@ -8405,7 +8591,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 32 } }, @@ -8436,7 +8622,7 @@ }, "calling_convention": "system", "source": { - "file_index": 29, + "file_index": 32, "line": 33 } } @@ -8450,7 +8636,7 @@ "pinned": false }, "source": { - "file_index": 29, + "file_index": 32, "line": 30 } }, @@ -8500,7 +8686,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 29, + "file_index": 32, "line": 32 } }, @@ -8540,7 +8726,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 29, + "file_index": 32, "line": 33 } } @@ -8555,7 +8741,7 @@ "pinned": false }, "source": { - "file_index": 29, + "file_index": 32, "line": 32 } }, @@ -8585,7 +8771,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 30, + "file_index": 33, "line": 9 } } @@ -8600,7 +8786,7 @@ "pinned": false }, "source": { - "file_index": 30, + "file_index": 33, "line": 8 } }, @@ -8636,7 +8822,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 31, + "file_index": 34, "line": 59 } }, @@ -8663,7 +8849,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 31, + "file_index": 34, "line": 60 } } @@ -8678,7 +8864,7 @@ "pinned": false }, "source": { - "file_index": 31, + "file_index": 34, "line": 58 } }, @@ -8703,7 +8889,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 16 } }, @@ -8733,7 +8919,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 31, + "file_index": 34, "line": 33 } }, @@ -8753,7 +8939,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 31, + "file_index": 34, "line": 34 } } @@ -8768,7 +8954,7 @@ "pinned": false }, "source": { - "file_index": 31, + "file_index": 34, "line": 32 } }, @@ -8801,7 +8987,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 25 } }, @@ -8834,7 +9020,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 31, + "file_index": 34, "line": 42 } }, @@ -8857,7 +9043,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 31, + "file_index": 34, "line": 43 } }, @@ -8884,7 +9070,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 31, + "file_index": 34, "line": 44 } }, @@ -8904,7 +9090,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 31, + "file_index": 34, "line": 45 } }, @@ -8924,7 +9110,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 31, + "file_index": 34, "line": 46 } } @@ -8939,7 +9125,7 @@ "pinned": false }, "source": { - "file_index": 31, + "file_index": 34, "line": 41 } }, @@ -8961,7 +9147,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 9 } }, @@ -8986,7 +9172,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 13 } }, @@ -9017,7 +9203,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 28 } }, @@ -9045,7 +9231,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 19 } }, @@ -9067,7 +9253,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 6 } }, @@ -9096,7 +9282,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 54 } }, @@ -9124,7 +9310,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 51 } }, @@ -9152,7 +9338,7 @@ } }, "source": { - "file_index": 31, + "file_index": 34, "line": 22 } }, @@ -9287,7 +9473,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 32, + "file_index": 35, "line": 8 } }, @@ -9304,7 +9490,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 32, + "file_index": 35, "line": 13 } } @@ -9326,7 +9512,7 @@ "return_type": null, "calling_convention": "system", "source": { - "file_index": 32, + "file_index": 35, "line": 20 } } @@ -9349,7 +9535,7 @@ "return_type": null, "calling_convention": "system", "source": { - "file_index": 32, + "file_index": 35, "line": 8 } } @@ -9363,7 +9549,7 @@ "pinned": false }, "source": { - "file_index": 32, + "file_index": 35, "line": 5 } }, @@ -9403,7 +9589,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 32, + "file_index": 35, "line": 8 } } @@ -9418,7 +9604,7 @@ "pinned": false }, "source": { - "file_index": 32, + "file_index": 35, "line": 8 } }, @@ -9448,7 +9634,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 33, + "file_index": 36, "line": 13 } }, @@ -9465,7 +9651,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 33, + "file_index": 36, "line": 23 } } @@ -9492,7 +9678,7 @@ }, "calling_convention": "system", "source": { - "file_index": 33, + "file_index": 36, "line": 13 } }, @@ -9512,7 +9698,7 @@ "return_type": null, "calling_convention": "system", "source": { - "file_index": 33, + "file_index": 36, "line": 11 } }, @@ -9543,7 +9729,7 @@ }, "calling_convention": "system", "source": { - "file_index": 33, + "file_index": 36, "line": 16 } }, @@ -9566,7 +9752,7 @@ }, "calling_convention": "system", "source": { - "file_index": 33, + "file_index": 36, "line": 19 } } @@ -9580,7 +9766,7 @@ "pinned": false }, "source": { - "file_index": 33, + "file_index": 36, "line": 9 } }, @@ -9623,7 +9809,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 33, + "file_index": 36, "line": 13 } }, @@ -9653,7 +9839,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 33, + "file_index": 36, "line": 11 } }, @@ -9693,7 +9879,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 33, + "file_index": 36, "line": 16 } }, @@ -9726,7 +9912,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 33, + "file_index": 36, "line": 19 } } @@ -9741,7 +9927,7 @@ "pinned": false }, "source": { - "file_index": 33, + "file_index": 36, "line": 13 } }, @@ -9789,7 +9975,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 36, + "file_index": 39, "line": 10 } } @@ -9804,7 +9990,7 @@ "pinned": false }, "source": { - "file_index": 36, + "file_index": 39, "line": 9 } }, @@ -9834,7 +10020,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 34, + "file_index": 37, "line": 8 } }, @@ -9851,7 +10037,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 34, + "file_index": 37, "line": 9 } }, @@ -9868,7 +10054,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 34, + "file_index": 37, "line": 10 } } @@ -9883,7 +10069,7 @@ "pinned": false }, "source": { - "file_index": 34, + "file_index": 37, "line": 7 } }, @@ -9910,7 +10096,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 35, + "file_index": 38, "line": 6 } } @@ -9925,7 +10111,7 @@ "pinned": false }, "source": { - "file_index": 35, + "file_index": 38, "line": 5 } }, @@ -9952,7 +10138,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 37, + "file_index": 40, "line": 4 } }, @@ -9969,7 +10155,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 37, + "file_index": 40, "line": 5 } } @@ -9984,7 +10170,7 @@ "pinned": false }, "source": { - "file_index": 37, + "file_index": 40, "line": 3 } }, @@ -10021,7 +10207,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 38, + "file_index": 41, "line": 14 } }, @@ -10041,7 +10227,7 @@ "alignment": 8, "is_base": false, "source": { - "file_index": 38, + "file_index": 41, "line": 15 } } @@ -10056,7 +10242,7 @@ "pinned": false }, "source": { - "file_index": 38, + "file_index": 41, "line": 13 } }, @@ -10083,7 +10269,7 @@ "alignment": 1, "is_base": false, "source": { - "file_index": 39, + "file_index": 42, "line": 10 } }, @@ -10100,7 +10286,7 @@ "alignment": 4, "is_base": false, "source": { - "file_index": 39, + "file_index": 42, "line": 13 } }, @@ -10121,7 +10307,7 @@ "alignment": 1, "is_base": false, "source": { - "file_index": 39, + "file_index": 42, "line": 6 } } @@ -10136,7 +10322,7 @@ "pinned": false }, "source": { - "file_index": 39, + "file_index": 42, "line": 9 } }, @@ -10167,7 +10353,7 @@ "alignment": 1, "is_base": false, "source": { - "file_index": 39, + "file_index": 42, "line": 1 } } @@ -10182,7 +10368,7 @@ "pinned": false }, "source": { - "file_index": 39, + "file_index": 42, "line": 4 } } @@ -10539,6 +10725,19 @@ "line": 1 } }, + "doc_self_links": { + "doc": null, + "items": [ + "doc_self_links::Container" + ], + "submodules": {}, + "functions": [], + "splices": [], + "source": { + "file_index": 12, + "line": 1 + } + }, "enums": { "doc": null, "items": [ @@ -10548,7 +10747,7 @@ "functions": [], "splices": [], "source": { - "file_index": 12, + "file_index": 13, "line": 1 } }, @@ -10562,7 +10761,7 @@ "functions": [], "splices": [], "source": { - "file_index": 13, + "file_index": 14, "line": 1 } }, @@ -10579,7 +10778,7 @@ "functions": [], "splices": [], "source": { - "file_index": 14, + "file_index": 15, "line": 1 } }, @@ -10605,7 +10804,7 @@ }, "calling_convention": "system", "source": { - "file_index": 15, + "file_index": 16, "line": 34 } } @@ -10673,7 +10872,7 @@ } ], "source": { - "file_index": 15, + "file_index": 16, "line": 1 } }, @@ -10687,7 +10886,7 @@ "functions": [], "splices": [], "source": { - "file_index": 16, + "file_index": 17, "line": 1 } }, @@ -10710,7 +10909,7 @@ "return_type": null, "calling_convention": "system", "source": { - "file_index": 17, + "file_index": 18, "line": 13 } }, @@ -10738,14 +10937,14 @@ }, "calling_convention": "system", "source": { - "file_index": 17, + "file_index": 18, "line": 17 } } ], "splices": [], "source": { - "file_index": 17, + "file_index": 18, "line": 1 } }, @@ -10768,7 +10967,7 @@ "functions": [], "splices": [], "source": { - "file_index": 18, + "file_index": 19, "line": 1 } }, @@ -10785,7 +10984,7 @@ "functions": [], "splices": [], "source": { - "file_index": 19, + "file_index": 20, "line": 1 } }, @@ -10801,7 +11000,7 @@ "functions": [], "splices": [], "source": { - "file_index": 20, + "file_index": 21, "line": 1 } }, @@ -10815,7 +11014,7 @@ "functions": [], "splices": [], "source": { - "file_index": 21, + "file_index": 22, "line": 1 } }, @@ -10835,7 +11034,7 @@ "functions": [], "splices": [], "source": { - "file_index": 22, + "file_index": 23, "line": 1 } }, @@ -10848,7 +11047,7 @@ "functions": [], "splices": [], "source": { - "file_index": 23, + "file_index": 24, "line": 1 } }, @@ -10864,10 +11063,53 @@ "functions": [], "splices": [], "source": { - "file_index": 24, + "file_index": 25, + "line": 1 + } + }, + "qualified_links_consumer": { + "doc": null, + "items": [ + "qualified_links_consumer::Consumer" + ], + "submodules": {}, + "functions": [], + "splices": [], + "source": { + "file_index": 26, "line": 1 } }, + "qualified_links_provider": { + "doc": null, + "items": [ + "qualified_links_provider::shared_extern" + ], + "submodules": {}, + "functions": [ + { + "visibility": "public", + "name": "shared_function", + "doc": " A module with a freestanding function.", + "body": { + "type": "address", + "address": 256 + }, + "arguments": [], + "return_type": null, + "calling_convention": "system", + "source": { + "file_index": 27, + "line": 3 + } + } + ], + "splices": [], + "source": { + "file_index": 27, + "line": 3 + } + }, "reexport": { "doc": null, "items": [ @@ -10887,7 +11129,7 @@ "functions": [], "splices": [], "source": { - "file_index": 25, + "file_index": 28, "line": 1 } }, @@ -10900,7 +11142,7 @@ "functions": [], "splices": [], "source": { - "file_index": 26, + "file_index": 29, "line": 1 } }, @@ -10924,7 +11166,7 @@ "functions": [], "splices": [], "source": { - "file_index": 27, + "file_index": 30, "line": 1 } }, @@ -10937,7 +11179,7 @@ "functions": [], "splices": [], "source": { - "file_index": 28, + "file_index": 31, "line": 1 } }, @@ -10955,7 +11197,7 @@ "functions": [], "splices": [], "source": { - "file_index": 29, + "file_index": 32, "line": 1 } }, @@ -10968,7 +11210,7 @@ "functions": [], "splices": [], "source": { - "file_index": 30, + "file_index": 33, "line": 1 } }, @@ -10993,7 +11235,7 @@ "functions": [], "splices": [], "source": { - "file_index": 31, + "file_index": 34, "line": 1 } }, @@ -11007,7 +11249,7 @@ "functions": [], "splices": [], "source": { - "file_index": 32, + "file_index": 35, "line": 3 } }, @@ -11021,7 +11263,7 @@ "functions": [], "splices": [], "source": { - "file_index": 33, + "file_index": 36, "line": 8 } }, @@ -11040,7 +11282,7 @@ "functions": [], "splices": [], "source": { - "file_index": 34, + "file_index": 37, "line": 1 } }, @@ -11057,7 +11299,7 @@ "functions": [], "splices": [], "source": { - "file_index": 35, + "file_index": 38, "line": 1 } } @@ -11075,7 +11317,7 @@ "functions": [], "splices": [], "source": { - "file_index": 37, + "file_index": 40, "line": 1 } } @@ -11094,7 +11336,7 @@ } ], "source": { - "file_index": 36, + "file_index": 39, "line": 7 } }, @@ -11107,7 +11349,7 @@ "functions": [], "splices": [], "source": { - "file_index": 38, + "file_index": 41, "line": 1 } }, @@ -11121,7 +11363,7 @@ "functions": [], "splices": [], "source": { - "file_index": 39, + "file_index": 42, "line": 1 } } @@ -11139,6 +11381,7 @@ "definition_body.pyxis", "diamond_inheritance.pyxis", "doc_comments.pyxis", + "doc_self_links.pyxis", "enums.pyxis", "extern_bindings.pyxis", "extern_values.pyxis", @@ -11152,6 +11395,8 @@ "multiple_levels.pyxis", "nested_items.pyxis", "pinned.pyxis", + "qualified_links_consumer.pyxis", + "qualified_links_provider.pyxis", "reexport.pyxis", "reexport_consumer.pyxis", "self_referential_generics.pyxis", diff --git a/codegen_tests/output/rust/doc_self_links.rs b/codegen_tests/output/rust/doc_self_links.rs new file mode 100644 index 00000000..4a396a2f --- /dev/null +++ b/codegen_tests/output/rust/doc_self_links.rs @@ -0,0 +1,63 @@ +#![cfg_attr(any(), rustfmt::skip)] +#[allow(unused_imports)] +use Container_Nested as Nested; +#[repr(C, align(4))] +/// Test `Self::` links in type docs. +/// +/// Link to a field as [`Self::field`](Self::field), to a method as +/// [`Self::method`](Self::method), to a nested type as +/// [`Self::Nested`](Container_Nested), and to a nested type's member as +/// [`Self::Nested::nested_field`](Container_Nested::nested_field). +pub struct Container { + /// A field. + pub field: u32, +} +fn _Container_size_check() { + unsafe { + ::std::mem::transmute::<[u8; 0x4], Container>([0u8; 0x4]); + } + unreachable!() +} +impl Container { + /// A method. + pub unsafe fn method(&self) { + unsafe { + let f: unsafe extern "system" fn(this: *const Self) = ::std::mem::transmute( + 0x10 as usize, + ); + f(self as *const Self as _) + } + } +} +impl std::convert::AsRef for Container { + fn as_ref(&self) -> &Container { + self + } +} +impl std::convert::AsMut for Container { + fn as_mut(&mut self) -> &mut Container { + self + } +} +#[repr(C, align(2))] +/// A nested type. Its field is [`Self::nested_field`](Self::nested_field). +pub struct Container_Nested { + pub nested_field: u16, +} +fn _Container_Nested_size_check() { + unsafe { + ::std::mem::transmute::<[u8; 0x2], Container_Nested>([0u8; 0x2]); + } + unreachable!() +} +impl Container_Nested {} +impl std::convert::AsRef for Container_Nested { + fn as_ref(&self) -> &Container_Nested { + self + } +} +impl std::convert::AsMut for Container_Nested { + fn as_mut(&mut self) -> &mut Container_Nested { + self + } +} diff --git a/codegen_tests/output/rust/lib.rs b/codegen_tests/output/rust/lib.rs index 04302d13..9c0061ca 100644 --- a/codegen_tests/output/rust/lib.rs +++ b/codegen_tests/output/rust/lib.rs @@ -106,6 +106,7 @@ pub mod consts; pub mod definition_body; pub mod diamond_inheritance; pub mod doc_comments; +pub mod doc_self_links; pub mod enums; pub mod extern_bindings; pub mod extern_values; @@ -119,6 +120,8 @@ pub mod min_size; pub mod multiple_levels; pub mod nested_items; pub mod pinned; +pub mod qualified_links_consumer; +pub mod qualified_links_provider; pub mod reexport; pub mod reexport_consumer; pub mod self_referential_generics; diff --git a/codegen_tests/output/rust/qualified_links_consumer.rs b/codegen_tests/output/rust/qualified_links_consumer.rs new file mode 100644 index 00000000..9d02800d --- /dev/null +++ b/codegen_tests/output/rust/qualified_links_consumer.rs @@ -0,0 +1,26 @@ +#![cfg_attr(any(), rustfmt::skip)] +#[repr(C, align(1))] +/// Test module-qualified doc-links to functions and extern values. +/// +/// See [`shared_function`](crate::qualified_links_provider::shared_function) and +/// [`shared_extern`](crate::qualified_links_provider::get_shared_extern). +pub struct Consumer { + pub _marker: u8, +} +fn _Consumer_size_check() { + unsafe { + ::std::mem::transmute::<[u8; 0x1], Consumer>([0u8; 0x1]); + } + unreachable!() +} +impl Consumer {} +impl std::convert::AsRef for Consumer { + fn as_ref(&self) -> &Consumer { + self + } +} +impl std::convert::AsMut for Consumer { + fn as_mut(&mut self) -> &mut Consumer { + self + } +} diff --git a/codegen_tests/output/rust/qualified_links_provider.rs b/codegen_tests/output/rust/qualified_links_provider.rs new file mode 100644 index 00000000..ac0d6222 --- /dev/null +++ b/codegen_tests/output/rust/qualified_links_provider.rs @@ -0,0 +1,12 @@ +#![cfg_attr(any(), rustfmt::skip)] +/// An extern value. +pub unsafe fn get_shared_extern() -> &'static mut *mut u32 { + unsafe { &mut *(0x200 as *mut *mut u32) } +} +/// A module with a freestanding function. +pub unsafe fn shared_function() { + unsafe { + let f: unsafe extern "system" fn() = ::std::mem::transmute(0x100 as usize); + f() + } +} diff --git a/src/backends/json.rs b/src/backends/json.rs index 8befc26a..750d8e36 100644 --- a/src/backends/json.rs +++ b/src/backends/json.rs @@ -271,11 +271,11 @@ impl DocCx<'_> { /// Convert a doc comment into its markdown text and resolved links. fn convert(&self, doc: &[String]) -> (Option, Vec) { let mut links: Vec = Vec::new(); - for text in crate::semantic::doc_links::extract_links(doc) { + for (text, segments) in crate::semantic::doc_links::extract_links(doc) { if links.iter().any(|l| l.text == text) { continue; } - if let Some(target) = self.resolver.resolve(&self.scope, &text) { + if let Some(target) = self.resolver.resolve(&self.scope, &segments, None) { links.push(JsonDocLink::from_target(text, target)); } } diff --git a/src/semantic/doc_links.rs b/src/semantic/doc_links.rs index 0ea24eb6..a104f73a 100644 --- a/src/semantic/doc_links.rs +++ b/src/semantic/doc_links.rs @@ -285,45 +285,100 @@ impl DocLinkResolver { /// Resolve a written link path (e.g. `Action`, `Type::method`) against a /// module scope. Returns `None` if it doesn't resolve to anything. - pub fn resolve(&self, scope: &[ItemPath], path_str: &str) -> Option { + /// + /// `path` is the parsed `::`-separated segments of the link text. + /// `enclosing_type` is the path of the type/enum/bitflags whose definition + /// contains the doc comment — used to resolve `Self::` links. `None` at + /// module scope. + pub fn resolve( + &self, + scope: &[ItemPath], + path: &[ItemPathSegment], + enclosing_type: Option<&ItemPath>, + ) -> Option { + // If the first segment is `Self`, replace it with the enclosing type's + // segments. Works entirely at the ItemPathSegment level. + let owned_segments; + let path = if path.first().is_some_and(|s| s.as_str() == "Self") { + let enclosing = enclosing_type?; + owned_segments = enclosing + .iter() + .cloned() + .chain(path[1..].iter().cloned()) + .collect::>(); + &owned_segments + } else { + path + }; + // 1. The whole path as a type. A nested constant is skipped here so it // falls through to the `Type::member` branch and resolves as a // member of its parent — the Rust backend emits it as an associated // const, so it has no importable free-item path of its own. - if let Some(item_path) = self.find_item(scope, path_str) + if let Some(item_path) = self.find_item(scope, path) && !self.nested_constant_paths.contains(&item_path) && !self.extern_value_paths.contains(&item_path) { return Some(DocLinkTarget::Item(item_path)); } // 2. `Type::member`. - if let Some((prefix, member)) = path_str.rsplit_once("::") - && let Some(item_path) = self.find_item(scope, prefix) - && let Some(kind) = self.find_member(&item_path, member) - { - return Some(DocLinkTarget::Member { - item: item_path, - name: member.to_string(), - kind, - }); + if path.len() >= 2 { + let (prefix, member) = path.split_at(path.len() - 1); + let member = member[0].as_str(); + if let Some(item_path) = self.find_item(scope, prefix) + && let Some(kind) = self.find_member(&item_path, member) + { + return Some(DocLinkTarget::Member { + item: item_path, + name: member.to_string(), + kind, + }); + } } // 3/4. A module-level freestanding function or extern value — the // current module first, then any module in the crate (the backend // imports it, like types). - if !path_str.contains("::") { - if let Some(module) = self.find_in_modules(scope, &self.module_functions, path_str) { + if path.len() == 1 { + let name = path[0].as_str(); + if let Some(module) = self.find_in_modules(scope, &self.module_functions, name) { return Some(DocLinkTarget::Function { module, - name: path_str.to_string(), + name: name.to_string(), }); } - if let Some(module) = self.find_in_modules(scope, &self.module_extern_values, path_str) - { + if let Some(module) = self.find_in_modules(scope, &self.module_extern_values, name) { return Some(DocLinkTarget::ExternValue { module, - name: path_str.to_string(), + name: name.to_string(), }); } + } else if path.len() >= 2 { + // Qualified: resolve the module path, then check that module. + let name = path[path.len() - 1].as_str(); + let module_segments = &path[..path.len() - 1]; + let bases = std::iter::once(ItemPath::empty()).chain(scope.iter().cloned()); + for base in bases { + let mut full = base.clone(); + for seg in module_segments { + full.push(seg.clone()); + } + if let Some(fns) = self.module_functions.get(&full) + && fns.iter().any(|n| n == name) + { + return Some(DocLinkTarget::Function { + module: full, + name: name.to_string(), + }); + } + if let Some(evs) = self.module_extern_values.get(&full) + && evs.iter().any(|n| n == name) + { + return Some(DocLinkTarget::ExternValue { + module: full, + name: name.to_string(), + }); + } + } } None } @@ -356,13 +411,14 @@ impl DocLinkResolver { /// crate-wide (any accessible type with that name) since the Rust backend /// imports doc-referenced types regardless of the current `use`s; a /// `::`-qualified name is resolved root- or scope-relative. - fn find_item(&self, scope: &[ItemPath], path_str: &str) -> Option { + fn find_item(&self, scope: &[ItemPath], segments: &[ItemPathSegment]) -> Option { let from_module = scope.first(); - if !path_str.contains("::") { + if segments.len() == 1 { // 1. A type directly imported into scope wins. + let name = segments[0].as_str(); if let Some(p) = scope.iter().rev().find(|ip| { - self.items.contains_key(ip) && ip.last().map(|s| s.as_str()) == Some(path_str) + self.items.contains_key(ip) && ip.last().map(|s| s.as_str()) == Some(name) }) { return Some(p.clone()); } @@ -372,8 +428,7 @@ impl DocLinkResolver { .items .keys() .filter(|ip| { - ip.last().map(|s| s.as_str()) == Some(path_str) - && self.can_access(from_module, ip) + ip.last().map(|s| s.as_str()) == Some(name) && self.can_access(from_module, ip) }) .collect(); candidates.sort_by_key(|ip| { @@ -384,12 +439,10 @@ impl DocLinkResolver { } // Qualified path: try it root-relative or relative to a scope module. - let segments: Vec = - path_str.split("::").map(ItemPathSegment::from).collect(); let bases = std::iter::once(ItemPath::empty()).chain(scope.iter().cloned()); for base in bases { let mut full = base.clone(); - for seg in &segments { + for seg in segments { full.push(seg.clone()); } if self.items.contains_key(&full) && self.can_access(from_module, &full) { @@ -426,18 +479,48 @@ impl DocLinkResolver { modules: &BTreeMap, module_path: &ItemPath, ) -> ModuleDocLinks { + let mut links = ModuleDocLinks::default(); + let out = &mut links; + self.walk_module_docs( + type_registry, + modules, + module_path, + |doc, scope, enclosing, _| { + self.add_doc_imports(scope, enclosing, doc, out); + Ok(()) + }, + ) + .ok(); + links + } + + /// Walk every doc-comment-bearing location in `module_path` — the module's + /// own doc and functions, every item whose parent is `module_path`, and + /// every member/nested-item doc within those items. The closure receives + /// `(doc, scope, enclosing_type, location)` at each location. + /// + /// `enclosing_type` is `Some(path)` when inside a type/enum/bitflags + /// definition (enables `Self::` resolution) and `None` at module scope. + /// For nested items, the enclosing type is the nested item's own path — + /// `Self::` inside a nested enum's variant doc refers to the nested enum, + /// not the outer type — but the augmented `type_scope` (outer type's + /// scope) is still used for bare-name resolution of siblings. + fn walk_module_docs( + &self, + type_registry: &TypeRegistry, + modules: &BTreeMap, + module_path: &ItemPath, + mut visit: impl FnMut(&[String], &[ItemPath], Option<&ItemPath>, &ItemLocation) -> Result<()>, + ) -> Result<()> { let Some(module) = modules.get(module_path) else { - return ModuleDocLinks::default(); + return Ok(()); }; let scope = module.scope(); - let mut links = ModuleDocLinks::default(); - self.add_doc_imports(&scope, module.doc(), &mut links); + visit(module.doc(), &scope, None, module.location())?; for f in module.functions() { - self.add_doc_imports(&scope, &f.doc, &mut links); + visit(&f.doc, &scope, None, &f.location)?; } - // Extern values' docs (including module-level ones) are collected by the - // registry walk below. for (path, item) in type_registry.iter() { if path.parent().as_ref() != Some(module_path) { @@ -446,6 +529,7 @@ impl DocLinkResolver { let Some(resolved) = item.resolved() else { continue; }; + let enclosing = Some(path); match &resolved.inner { ItemDefinitionInner::Type(td) => { // Augment scope with the type's own path so bare @@ -453,87 +537,104 @@ impl DocLinkResolver { let type_scope: Vec = std::iter::once(path.clone()) .chain(scope.iter().cloned()) .collect(); - self.add_doc_imports(&type_scope, &td.doc, &mut links); + visit(&td.doc, &type_scope, enclosing, &item.location)?; for r in &td.regions { - self.add_doc_imports(&type_scope, &r.doc, &mut links); + visit(&r.doc, &type_scope, enclosing, &r.location)?; } for f in &td.associated_functions { - self.add_doc_imports(&type_scope, &f.doc, &mut links); + visit(&f.doc, &type_scope, enclosing, &f.location)?; } if let Some(v) = &td.vftable { for f in &v.functions { - self.add_doc_imports(&type_scope, &f.doc, &mut links); + visit(&f.doc, &type_scope, enclosing, &f.location)?; } } - // Also scan doc comments on nested items for nested_path in &td.nested_item_paths { - if let Some(nested_item) = type_registry + let Some(nested_item) = type_registry .get(nested_path, &ItemLocation::internal()) .ok() - && let Some(nested_resolved) = nested_item.resolved() - { - match &nested_resolved.inner { - ItemDefinitionInner::Type(ntd) => { - self.add_doc_imports(&type_scope, &ntd.doc, &mut links); - } - ItemDefinitionInner::Enum(ned) => { - self.add_doc_imports(&type_scope, &ned.doc, &mut links); - for v in &ned.variants { - self.add_doc_imports(&type_scope, &v.doc, &mut links); - } - } - ItemDefinitionInner::Bitflags(nbd) => { - self.add_doc_imports(&type_scope, &nbd.doc, &mut links); - for f in &nbd.flags { - self.add_doc_imports(&type_scope, &f.doc, &mut links); - } - } - ItemDefinitionInner::TypeAlias(nta) => { - self.add_doc_imports(&type_scope, &nta.doc, &mut links); + else { + continue; + }; + let Some(nested_resolved) = nested_item.resolved() else { + continue; + }; + let nested_loc = &nested_item.location; + // Nested items use their own path as enclosing type — + // `Self::` inside a nested enum's variant doc refers to + // the nested enum, not the outer type. The `type_scope` + // (outer type's augmented scope) is still used for + // bare-name resolution of siblings. + let nested_enclosing = Some(nested_path); + match &nested_resolved.inner { + ItemDefinitionInner::Type(ntd) => { + visit(&ntd.doc, &type_scope, nested_enclosing, nested_loc)?; + } + ItemDefinitionInner::Enum(ned) => { + visit(&ned.doc, &type_scope, nested_enclosing, nested_loc)?; + for v in &ned.variants { + visit(&v.doc, &type_scope, nested_enclosing, &v.location)?; } - ItemDefinitionInner::Constant(ncd) => { - self.add_doc_imports(&type_scope, &ncd.doc, &mut links); + for f in &ned.associated_functions { + visit(&f.doc, &type_scope, nested_enclosing, &f.location)?; } - ItemDefinitionInner::ExternValue(nev) => { - self.add_doc_imports(&type_scope, &nev.doc, &mut links); + } + ItemDefinitionInner::Bitflags(nbd) => { + visit(&nbd.doc, &type_scope, nested_enclosing, nested_loc)?; + for f in &nbd.flags { + visit(&f.doc, &type_scope, nested_enclosing, &f.location)?; } } + ItemDefinitionInner::TypeAlias(nta) => { + visit(&nta.doc, &type_scope, nested_enclosing, nested_loc)?; + } + ItemDefinitionInner::Constant(ncd) => { + visit(&ncd.doc, &type_scope, nested_enclosing, nested_loc)?; + } + ItemDefinitionInner::ExternValue(nev) => { + visit(&nev.doc, &type_scope, nested_enclosing, nested_loc)?; + } } } } ItemDefinitionInner::Enum(ed) => { - self.add_doc_imports(&scope, &ed.doc, &mut links); + visit(&ed.doc, &scope, enclosing, &item.location)?; for v in &ed.variants { - self.add_doc_imports(&scope, &v.doc, &mut links); + visit(&v.doc, &scope, enclosing, &v.location)?; } for f in &ed.associated_functions { - self.add_doc_imports(&scope, &f.doc, &mut links); + visit(&f.doc, &scope, enclosing, &f.location)?; } } ItemDefinitionInner::Bitflags(bd) => { - self.add_doc_imports(&scope, &bd.doc, &mut links); + visit(&bd.doc, &scope, enclosing, &item.location)?; for f in &bd.flags { - self.add_doc_imports(&scope, &f.doc, &mut links); + visit(&f.doc, &scope, enclosing, &f.location)?; } } ItemDefinitionInner::TypeAlias(ta) => { - self.add_doc_imports(&scope, &ta.doc, &mut links); + visit(&ta.doc, &scope, enclosing, &item.location)?; } ItemDefinitionInner::Constant(cd) => { - self.add_doc_imports(&scope, &cd.doc, &mut links); + visit(&cd.doc, &scope, enclosing, &item.location)?; } ItemDefinitionInner::ExternValue(ev) => { - self.add_doc_imports(&scope, &ev.doc, &mut links); + visit(&ev.doc, &scope, enclosing, &item.location)?; } } } - - links + Ok(()) } - fn add_doc_imports(&self, scope: &[ItemPath], doc: &[String], links: &mut ModuleDocLinks) { - for text in extract_links(doc) { - if let Some(target) = self.resolve(scope, &text) { + fn add_doc_imports( + &self, + scope: &[ItemPath], + enclosing_type: Option<&ItemPath>, + doc: &[String], + links: &mut ModuleDocLinks, + ) { + for (text, segments) in extract_links(doc) { + if let Some(target) = self.resolve(scope, &segments, enclosing_type) { links.imports.insert(target.import_path()); if let Some(value_path) = target.extern_value_path() { links.extern_value_links.push((text, value_path)); @@ -610,80 +711,24 @@ pub fn validate( type_registry: &TypeRegistry, modules: &BTreeMap, ) -> Result<()> { - let check = |doc: &[String], scope: &[ItemPath], location: &ItemLocation| -> Result<()> { - for link in extract_links(doc) { - if resolver.resolve(scope, &link).is_none() { - return Err(SemanticError::DocLinkNotFound { - path: link, - location: *location, - }); - } - } - Ok(()) - }; - - for (path, module) in modules { - let scope = module.scope(); - check(module.doc(), &scope, module.location())?; - for f in module.functions() { - check(&f.doc, &scope, &f.location)?; - } - // Extern values' docs are checked in the registry walk below. - let _ = path; - } - - for (path, item) in type_registry.iter() { - let Some(resolved) = item.resolved() else { - continue; - }; - let module_path = path.parent().unwrap_or_else(ItemPath::empty); - let Some(module) = modules.get(&module_path) else { - continue; - }; - let scope = module.scope(); - let loc = &item.location; - match &resolved.inner { - ItemDefinitionInner::Type(td) => { - check(&td.doc, &scope, loc)?; - for r in &td.regions { - check(&r.doc, &scope, &r.location)?; - } - for f in &td.associated_functions { - check(&f.doc, &scope, &f.location)?; - } - if let Some(v) = &td.vftable { - for f in &v.functions { - check(&f.doc, &scope, &f.location)?; + for module_path in modules.keys() { + resolver.walk_module_docs( + type_registry, + modules, + module_path, + |doc, scope, enclosing, location| { + for (text, segments) in extract_links(doc) { + if resolver.resolve(scope, &segments, enclosing).is_none() { + return Err(SemanticError::DocLinkNotFound { + path: text, + location: *location, + }); } } - } - ItemDefinitionInner::Enum(ed) => { - check(&ed.doc, &scope, loc)?; - for v in &ed.variants { - check(&v.doc, &scope, &v.location)?; - } - for f in &ed.associated_functions { - check(&f.doc, &scope, &f.location)?; - } - } - ItemDefinitionInner::Bitflags(bd) => { - check(&bd.doc, &scope, loc)?; - for f in &bd.flags { - check(&f.doc, &scope, &f.location)?; - } - } - ItemDefinitionInner::TypeAlias(ta) => { - check(&ta.doc, &scope, loc)?; - } - ItemDefinitionInner::Constant(cd) => { - check(&cd.doc, &scope, loc)?; - } - ItemDefinitionInner::ExternValue(ev) => { - check(&ev.doc, &scope, loc)?; - } - } + Ok(()) + }, + )?; } - Ok(()) } @@ -851,15 +896,22 @@ pub fn scan_links(text: &str) -> Vec { out } -/// Extract the intra-doc link path strings the compiler validates and imports: +/// Extract the intra-doc link paths the compiler validates and imports: /// the inline (`[label](path)`) and code-shortcut (`` [`path`] ``) forms. Bare /// `[path]` shortcuts are intentionally excluded. -pub fn extract_links(doc: &[String]) -> Vec { +/// +/// Returns `(original_text, parsed_segments)` for each link — the original +/// path text (for backend rewriting / extern-value link storage) and the +/// parsed `::`-separated segments (for `resolve()`). +pub fn extract_links(doc: &[String]) -> Vec<(String, Vec)> { let text = doc.join("\n"); scan_links(&text) .into_iter() .filter(|l| l.syntax != DocLinkSyntax::PlainShortcut) - .map(|l| l.path) + .map(|l| { + let segs = l.path.split("::").map(ItemPathSegment::from).collect(); + (l.path, segs) + }) .collect() } diff --git a/src/semantic/tests/doc_links.rs b/src/semantic/tests/doc_links.rs index 2bae88cf..d8ca03db 100644 --- a/src/semantic/tests/doc_links.rs +++ b/src/semantic/tests/doc_links.rs @@ -11,6 +11,13 @@ use crate::{ use super::util::*; use pretty_assertions::assert_eq; +/// Parse a `::`-separated path into segments for `resolve()`. +fn segs(s: &str) -> Vec { + s.split("::") + .map(crate::grammar::ItemPathSegment::from) + .collect() +} + #[test] fn extracts_shortcut_and_inline_links() { let doc = vec![ @@ -19,10 +26,11 @@ fn extracts_shortcut_and_inline_links() { " A code-labelled inline link [`Update`](Mode::Update) too.".to_string(), " And plain [text] is ignored.".to_string(), ]; - assert_eq!( - extract_links(&doc), - vec!["Foo", "Bar::baz", "Qux::quux", "Mode::Update"] - ); + let texts: Vec = extract_links(&doc) + .into_iter() + .map(|(text, _)| text) + .collect(); + assert_eq!(texts, vec!["Foo", "Bar::baz", "Qux::quux", "Mode::Update"]); } #[test] @@ -30,7 +38,11 @@ fn ignores_brackets_inside_code_spans() { // `[first, last)` is a code span — its `[` must not consume the `]` // from the real link [`Target`]. let doc = vec![" Half-open range `[first, last)`: walks the [`Target`] list.".to_string()]; - assert_eq!(extract_links(&doc), vec!["Target"]); + let texts: Vec = extract_links(&doc) + .into_iter() + .map(|(text, _)| text) + .collect(); + assert_eq!(texts, vec!["Target"]); } #[test] @@ -78,41 +90,44 @@ fn resolves_every_link_form() { }; assert_eq!( - resolver.resolve(&scope, "Target"), + resolver.resolve(&scope, &segs("Target"), None), Some(DocLinkTarget::Item(IP::from("test::Target"))) ); assert_eq!( - resolver.resolve(&scope, "Target::do_it"), + resolver.resolve(&scope, &segs("Target::do_it"), None), Some(member("test::Target", "do_it", DocLinkMemberKind::Method)) ); assert_eq!( - resolver.resolve(&scope, "Target::m_value"), + resolver.resolve(&scope, &segs("Target::m_value"), None), Some(member("test::Target", "m_value", DocLinkMemberKind::Field)) ); assert_eq!( - resolver.resolve(&scope, "Mode::VarA"), + resolver.resolve(&scope, &segs("Mode::VarA"), None), Some(member("test::Mode", "VarA", DocLinkMemberKind::Variant)) ); assert_eq!( - resolver.resolve(&scope, "Flags::FlagX"), + resolver.resolve(&scope, &segs("Flags::FlagX"), None), Some(member("test::Flags", "FlagX", DocLinkMemberKind::Flag)) ); assert_eq!( - resolver.resolve(&scope, "helper"), + resolver.resolve(&scope, &segs("helper"), None), Some(DocLinkTarget::Function { module: IP::from("test"), name: "helper".to_string(), }) ); assert_eq!( - resolver.resolve(&scope, "global"), + resolver.resolve(&scope, &segs("global"), None), Some(DocLinkTarget::ExternValue { module: IP::from("test"), name: "global".to_string(), }) ); - assert_eq!(resolver.resolve(&scope, "Nonexistent"), None); - assert_eq!(resolver.resolve(&scope, "Target::missing"), None); + assert_eq!(resolver.resolve(&scope, &segs("Nonexistent"), None), None); + assert_eq!( + resolver.resolve(&scope, &segs("Target::missing"), None), + None + ); } #[test] @@ -139,7 +154,7 @@ fn resolves_nested_constant_as_member() { let resolver = state.doc_link_resolver(); assert_eq!( - resolver.resolve(&scope, "Player::STARTING_GOLD"), + resolver.resolve(&scope, &segs("Player::STARTING_GOLD"), None), Some(DocLinkTarget::Member { item: IP::from("test::Player"), name: "STARTING_GOLD".to_string(), @@ -148,8 +163,11 @@ fn resolves_nested_constant_as_member() { ); // A nested constant has no freestanding path to link to by bare name, so it // must not resolve as an item (which would emit an unresolvable import). - assert_eq!(resolver.resolve(&scope, "STARTING_GOLD"), None); - assert_eq!(resolver.resolve(&scope, "Player::MISSING"), None); + assert_eq!(resolver.resolve(&scope, &segs("STARTING_GOLD"), None), None); + assert_eq!( + resolver.resolve(&scope, &segs("Player::MISSING"), None), + None + ); } #[test] @@ -205,7 +223,9 @@ fn resolves_a_type_in_a_sibling_module() { .unwrap() .scope(); assert_eq!( - state.doc_link_resolver().resolve(&scope, "Other"), + state + .doc_link_resolver() + .resolve(&scope, &segs("Other"), None), Some(DocLinkTarget::Item(IP::from("other::Other"))) ); } @@ -222,3 +242,281 @@ fn errors_on_unresolved_doc_link() { "unexpected error: {err:?}" ); } + +// --- Self:: resolution tests --- + +#[test] +fn resolves_self_member() { + // A type with a field and method. `Self::field` and `Self::method` resolve + // as members of the enclosing type. + let module = + M::new() + .with_definitions([ID::new( + (V::Public, "Widget"), + TD::new([TS::field((V::Public, "m_value"), T::ident("u32")) + .with_attributes([A::address(0)])]) + .with_attributes([A::size(4), A::align(4)]), + )]) + .with_impls([FB::new( + "Widget", + [F::new((V::Public, "do_it"), [Ar::const_self()]) + .with_attributes([A::address(0x10)])], + )]); + + let state = build_state(&module, &IP::from("test")).unwrap(); + let scope = state.modules().get(&IP::from("test")).unwrap().scope(); + let resolver = state.doc_link_resolver(); + let enclosing = IP::from("test::Widget"); + + assert_eq!( + resolver.resolve(&scope, &segs("Self::m_value"), Some(&enclosing)), + Some(DocLinkTarget::Member { + item: IP::from("test::Widget"), + name: "m_value".to_string(), + kind: DocLinkMemberKind::Field, + }) + ); + assert_eq!( + resolver.resolve(&scope, &segs("Self::do_it"), Some(&enclosing)), + Some(DocLinkTarget::Member { + item: IP::from("test::Widget"), + name: "do_it".to_string(), + kind: DocLinkMemberKind::Method, + }) + ); +} + +#[test] +fn resolves_self_member_without_enclosing_type() { + // `Self::member` at module scope (no enclosing type) returns None. + let module = M::new().with_definitions([ID::new( + (V::Public, "Widget"), + TD::new([ + TS::field((V::Public, "m_value"), T::ident("u32")).with_attributes([A::address(0)]) + ]) + .with_attributes([A::size(4), A::align(4)]), + )]); + + let state = build_state(&module, &IP::from("test")).unwrap(); + let scope = state.modules().get(&IP::from("test")).unwrap().scope(); + let resolver = state.doc_link_resolver(); + + assert_eq!(resolver.resolve(&scope, &segs("Self::m_value"), None), None); +} + +#[test] +fn resolves_self_item() { + // Bare `Self` resolves to the enclosing type as a whole item. + let module = M::new().with_definitions([ID::new( + (V::Public, "Widget"), + TD::new([ + TS::field((V::Public, "m_value"), T::ident("u32")).with_attributes([A::address(0)]) + ]) + .with_attributes([A::size(4), A::align(4)]), + )]); + + let state = build_state(&module, &IP::from("test")).unwrap(); + let scope = state.modules().get(&IP::from("test")).unwrap().scope(); + let resolver = state.doc_link_resolver(); + let enclosing = IP::from("test::Widget"); + + assert_eq!( + resolver.resolve(&scope, &segs("Self"), Some(&enclosing)), + Some(DocLinkTarget::Item(IP::from("test::Widget"))) + ); + // `Self` with no enclosing type returns None. + assert_eq!(resolver.resolve(&scope, &segs("Self"), None), None); +} + +#[test] +fn resolves_self_nested_type_member() { + // A type with a nested type that has a member. `Self::NestedType::member` + // resolves as a member of the nested type. + let module = M::new().with_definitions([ID::new( + (V::Public, "Outer"), + TD::new([ + TS::item(ID::new( + (V::Public, "Inner"), + TD::new([TS::field((V::Public, "inner_field"), T::ident("u32")) + .with_attributes([A::address(0)])]) + .with_attributes([A::size(4), A::align(4)]), + )), + TS::field((V::Public, "outer_field"), T::ident("u32")).with_attributes([A::address(0)]), + ]) + .with_attributes([A::size(8), A::align(4)]), + )]); + + let state = build_state(&module, &IP::from("test")).unwrap(); + let scope = state.modules().get(&IP::from("test")).unwrap().scope(); + let resolver = state.doc_link_resolver(); + let enclosing = IP::from("test::Outer"); + + assert_eq!( + resolver.resolve(&scope, &segs("Self::Inner::inner_field"), Some(&enclosing)), + Some(DocLinkTarget::Member { + item: IP::from("test::Outer::Inner"), + name: "inner_field".to_string(), + kind: DocLinkMemberKind::Field, + }) + ); +} + +// --- Module-qualified function/extern-value tests --- + +#[test] +fn resolves_qualified_function() { + // A function in module `a` referenced from module `b` via `a::func`. + let module_a = M::new().with_functions([ + F::new((V::Public, "shared_func"), []).with_attributes([A::address(0x10)]) + ]); + let module_b = M::new(); + + let mut builder = SemanticBuilder::new(pointer_size()); + builder.add_module(&module_a, &IP::from("a")).unwrap(); + builder.add_module(&module_b, &IP::from("b")).unwrap(); + let state = builder.build().unwrap(); + + let scope = state.modules().get(&IP::from("b")).unwrap().scope(); + let resolver = state.doc_link_resolver(); + + assert_eq!( + resolver.resolve(&scope, &segs("a::shared_func"), None), + Some(DocLinkTarget::Function { + module: IP::from("a"), + name: "shared_func".to_string(), + }) + ); +} + +#[test] +fn resolves_qualified_extern_value() { + // An extern value in module `a` referenced from module `b` via `a::global`. + let module_a = M::new().with_definitions([ID::new( + (V::Public, "global"), + EVD::new(T::ident("u32").mut_pointer()).with_attributes([A::address(0x20)]), + )]); + let module_b = M::new(); + + let mut builder = SemanticBuilder::new(pointer_size()); + builder.add_module(&module_a, &IP::from("a")).unwrap(); + builder.add_module(&module_b, &IP::from("b")).unwrap(); + let state = builder.build().unwrap(); + + let scope = state.modules().get(&IP::from("b")).unwrap().scope(); + let resolver = state.doc_link_resolver(); + + assert_eq!( + resolver.resolve(&scope, &segs("a::global"), None), + Some(DocLinkTarget::ExternValue { + module: IP::from("a"), + name: "global".to_string(), + }) + ); +} + +#[test] +fn resolves_qualified_function_not_found() { + // Module doesn't exist or function doesn't exist in that module. + let module_a = M::new().with_functions([ + F::new((V::Public, "shared_func"), []).with_attributes([A::address(0x10)]) + ]); + let module_b = M::new(); + + let mut builder = SemanticBuilder::new(pointer_size()); + builder.add_module(&module_a, &IP::from("a")).unwrap(); + builder.add_module(&module_b, &IP::from("b")).unwrap(); + let state = builder.build().unwrap(); + + let scope = state.modules().get(&IP::from("b")).unwrap().scope(); + let resolver = state.doc_link_resolver(); + + // Nonexistent module + assert_eq!( + resolver.resolve(&scope, &segs("nonexistent::func"), None), + None + ); + // Existing module, nonexistent function + assert_eq!( + resolver.resolve(&scope, &segs("a::missing_func"), None), + None + ); +} + +// --- validate() Self:: tests --- + +#[test] +fn validate_accepts_self_doc_link() { + // A type with a valid `Self::` doc-link in its doc comment should pass + // validation without error. + let module = + M::new() + .with_definitions([ID::new( + (V::Public, "Widget"), + TD::new([TS::field((V::Public, "m_value"), T::ident("u32")) + .with_attributes([A::address(0)])]) + .with_attributes([A::size(4), A::align(4)]), + ) + .with_doc_comments(vec![" See [`Self::m_value`] for the value.".to_string()])]) + .with_impls([FB::new( + "Widget", + [F::new((V::Public, "do_it"), [Ar::const_self()]) + .with_attributes([A::address(0x10)])], + )]); + + // Should succeed — no DocLinkNotFound error. + build_state(&module, &IP::from("test")).unwrap(); +} + +#[test] +fn validate_accepts_self_in_nested_item() { + // A type with a nested enum whose variant has a `Self::` doc-link should + // pass validation. + let module = M::new().with_definitions([ID::new( + (V::Public, "Outer"), + TD::new([ + TS::item(ID::new( + (V::Public, "Inner"), + ED::new( + T::ident("u32"), + [ES::field("VariantA") + .with_doc_comments(vec![" See [`Self::VariantA`].".to_string()])], + [], + ), + )), + TS::field((V::Public, "outer_field"), T::ident("u32")).with_attributes([A::address(0)]), + ]) + .with_attributes([A::size(8), A::align(4)]), + )]); + + // Should succeed — `Self::VariantA` inside the nested enum refers to + // the nested enum's own variant. + build_state(&module, &IP::from("test")).unwrap(); +} + +#[test] +fn validate_rejects_invalid_link_in_nested_item() { + // A type with a nested enum whose variant has an invalid doc-link should + // fail validation. + let module = M::new().with_definitions([ID::new( + (V::Public, "Outer"), + TD::new([ + TS::item(ID::new( + (V::Public, "Inner"), + ED::new( + T::ident("u32"), + [ES::field("VariantA") + .with_doc_comments(vec![" See [`Nonexistent`].".to_string()])], + [], + ), + )), + TS::field((V::Public, "outer_field"), T::ident("u32")).with_attributes([A::address(0)]), + ]) + .with_attributes([A::size(8), A::align(4)]), + )]); + + let err = build_state(&module, &IP::from("test")).unwrap_err(); + assert!( + matches!(&err, SemanticError::DocLinkNotFound { path, .. } if path == "Nonexistent"), + "unexpected error: {err:?}" + ); +} diff --git a/tooling/lsp/src/handlers/doc_links.rs b/tooling/lsp/src/handlers/doc_links.rs index 9ab6b808..a65ad807 100644 --- a/tooling/lsp/src/handlers/doc_links.rs +++ b/tooling/lsp/src/handlers/doc_links.rs @@ -2,6 +2,11 @@ use super::*; use pyxis::semantic::doc_links::{DocLinkSyntax, DocLinkTarget, ScannedLink}; +/// Parse a `::`-separated path string into segments for `resolve()`. +fn parse_segments(path: &str) -> Vec { + path.split("::").map(ItemPathSegment::from).collect() +} + impl ServerState { /// Doc-comment links that reference `symbol`, returning the span of the /// symbol's name within each link's path (a type's name segment, or a @@ -66,7 +71,7 @@ impl ServerState { }; for dl in scan_doc_links(line) { if !resolver - .resolve(&scope, &dl.path) + .resolve(&scope, &parse_segments(&dl.path), None) .is_some_and(|t| matches(&t)) { continue; @@ -154,7 +159,7 @@ impl ServerState { for dl in scan_doc_links(line) { // Target file + 1-based line to anchor the link at, and a tooltip. let Some((target_uri, target_line, tooltip)) = - (match resolver.resolve(&scope, &dl.path) { + (match resolver.resolve(&scope, &parse_segments(&dl.path), None) { Some(DocLinkTarget::Item(p)) => self .resolved_definition(&p, type_registry, uri) .map(|rd| (rd.uri, rd.name_span.start.line, p.to_string())), @@ -441,30 +446,31 @@ impl ServerState { Location::new(loc.line, dl.link.0 + 1), Location::new(loc.line, dl.link.1 + 1), ); - let (location, hover) = match resolver.resolve(&scope, &dl.path)? { - DocLinkTarget::Item(p) => to_type(self, &p)?, - DocLinkTarget::Member { item, name, .. } => { - match self.resolve_doc_member(&item, &name, uri) { - Some((muri, mspan, mhover)) => { - let mcontent = self.get_content(&muri)?; - ( - lsp_types::Location { - uri: muri.clone(), - range: pyxis_span_to_lsp_range(mcontent, &mspan), - }, - mhover, - ) + let (location, hover) = + match resolver.resolve(&scope, &parse_segments(&dl.path), None)? { + DocLinkTarget::Item(p) => to_type(self, &p)?, + DocLinkTarget::Member { item, name, .. } => { + match self.resolve_doc_member(&item, &name, uri) { + Some((muri, mspan, mhover)) => { + let mcontent = self.get_content(&muri)?; + ( + lsp_types::Location { + uri: muri.clone(), + range: pyxis_span_to_lsp_range(mcontent, &mspan), + }, + mhover, + ) + } + None => to_type(self, &item)?, } - None => to_type(self, &item)?, } - } - DocLinkTarget::Function { module, name } => { - self.resolve_doc_module_item(&module, &name, uri, true)? - } - DocLinkTarget::ExternValue { module, name } => { - self.resolve_doc_module_item(&module, &name, uri, false)? - } - }; + DocLinkTarget::Function { module, name } => { + self.resolve_doc_module_item(&module, &name, uri, true)? + } + DocLinkTarget::ExternValue { module, name } => { + self.resolve_doc_module_item(&module, &name, uri, false)? + } + }; return Some((link_span, location, hover)); } None diff --git a/tooling/lsp/src/handlers/mod.rs b/tooling/lsp/src/handlers/mod.rs index 1b1fc1e9..d5b07902 100644 --- a/tooling/lsp/src/handlers/mod.rs +++ b/tooling/lsp/src/handlers/mod.rs @@ -16,7 +16,7 @@ pub(crate) use lsp_types::{ pub(crate) use pyxis::{ grammar::{ Attribute, Attributes, BitflagsDefItem, EnumDefItem, Function, ItemDefinition, ItemPath, - Module, ModuleItem, Type, TypeDefItem, TypeField, UseTree, Visibility, + ItemPathSegment, Module, ModuleItem, Type, TypeDefItem, TypeField, UseTree, Visibility, }, semantic, semantic::{declaration_registry::DeclarationRegistry, type_registry::TypeRegistry}, From c45a521ff79c8e9c434bd396ac1667ee25b248ca Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 09:04:39 +0200 Subject: [PATCH 02/10] Resolve doc links once during semantic analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ad-hoc string handling in the doc-link resolution path with a structured pipeline: - `DocLinkPath` parses link text exactly once (leading `Self` prefix + segments); `resolve()` and every consumer take the structured form. - `resolve_all` resolves every doc link in one crate-wide pass during semantic analysis, storing `ResolvedDocLink { text, target }` tables keyed by doc-block location, per module. Validation is this pass's failure path. The tables ride `SemanticAnalysis`/`SemanticOutput` so backends can consume targets instead of re-resolving. - The doc walk is now genuinely recursive: nested types' fields, associated functions, vftables, and further-nested items are visited (and validated) at any depth, with the enclosing type's augmented scope. - `Self` enclosing context now follows emitted-rustdoc reality: the type itself for type/enum/bitflags docs and their members, the parent type for nested constants/extern values (their docs land in the parent's impl block), and nothing for type aliases or module-level values — `[Self]` on a module-level constant is now rejected instead of validating and emitting a link rustdoc can't resolve. Behavior-preserving for the emitted corpus (byte-identical output). --- src/backends/rust/mod.rs | 16 +- src/semantic/doc_links.rs | 469 ++++++++++++++------------ src/semantic/ir.rs | 3 + src/semantic/output.rs | 26 +- src/semantic/queries/root.rs | 29 +- src/semantic/tests/doc_links.rs | 186 +++++++++- src/span/mod.rs | 4 +- tooling/lsp/src/handlers/doc_links.rs | 13 +- tooling/lsp/src/handlers/mod.rs | 2 +- 9 files changed, 492 insertions(+), 256 deletions(-) diff --git a/src/backends/rust/mod.rs b/src/backends/rust/mod.rs index 4fca7430..1895464d 100644 --- a/src/backends/rust/mod.rs +++ b/src/backends/rust/mod.rs @@ -128,16 +128,12 @@ pub fn write_module( // Compute doc link imports and nested item rewrites before rendering // module docs, so doc link references can be rewritten. let module_scope = module.scope(); - let doc_links = semantic_state.doc_link_resolver().module_doc_links( - semantic_state.type_registry(), - semantic_state.modules(), - key, - ); - let doc_imports = &doc_links.imports; + let doc_links = semantic_state.module_doc_links(key); + let doc_imports = doc_links.imports(); let module_path_set: BTreeSet = semantic_state.modules().keys().cloned().collect(); let mut cross_module_imports: Vec<&ItemPath> = Vec::new(); let mut same_module_aliases: Vec<(&ItemPath, String)> = Vec::new(); - for p in doc_imports { + for p in &doc_imports { let declaring_len = find_module_prefix_len(p, &module_path_set); let declaring_module: ItemPath = p.iter().take(declaring_len).cloned().collect(); if &declaring_module == key { @@ -258,9 +254,9 @@ pub fn write_module( // an inherent method when nested), so a doc link written against the value's // logical name won't resolve. Rewrite each such link's destination to the // accessor's Rust path, reusing the same accessor naming the emitter uses. - for (text, value_path) in &doc_links.extern_value_links { - let rust_path = extern_value_accessor_doc_path(value_path, key, &module_path_set, prefix); - nested_rewrites.insert(text.clone(), rust_path); + for (text, value_path) in doc_links.extern_value_links() { + let rust_path = extern_value_accessor_doc_path(&value_path, key, &module_path_set, prefix); + nested_rewrites.insert(text.to_string(), rust_path); } // Generate `use FlatName as LeafName;` aliases for same-module nested diff --git a/src/semantic/doc_links.rs b/src/semantic/doc_links.rs index a104f73a..ec1e5421 100644 --- a/src/semantic/doc_links.rs +++ b/src/semantic/doc_links.rs @@ -84,19 +84,85 @@ pub enum DocLinkMemberKind { ExternValue, } -/// The intra-doc links referenced across a module's documentation, gathered by -/// [`DocLinkResolver::module_doc_links`] for backend rewriting. -#[derive(Debug, Clone, Default)] +/// A parsed intra-doc link path: an optional leading `Self` plus the remaining +/// `::`-separated segments. +/// +/// This is the *only* place link text is split into segments — everything +/// downstream of [`DocLinkPath::parse`] works with the structured form, so the +/// resolution path never re-derives structure from strings. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DocLinkPath { + /// Whether the written path began with `Self` (bare `Self` or `Self::…`). + pub self_prefixed: bool, + /// The path segments after any `Self` prefix. + pub segments: Vec, +} + +impl DocLinkPath { + pub fn parse(text: &str) -> Self { + let mut parts = text.split("::").peekable(); + let self_prefixed = parts.peek() == Some(&"Self"); + if self_prefixed { + parts.next(); + } + DocLinkPath { + self_prefixed, + segments: parts.map(ItemPathSegment::from).collect(), + } + } +} + +/// A single doc link resolved to its target, alongside the exact path text +/// written in the source — the text a backend substitutes when rewriting the +/// link destination. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ResolvedDocLink { + pub text: String, + pub target: DocLinkTarget, +} + +/// Every resolved intra-doc link in one module's documentation, keyed by the +/// location of the doc-bearing node (module, item, field, function, variant, +/// flag, …). Produced once by [`resolve_all`] during semantic analysis; +/// backends look their links up here rather than re-scanning and re-resolving +/// doc text with locally-reconstructed context. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] pub struct ModuleDocLinks { - /// Absolute paths of every item/function/extern referenced by a link, to be - /// imported so rustdoc resolves them. - pub imports: BTreeSet, + pub by_location: BTreeMap>, +} + +impl ModuleDocLinks { + /// The resolved links of the doc block owned by the node at `location`. + pub fn at(&self, location: &ItemLocation) -> &[ResolvedDocLink] { + self.by_location + .get(location) + .map(Vec::as_slice) + .unwrap_or_default() + } + + /// Iterate every resolved link in the module. + pub fn iter(&self) -> impl Iterator { + self.by_location.values().flatten() + } + + /// Absolute paths of every item/function/extern referenced by a link — the + /// set the Rust backend imports so rustdoc resolves the links. + pub fn imports(&self) -> BTreeSet { + self.iter().map(|l| l.target.import_path()).collect() + } + /// `(written link text, extern-value item path)` for each link pointing at - /// an extern value. The backend rewrites the link destination to the emitted - /// `get_` accessor rather than the value's logical name. - pub extern_value_links: Vec<(String, ItemPath)>, + /// an extern value. The Rust backend rewrites these destinations to the + /// emitted `get_` accessor rather than the value's logical name. + pub fn extern_value_links(&self) -> impl Iterator { + self.iter() + .filter_map(|l| Some((l.text.as_str(), l.target.extern_value_path()?))) + } } +/// The resolved doc links of every module in the crate, keyed by module path. +pub type DocLinks = BTreeMap; + #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum ItemMembers { Type { @@ -286,29 +352,29 @@ impl DocLinkResolver { /// Resolve a written link path (e.g. `Action`, `Type::method`) against a /// module scope. Returns `None` if it doesn't resolve to anything. /// - /// `path` is the parsed `::`-separated segments of the link text. - /// `enclosing_type` is the path of the type/enum/bitflags whose definition - /// contains the doc comment — used to resolve `Self::` links. `None` at - /// module scope. + /// `enclosing_type` is the path of the item whose emitted docs will contain + /// the link — the type/enum/bitflags itself for its own and its members' + /// docs, or the *parent* type for a nested constant/extern value (their + /// docs land in the parent's `impl` block). It substitutes a `Self` prefix; + /// `None` at module scope, where `Self` doesn't resolve. pub fn resolve( &self, scope: &[ItemPath], - path: &[ItemPathSegment], + path: &DocLinkPath, enclosing_type: Option<&ItemPath>, ) -> Option { - // If the first segment is `Self`, replace it with the enclosing type's - // segments. Works entirely at the ItemPathSegment level. + // Substitute a `Self` prefix with the enclosing type's segments. let owned_segments; - let path = if path.first().is_some_and(|s| s.as_str() == "Self") { + let path: &[ItemPathSegment] = if path.self_prefixed { let enclosing = enclosing_type?; owned_segments = enclosing .iter() + .chain(path.segments.iter()) .cloned() - .chain(path[1..].iter().cloned()) .collect::>(); &owned_segments } else { - path + &path.segments }; // 1. The whole path as a type. A nested constant is skipped here so it @@ -358,7 +424,7 @@ impl DocLinkResolver { let module_segments = &path[..path.len() - 1]; let bases = std::iter::once(ItemPath::empty()).chain(scope.iter().cloned()); for base in bases { - let mut full = base.clone(); + let mut full = base; for seg in module_segments { full.push(seg.clone()); } @@ -468,181 +534,6 @@ impl DocLinkResolver { } } - /// Collect every intra-doc link referenced anywhere in `module_path`'s - /// documentation (its own doc, its items + their members, its functions) — - /// the item paths to import so rustdoc resolves them, plus the extern-value - /// links the Rust backend rewrites to `get_` accessors. See - /// [`ModuleDocLinks`]. - pub fn module_doc_links( - &self, - type_registry: &TypeRegistry, - modules: &BTreeMap, - module_path: &ItemPath, - ) -> ModuleDocLinks { - let mut links = ModuleDocLinks::default(); - let out = &mut links; - self.walk_module_docs( - type_registry, - modules, - module_path, - |doc, scope, enclosing, _| { - self.add_doc_imports(scope, enclosing, doc, out); - Ok(()) - }, - ) - .ok(); - links - } - - /// Walk every doc-comment-bearing location in `module_path` — the module's - /// own doc and functions, every item whose parent is `module_path`, and - /// every member/nested-item doc within those items. The closure receives - /// `(doc, scope, enclosing_type, location)` at each location. - /// - /// `enclosing_type` is `Some(path)` when inside a type/enum/bitflags - /// definition (enables `Self::` resolution) and `None` at module scope. - /// For nested items, the enclosing type is the nested item's own path — - /// `Self::` inside a nested enum's variant doc refers to the nested enum, - /// not the outer type — but the augmented `type_scope` (outer type's - /// scope) is still used for bare-name resolution of siblings. - fn walk_module_docs( - &self, - type_registry: &TypeRegistry, - modules: &BTreeMap, - module_path: &ItemPath, - mut visit: impl FnMut(&[String], &[ItemPath], Option<&ItemPath>, &ItemLocation) -> Result<()>, - ) -> Result<()> { - let Some(module) = modules.get(module_path) else { - return Ok(()); - }; - let scope = module.scope(); - - visit(module.doc(), &scope, None, module.location())?; - for f in module.functions() { - visit(&f.doc, &scope, None, &f.location)?; - } - - for (path, item) in type_registry.iter() { - if path.parent().as_ref() != Some(module_path) { - continue; - } - let Some(resolved) = item.resolved() else { - continue; - }; - let enclosing = Some(path); - match &resolved.inner { - ItemDefinitionInner::Type(td) => { - // Augment scope with the type's own path so bare - // references to nested items (e.g. [InnerEnum]) resolve. - let type_scope: Vec = std::iter::once(path.clone()) - .chain(scope.iter().cloned()) - .collect(); - visit(&td.doc, &type_scope, enclosing, &item.location)?; - for r in &td.regions { - visit(&r.doc, &type_scope, enclosing, &r.location)?; - } - for f in &td.associated_functions { - visit(&f.doc, &type_scope, enclosing, &f.location)?; - } - if let Some(v) = &td.vftable { - for f in &v.functions { - visit(&f.doc, &type_scope, enclosing, &f.location)?; - } - } - for nested_path in &td.nested_item_paths { - let Some(nested_item) = type_registry - .get(nested_path, &ItemLocation::internal()) - .ok() - else { - continue; - }; - let Some(nested_resolved) = nested_item.resolved() else { - continue; - }; - let nested_loc = &nested_item.location; - // Nested items use their own path as enclosing type — - // `Self::` inside a nested enum's variant doc refers to - // the nested enum, not the outer type. The `type_scope` - // (outer type's augmented scope) is still used for - // bare-name resolution of siblings. - let nested_enclosing = Some(nested_path); - match &nested_resolved.inner { - ItemDefinitionInner::Type(ntd) => { - visit(&ntd.doc, &type_scope, nested_enclosing, nested_loc)?; - } - ItemDefinitionInner::Enum(ned) => { - visit(&ned.doc, &type_scope, nested_enclosing, nested_loc)?; - for v in &ned.variants { - visit(&v.doc, &type_scope, nested_enclosing, &v.location)?; - } - for f in &ned.associated_functions { - visit(&f.doc, &type_scope, nested_enclosing, &f.location)?; - } - } - ItemDefinitionInner::Bitflags(nbd) => { - visit(&nbd.doc, &type_scope, nested_enclosing, nested_loc)?; - for f in &nbd.flags { - visit(&f.doc, &type_scope, nested_enclosing, &f.location)?; - } - } - ItemDefinitionInner::TypeAlias(nta) => { - visit(&nta.doc, &type_scope, nested_enclosing, nested_loc)?; - } - ItemDefinitionInner::Constant(ncd) => { - visit(&ncd.doc, &type_scope, nested_enclosing, nested_loc)?; - } - ItemDefinitionInner::ExternValue(nev) => { - visit(&nev.doc, &type_scope, nested_enclosing, nested_loc)?; - } - } - } - } - ItemDefinitionInner::Enum(ed) => { - visit(&ed.doc, &scope, enclosing, &item.location)?; - for v in &ed.variants { - visit(&v.doc, &scope, enclosing, &v.location)?; - } - for f in &ed.associated_functions { - visit(&f.doc, &scope, enclosing, &f.location)?; - } - } - ItemDefinitionInner::Bitflags(bd) => { - visit(&bd.doc, &scope, enclosing, &item.location)?; - for f in &bd.flags { - visit(&f.doc, &scope, enclosing, &f.location)?; - } - } - ItemDefinitionInner::TypeAlias(ta) => { - visit(&ta.doc, &scope, enclosing, &item.location)?; - } - ItemDefinitionInner::Constant(cd) => { - visit(&cd.doc, &scope, enclosing, &item.location)?; - } - ItemDefinitionInner::ExternValue(ev) => { - visit(&ev.doc, &scope, enclosing, &item.location)?; - } - } - } - Ok(()) - } - - fn add_doc_imports( - &self, - scope: &[ItemPath], - enclosing_type: Option<&ItemPath>, - doc: &[String], - links: &mut ModuleDocLinks, - ) { - for (text, segments) in extract_links(doc) { - if let Some(target) = self.resolve(scope, &segments, enclosing_type) { - links.imports.insert(target.import_path()); - if let Some(value_path) = target.extern_value_path() { - links.extern_value_links.push((text, value_path)); - } - } - } - } - fn find_member(&self, item_path: &ItemPath, member: &str) -> Option { match &self.items.get(item_path)?.members { ItemMembers::Type { @@ -704,30 +595,166 @@ impl DocLinkResolver { } } -/// Validate every doc comment's intra-doc links, erroring on the first that -/// doesn't resolve. -pub fn validate( +/// Resolve every intra-doc link in every doc comment across the crate, in one +/// pass. Returns the per-module location-keyed link tables that backends +/// consume, or the first link that fails to resolve as an error. +/// +/// This is the single point where doc links are resolved — validation is this +/// pass's failure path, and every downstream consumer (backends, LSP hover on +/// build results) reads the returned tables rather than re-resolving with +/// locally-reconstructed context. +pub fn resolve_all( resolver: &DocLinkResolver, type_registry: &TypeRegistry, modules: &BTreeMap, -) -> Result<()> { - for module_path in modules.keys() { - resolver.walk_module_docs( - type_registry, - modules, - module_path, - |doc, scope, enclosing, location| { - for (text, segments) in extract_links(doc) { - if resolver.resolve(scope, &segments, enclosing).is_none() { - return Err(SemanticError::DocLinkNotFound { - path: text, - location: *location, - }); - } +) -> Result { + let mut links: DocLinks = modules + .keys() + .map(|k| (k.clone(), ModuleDocLinks::default())) + .collect(); + + let mut record = |module_path: &ItemPath, + doc: &[String], + scope: &[ItemPath], + enclosing: Option<&ItemPath>, + location: &ItemLocation| + -> Result<()> { + for (text, path) in extract_links(doc) { + let Some(target) = resolver.resolve(scope, &path, enclosing) else { + return Err(SemanticError::DocLinkNotFound { + path: text, + location: *location, + }); + }; + links + .entry(module_path.clone()) + .or_default() + .by_location + .entry(*location) + .or_default() + .push(ResolvedDocLink { text, target }); + } + Ok(()) + }; + + let scopes: BTreeMap<&ItemPath, Vec> = modules + .iter() + .map(|(path, module)| (path, module.scope())) + .collect(); + + for (module_path, module) in modules { + let scope = &scopes[module_path]; + record(module_path, module.doc(), scope, None, module.location())?; + for f in module.functions() { + record(module_path, &f.doc, scope, None, &f.location)?; + } + } + + // Top-level items — those whose parent is a module. Items nested inside + // another item are reached by `walk_item_docs`' recursion instead, with + // the enclosing type's augmented scope. + for (path, item) in type_registry.iter() { + let Some(parent) = path.parent() else { + continue; + }; + let Some(scope) = scopes.get(&parent) else { + continue; + }; + walk_item_docs(type_registry, &parent, path, item, scope, None, &mut record)?; + } + + Ok(links) +} + +/// Walk the doc comments of one item — its own doc, its members' docs, and +/// (recursively) any nested items' — calling `record` with the scope and +/// `Self`-enclosing context each doc block resolves under. +/// +/// `enclosing_type` for a doc block is the item whose *emitted* docs will +/// contain it: the type/enum/bitflags itself for its own, its members', and +/// its associated functions' docs, and the parent type for a nested +/// constant/extern value (the Rust backend emits those inside the parent's +/// `impl` block, where rustdoc resolves `Self` as the parent). Type aliases +/// and module-level constants/extern values get `None` — `Self` has nothing +/// to refer to in their emitted docs. +/// +/// `parent_type` is the type this item is nested inside, `None` at module +/// level. +fn walk_item_docs( + type_registry: &TypeRegistry, + module_path: &ItemPath, + path: &ItemPath, + item: &crate::semantic::types::ItemDefinition, + scope: &[ItemPath], + parent_type: Option<&ItemPath>, + record: &mut F, +) -> Result<()> +where + F: FnMut(&ItemPath, &[String], &[ItemPath], Option<&ItemPath>, &ItemLocation) -> Result<()>, +{ + let Some(resolved) = item.resolved() else { + return Ok(()); + }; + let enclosing = Some(path); + match &resolved.inner { + ItemDefinitionInner::Type(td) => { + // Augment scope with the type's own path so bare references to + // nested items (e.g. [InnerEnum]) resolve. + let type_scope: Vec = std::iter::once(path.clone()) + .chain(scope.iter().cloned()) + .collect(); + record(module_path, &td.doc, &type_scope, enclosing, &item.location)?; + for r in &td.regions { + record(module_path, &r.doc, &type_scope, enclosing, &r.location)?; + } + for f in &td.associated_functions { + record(module_path, &f.doc, &type_scope, enclosing, &f.location)?; + } + if let Some(v) = &td.vftable { + for f in &v.functions { + record(module_path, &f.doc, &type_scope, enclosing, &f.location)?; } - Ok(()) - }, - )?; + } + for nested_path in &td.nested_item_paths { + let Ok(nested_item) = type_registry.get(nested_path, &ItemLocation::internal()) + else { + continue; + }; + walk_item_docs( + type_registry, + module_path, + nested_path, + nested_item, + &type_scope, + Some(path), + record, + )?; + } + } + ItemDefinitionInner::Enum(ed) => { + record(module_path, &ed.doc, scope, enclosing, &item.location)?; + for v in &ed.variants { + record(module_path, &v.doc, scope, enclosing, &v.location)?; + } + for f in &ed.associated_functions { + record(module_path, &f.doc, scope, enclosing, &f.location)?; + } + } + ItemDefinitionInner::Bitflags(bd) => { + record(module_path, &bd.doc, scope, enclosing, &item.location)?; + for f in &bd.flags { + record(module_path, &f.doc, scope, enclosing, &f.location)?; + } + } + ItemDefinitionInner::TypeAlias(ta) => { + record(module_path, &ta.doc, scope, None, &item.location)?; + } + ItemDefinitionInner::Constant(cd) => { + record(module_path, &cd.doc, scope, parent_type, &item.location)?; + } + ItemDefinitionInner::ExternValue(ev) => { + record(module_path, &ev.doc, scope, parent_type, &item.location)?; + } } Ok(()) } @@ -900,17 +927,17 @@ pub fn scan_links(text: &str) -> Vec { /// the inline (`[label](path)`) and code-shortcut (`` [`path`] ``) forms. Bare /// `[path]` shortcuts are intentionally excluded. /// -/// Returns `(original_text, parsed_segments)` for each link — the original -/// path text (for backend rewriting / extern-value link storage) and the -/// parsed `::`-separated segments (for `resolve()`). -pub fn extract_links(doc: &[String]) -> Vec<(String, Vec)> { +/// Returns `(original_text, parsed_path)` for each link — the original path +/// text exactly as written (what a backend substitutes when rewriting) and its +/// parsed [`DocLinkPath`] (what [`DocLinkResolver::resolve`] consumes). +pub fn extract_links(doc: &[String]) -> Vec<(String, DocLinkPath)> { let text = doc.join("\n"); scan_links(&text) .into_iter() .filter(|l| l.syntax != DocLinkSyntax::PlainShortcut) .map(|l| { - let segs = l.path.split("::").map(ItemPathSegment::from).collect(); - (l.path, segs) + let parsed = DocLinkPath::parse(&l.path); + (l.path, parsed) }) .collect() } diff --git a/src/semantic/ir.rs b/src/semantic/ir.rs index eb9fc60a..b11bf90a 100644 --- a/src/semantic/ir.rs +++ b/src/semantic/ir.rs @@ -90,6 +90,8 @@ pub struct SemanticAnalysis<'db> { #[returns(ref)] pub doc_link_resolver: Arc, #[returns(ref)] + pub doc_links: Arc, + #[returns(ref)] pub errors: Arc>, #[returns(ref)] pub parse_errors: Arc>, @@ -113,6 +115,7 @@ impl SemanticAnalysis<'_> { (**self.type_registry(db)).clone(), (**self.modules(db)).clone(), (**self.doc_link_resolver(db)).clone(), + (**self.doc_links(db)).clone(), )) } } diff --git a/src/semantic/output.rs b/src/semantic/output.rs index 669bf585..f07378da 100644 --- a/src/semantic/output.rs +++ b/src/semantic/output.rs @@ -9,16 +9,21 @@ use std::collections::BTreeMap; use crate::{ grammar::ItemPath, - semantic::{Module, doc_links::DocLinkResolver, type_registry::TypeRegistry}, + semantic::{ + Module, + doc_links::{DocLinkResolver, DocLinks, ModuleDocLinks}, + type_registry::TypeRegistry, + }, }; /// The output of semantic analysis, projected from `SemanticAnalysis`. -/// Backends take `&SemanticOutput` and read its three fields. +/// Backends take `&SemanticOutput` and read its fields. #[derive(Debug)] pub struct SemanticOutput { type_registry: TypeRegistry, modules: BTreeMap, doc_link_resolver: DocLinkResolver, + doc_links: DocLinks, } impl SemanticOutput { @@ -34,17 +39,34 @@ impl SemanticOutput { &self.doc_link_resolver } + /// The resolved intra-doc links of every module, produced once during + /// semantic analysis. Backends read link targets from here rather than + /// re-resolving doc text. + pub fn doc_links(&self) -> &DocLinks { + &self.doc_links + } + + /// The resolved doc links of one module. Every module present in + /// [`Self::modules`] has an entry (possibly empty). + pub fn module_doc_links(&self, module_path: &ItemPath) -> &ModuleDocLinks { + static EMPTY: std::sync::LazyLock = + std::sync::LazyLock::new(ModuleDocLinks::default); + self.doc_links.get(module_path).unwrap_or(&EMPTY) + } + /// Construct a `SemanticOutput` from its parts. /// Used by the Salsa query layer to project `SemanticAnalysis`. pub(crate) fn from_parts( type_registry: TypeRegistry, modules: BTreeMap, doc_link_resolver: DocLinkResolver, + doc_links: DocLinks, ) -> Self { Self { type_registry, modules, doc_link_resolver, + doc_links, } } } diff --git a/src/semantic/queries/root.rs b/src/semantic/queries/root.rs index 1e992349..b4390a0e 100644 --- a/src/semantic/queries/root.rs +++ b/src/semantic/queries/root.rs @@ -67,6 +67,7 @@ pub fn analyze<'db>( Arc::new(type_registry.clone()), Arc::new(modules.clone()), Arc::new(doc_link_resolver), + Arc::new(doc_links::DocLinks::new()), Arc::new(errors), Arc::new(vec![]), ) @@ -78,6 +79,7 @@ pub fn analyze<'db>( let finish = |type_registry: TypeRegistry, modules: BTreeMap, doc_link_resolver: doc_links::DocLinkResolver, + doc_links: doc_links::DocLinks, errors: Vec| -> SemanticAnalysis<'db> { SemanticAnalysis::new( @@ -85,6 +87,7 @@ pub fn analyze<'db>( Arc::new(type_registry), Arc::new(modules), Arc::new(doc_link_resolver), + Arc::new(doc_links), Arc::new(errors), Arc::new(vec![]), ) @@ -114,6 +117,7 @@ pub fn analyze<'db>( &TypeRegistry::new(pointer_size), &BTreeMap::new(), )), + Arc::new(doc_links::DocLinks::new()), Arc::new(vec![]), Arc::new(parse_errors), ); @@ -364,16 +368,29 @@ pub fn analyze<'db>( let af_errors = merge_associated_functions(&mut type_registry, &modules); if !af_errors.is_empty() { let doc_link_resolver = doc_links::DocLinkResolver::build(&type_registry, &modules); - return finish(type_registry, modules, doc_link_resolver, af_errors); + return finish( + type_registry, + modules, + doc_link_resolver, + doc_links::DocLinks::new(), + af_errors, + ); } - // Doc link resolution (now with associated functions in the registry) + // Doc link resolution (now with associated functions in the registry). + // This is the single pass that resolves every intra-doc link; validation + // is its failure path, and the resulting tables are what backends consume. let doc_link_resolver = doc_links::DocLinkResolver::build(&type_registry, &modules); - if let Err(e) = doc_links::validate(&doc_link_resolver, &type_registry, &modules) { - return finish(type_registry, modules, doc_link_resolver, vec![e]); + match doc_links::resolve_all(&doc_link_resolver, &type_registry, &modules) { + Ok(resolved) => finish(type_registry, modules, doc_link_resolver, resolved, vec![]), + Err(e) => finish( + type_registry, + modules, + doc_link_resolver, + doc_links::DocLinks::new(), + vec![e], + ), } - - finish(type_registry, modules, doc_link_resolver, vec![]) } /// Compute associated functions (own impl methods + inherited from base types) diff --git a/src/semantic/tests/doc_links.rs b/src/semantic/tests/doc_links.rs index d8ca03db..99d078a3 100644 --- a/src/semantic/tests/doc_links.rs +++ b/src/semantic/tests/doc_links.rs @@ -11,11 +11,9 @@ use crate::{ use super::util::*; use pretty_assertions::assert_eq; -/// Parse a `::`-separated path into segments for `resolve()`. -fn segs(s: &str) -> Vec { - s.split("::") - .map(crate::grammar::ItemPathSegment::from) - .collect() +/// Parse a written link path for `resolve()`. +fn segs(s: &str) -> crate::semantic::doc_links::DocLinkPath { + crate::semantic::doc_links::DocLinkPath::parse(s) } #[test] @@ -520,3 +518,181 @@ fn validate_rejects_invalid_link_in_nested_item() { "unexpected error: {err:?}" ); } + +// --- DocLinkPath parsing --- + +#[test] +fn parses_doc_link_paths() { + use crate::{grammar::ItemPathSegment, semantic::doc_links::DocLinkPath}; + let seg = ItemPathSegment::from; + + assert_eq!( + DocLinkPath::parse("Foo"), + DocLinkPath { + self_prefixed: false, + segments: vec![seg("Foo")], + } + ); + assert_eq!( + DocLinkPath::parse("a::b::c"), + DocLinkPath { + self_prefixed: false, + segments: vec![seg("a"), seg("b"), seg("c")], + } + ); + assert_eq!( + DocLinkPath::parse("Self"), + DocLinkPath { + self_prefixed: true, + segments: vec![], + } + ); + assert_eq!( + DocLinkPath::parse("Self::member"), + DocLinkPath { + self_prefixed: true, + segments: vec![seg("member")], + } + ); + // `Self` only counts as a prefix in leading position. + assert_eq!( + DocLinkPath::parse("Foo::Self"), + DocLinkPath { + self_prefixed: false, + segments: vec![seg("Foo"), seg("Self")], + } + ); +} + +// --- Enclosing-type semantics of the doc walk --- + +#[test] +fn resolves_self_in_nested_constant_doc_as_parent() { + // A constant nested in a type emits as an associated const inside the + // parent's `impl` block, where rustdoc resolves `Self` as the parent type — + // so `Self::health` in its doc must resolve to the parent's field. + let module = M::new().with_definitions([ID::new( + (V::Public, "Player"), + TD::new([ + TS::item( + ID::new( + (V::Public, "STARTING_GOLD"), + CD::new(T::ident("u32"), int_literal(500)), + ) + .with_doc_comments(vec![" Initial value of [`Self::health`].".to_string()]), + ), + TS::field((V::Public, "health"), T::ident("i32")).with_attributes([A::address(0)]), + ]) + .with_attributes([A::size(4), A::align(4)]), + )]); + + let state = build_state(&module, &IP::from("test")).unwrap(); + let module_links = state.module_doc_links(&IP::from("test")); + let link = module_links + .iter() + .find(|l| l.text == "Self::health") + .expect("Self::health link recorded"); + assert_eq!( + link.target, + DocLinkTarget::Member { + item: IP::from("test::Player"), + name: "health".to_string(), + kind: DocLinkMemberKind::Field, + } + ); +} + +#[test] +fn validate_rejects_self_on_module_level_constant() { + // A module-level constant's emitted docs have no `Self` to refer to, so a + // `Self` link in them must fail validation rather than silently producing + // a link rustdoc can't resolve. + let module = M::new().with_definitions([ID::new( + (V::Public, "MAX_PLAYERS"), + CD::new(T::ident("u32"), int_literal(8)), + ) + .with_doc_comments(vec![" See [`Self`].".to_string()])]); + + let err = build_state(&module, &IP::from("test")).unwrap_err(); + assert!( + matches!(&err, SemanticError::DocLinkNotFound { path, .. } if path == "Self"), + "unexpected error: {err:?}" + ); +} + +#[test] +fn validate_rejects_self_on_type_alias() { + let module = + M::new().with_definitions([ID::new((V::Public, "Handle"), TAD::new(T::ident("u32"))) + .with_doc_comments(vec![" See [`Self`].".to_string()])]); + + let err = build_state(&module, &IP::from("test")).unwrap_err(); + assert!( + matches!(&err, SemanticError::DocLinkNotFound { path, .. } if path == "Self"), + "unexpected error: {err:?}" + ); +} + +#[test] +fn validate_rejects_invalid_link_in_nested_type_field_doc() { + // Docs on a *nested type's* fields are part of the walk too — a bad link + // there must fail validation just like one on a top-level type's field. + let module = M::new().with_definitions([ID::new( + (V::Public, "Outer"), + TD::new([ + TS::item(ID::new( + (V::Public, "Inner"), + TD::new([TS::field((V::Public, "inner_field"), T::ident("u32")) + .with_attributes([A::address(0)]) + .with_doc_comments(vec![" See [`Nonexistent`].".to_string()])]) + .with_attributes([A::size(4), A::align(4)]), + )), + TS::field((V::Public, "outer_field"), T::ident("u32")).with_attributes([A::address(0)]), + ]) + .with_attributes([A::size(8), A::align(4)]), + )]); + + let err = build_state(&module, &IP::from("test")).unwrap_err(); + assert!( + matches!(&err, SemanticError::DocLinkNotFound { path, .. } if path == "Nonexistent"), + "unexpected error: {err:?}" + ); +} + +#[test] +fn resolves_self_in_nested_type_field_doc() { + // `Self::` in a nested type's field doc refers to the nested type itself. + let module = M::new().with_definitions([ID::new( + (V::Public, "Outer"), + TD::new([ + TS::item(ID::new( + (V::Public, "Inner"), + TD::new([ + TS::field((V::Public, "first"), T::ident("u32")) + .with_attributes([A::address(0)]) + .with_doc_comments(vec![" Pairs with [`Self::second`].".to_string()]), + TS::field((V::Public, "second"), T::ident("u32")) + .with_attributes([A::address(4)]), + ]) + .with_attributes([A::size(8), A::align(4)]), + )), + TS::field((V::Public, "outer_field"), T::ident("u32")).with_attributes([A::address(0)]), + ]) + .with_attributes([A::size(12), A::align(4)]), + )]); + + let state = build_state(&module, &IP::from("test")).unwrap(); + let link = state + .module_doc_links(&IP::from("test")) + .iter() + .find(|l| l.text == "Self::second") + .expect("Self::second link recorded"); + assert_eq!( + link.target, + DocLinkTarget::Member { + item: IP::from("test::Outer::Inner"), + name: "second".to_string(), + kind: DocLinkMemberKind::Field, + } + ); +} diff --git a/src/span/mod.rs b/src/span/mod.rs index 8c1a5f13..d74ff269 100644 --- a/src/span/mod.rs +++ b/src/span/mod.rs @@ -31,7 +31,7 @@ impl std::fmt::Display for Location { } /// A span representing a range in source code -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Span { /// Start location (inclusive) pub start: Location, @@ -123,7 +123,7 @@ impl std::fmt::Display for FileId { /// Location of an item in source code (file ID + span) /// Every grammar and semantic item should have an ItemLocation for error reporting -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ItemLocation { /// Source file containing the item pub file_id: FileId, diff --git a/tooling/lsp/src/handlers/doc_links.rs b/tooling/lsp/src/handlers/doc_links.rs index a65ad807..d203f750 100644 --- a/tooling/lsp/src/handlers/doc_links.rs +++ b/tooling/lsp/src/handlers/doc_links.rs @@ -1,11 +1,6 @@ use super::*; -use pyxis::semantic::doc_links::{DocLinkSyntax, DocLinkTarget, ScannedLink}; - -/// Parse a `::`-separated path string into segments for `resolve()`. -fn parse_segments(path: &str) -> Vec { - path.split("::").map(ItemPathSegment::from).collect() -} +use pyxis::semantic::doc_links::{DocLinkPath, DocLinkSyntax, DocLinkTarget, ScannedLink}; impl ServerState { /// Doc-comment links that reference `symbol`, returning the span of the @@ -71,7 +66,7 @@ impl ServerState { }; for dl in scan_doc_links(line) { if !resolver - .resolve(&scope, &parse_segments(&dl.path), None) + .resolve(&scope, &DocLinkPath::parse(&dl.path), None) .is_some_and(|t| matches(&t)) { continue; @@ -159,7 +154,7 @@ impl ServerState { for dl in scan_doc_links(line) { // Target file + 1-based line to anchor the link at, and a tooltip. let Some((target_uri, target_line, tooltip)) = - (match resolver.resolve(&scope, &parse_segments(&dl.path), None) { + (match resolver.resolve(&scope, &DocLinkPath::parse(&dl.path), None) { Some(DocLinkTarget::Item(p)) => self .resolved_definition(&p, type_registry, uri) .map(|rd| (rd.uri, rd.name_span.start.line, p.to_string())), @@ -447,7 +442,7 @@ impl ServerState { Location::new(loc.line, dl.link.1 + 1), ); let (location, hover) = - match resolver.resolve(&scope, &parse_segments(&dl.path), None)? { + match resolver.resolve(&scope, &DocLinkPath::parse(&dl.path), None)? { DocLinkTarget::Item(p) => to_type(self, &p)?, DocLinkTarget::Member { item, name, .. } => { match self.resolve_doc_member(&item, &name, uri) { diff --git a/tooling/lsp/src/handlers/mod.rs b/tooling/lsp/src/handlers/mod.rs index d5b07902..1b1fc1e9 100644 --- a/tooling/lsp/src/handlers/mod.rs +++ b/tooling/lsp/src/handlers/mod.rs @@ -16,7 +16,7 @@ pub(crate) use lsp_types::{ pub(crate) use pyxis::{ grammar::{ Attribute, Attributes, BitflagsDefItem, EnumDefItem, Function, ItemDefinition, ItemPath, - ItemPathSegment, Module, ModuleItem, Type, TypeDefItem, TypeField, UseTree, Visibility, + Module, ModuleItem, Type, TypeDefItem, TypeField, UseTree, Visibility, }, semantic, semantic::{declaration_registry::DeclarationRegistry, type_registry::TypeRegistry}, From aa80c14e8f8bb5f3cb900e854b580726254d0bb1 Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 09:11:52 +0200 Subject: [PATCH 03/10] Surface Self:: doc links in JSON output via the resolved-link tables The JSON backend re-resolved doc links itself with no enclosing-type context, silently dropping every `Self::` link from `doc_links` (the viewer rendered them as dead links). `DocCx` now looks links up in the location-keyed tables produced during semantic analysis, so the JSON output cannot diverge from what the compiler validated. Link tables are keyed by a `DocBlockKey` rather than a raw location: a module's `location()` is a proxy borrowed from its first item, so keying the module's own doc block by it collided with that item's doc block (the module page showed the item's links). The module doc block gets a dedicated key variant instead, exercised by new module-level doc links in the doc_self_links corpus input. --- codegen_tests/input/doc_self_links.pyxis | 4 + codegen_tests/output/json/output.json | 54 +++++++-- codegen_tests/output/rust/doc_self_links.rs | 3 + src/backends/json.rs | 120 +++++++++++++------- src/semantic/doc_links.rs | 86 +++++++++++--- 5 files changed, 205 insertions(+), 62 deletions(-) diff --git a/codegen_tests/input/doc_self_links.pyxis b/codegen_tests/input/doc_self_links.pyxis index 67b1685c..00ebca5b 100644 --- a/codegen_tests/input/doc_self_links.pyxis +++ b/codegen_tests/input/doc_self_links.pyxis @@ -1,3 +1,7 @@ +//! Self-link testing module. Its own doc links [`Container`] — the module's +//! doc block must keep its links separate from its first item's, since the +//! module's source location is a proxy borrowed from that item. + /// Test `Self::` links in type docs. /// /// Link to a field as [`Self::field`](Self::field), to a method as diff --git a/codegen_tests/output/json/output.json b/codegen_tests/output/json/output.json index 1007d852..bf026829 100644 --- a/codegen_tests/output/json/output.json +++ b/codegen_tests/output/json/output.json @@ -3104,6 +3104,31 @@ "kind": { "type": "type", "doc": " Test `Self::` links in type docs.\n\n Link to a field as [`Self::field`](Self::field), to a method as\n [`Self::method`](Self::method), to a nested type as\n [`Self::Nested`](Self::Nested), and to a nested type's member as\n [`Self::Nested::nested_field`](Self::Nested::nested_field).", + "doc_links": [ + { + "text": "Self::field", + "target_kind": "item", + "path": "doc_self_links::Container", + "anchor": "field-field" + }, + { + "text": "Self::method", + "target_kind": "item", + "path": "doc_self_links::Container", + "anchor": "func-method" + }, + { + "text": "Self::Nested", + "target_kind": "item", + "path": "doc_self_links::Container::Nested" + }, + { + "text": "Self::Nested::nested_field", + "target_kind": "item", + "path": "doc_self_links::Container::Nested", + "anchor": "field-nested_field" + } + ], "fields": [ { "visibility": "public", @@ -3119,7 +3144,7 @@ "is_base": false, "source": { "file_index": 12, - "line": 14 + "line": 18 } } ], @@ -3141,7 +3166,7 @@ "calling_convention": "system", "source": { "file_index": 12, - "line": 19 + "line": 23 } } ], @@ -3158,7 +3183,7 @@ }, "source": { "file_index": 12, - "line": 7 + "line": 11 } }, "doc_self_links::Container::Nested": { @@ -3170,6 +3195,14 @@ "kind": { "type": "type", "doc": " A nested type. Its field is [`Self::nested_field`](Self::nested_field).", + "doc_links": [ + { + "text": "Self::nested_field", + "target_kind": "item", + "path": "doc_self_links::Container::Nested", + "anchor": "field-nested_field" + } + ], "fields": [ { "visibility": "public", @@ -3185,7 +3218,7 @@ "is_base": false, "source": { "file_index": 12, - "line": 10 + "line": 14 } } ], @@ -3200,7 +3233,7 @@ }, "source": { "file_index": 12, - "line": 9 + "line": 13 } }, "enums::TestEnum": { @@ -10726,7 +10759,14 @@ } }, "doc_self_links": { - "doc": null, + "doc": " Self-link testing module. Its own doc links [`Container`] — the module's\n doc block must keep its links separate from its first item's, since the\n module's source location is a proxy borrowed from that item.", + "doc_links": [ + { + "text": "Container", + "target_kind": "item", + "path": "doc_self_links::Container" + } + ], "items": [ "doc_self_links::Container" ], @@ -10735,7 +10775,7 @@ "splices": [], "source": { "file_index": 12, - "line": 1 + "line": 5 } }, "enums": { diff --git a/codegen_tests/output/rust/doc_self_links.rs b/codegen_tests/output/rust/doc_self_links.rs index 4a396a2f..652d16ad 100644 --- a/codegen_tests/output/rust/doc_self_links.rs +++ b/codegen_tests/output/rust/doc_self_links.rs @@ -1,4 +1,7 @@ #![cfg_attr(any(), rustfmt::skip)] +//! Self-link testing module. Its own doc links [`Container`] — the module's +//! doc block must keep its links separate from its first item's, since the +//! module's source location is a proxy borrowed from that item. #[allow(unused_imports)] use Container_Nested as Nested; #[repr(C, align(4))] diff --git a/src/backends/json.rs b/src/backends/json.rs index 750d8e36..86277501 100644 --- a/src/backends/json.rs +++ b/src/backends/json.rs @@ -260,24 +260,45 @@ impl JsonDocLink { } } -/// Context for resolving doc-comment links during conversion: the shared -/// resolver plus the scope of the module currently being converted. +/// Context for surfacing doc-comment links during conversion: the module's +/// resolved link table, produced once during semantic analysis. Links are +/// looked up by the doc-bearing node's location — conversion never re-scans +/// or re-resolves doc text, so it cannot diverge from what the compiler +/// validated. struct DocCx<'a> { - resolver: &'a crate::semantic::doc_links::DocLinkResolver, - scope: Vec, + links: &'a crate::semantic::doc_links::ModuleDocLinks, } impl DocCx<'_> { - /// Convert a doc comment into its markdown text and resolved links. - fn convert(&self, doc: &[String]) -> (Option, Vec) { + /// Convert a doc comment into its markdown text and the resolved links of + /// the doc block owned by the node at `location`. + fn convert( + &self, + doc: &[String], + location: &crate::span::ItemLocation, + ) -> (Option, Vec) { + Self::convert_resolved(doc, self.links.at(location)) + } + + /// Convert the module's own doc block (keyed separately from node docs — + /// see [`crate::semantic::doc_links::DocBlockKey`]). + fn convert_module_doc(&self, doc: &[String]) -> (Option, Vec) { + Self::convert_resolved(doc, self.links.module_doc()) + } + + fn convert_resolved( + doc: &[String], + resolved: &[crate::semantic::doc_links::ResolvedDocLink], + ) -> (Option, Vec) { let mut links: Vec = Vec::new(); - for (text, segments) in crate::semantic::doc_links::extract_links(doc) { - if links.iter().any(|l| l.text == text) { + for link in resolved { + if links.iter().any(|l| l.text == link.text) { continue; } - if let Some(target) = self.resolver.resolve(&self.scope, &segments, None) { - links.push(JsonDocLink::from_target(text, target)); - } + links.push(JsonDocLink::from_target( + link.text.clone(), + link.target.clone(), + )); } (doc_to_option(doc), links) } @@ -756,11 +777,10 @@ pub fn build( // predicate attached as structured data so downstream tooling can // render or filter per their own rules. let mut items = BTreeMap::new(); - for module in semantic_state.modules().values() { + for (module_path, module) in semantic_state.modules() { let bindings: BTreeMap<&str, ExternBindings> = module.extern_bindings().collect(); let cx = DocCx { - resolver: semantic_state.doc_link_resolver(), - scope: module.scope(), + links: semantic_state.module_doc_links(module_path), }; for definition in module.definitions(type_registry) { let binding = definition @@ -779,9 +799,9 @@ pub fn build( // module, so the loop above never emits them. Add them here so the viewer // can render the builtin types user code references. They carry no source // location (see `ItemDefinition::default`), and are public. + let predefined_links = Default::default(); let predefined_cx = DocCx { - resolver: semantic_state.doc_link_resolver(), - scope: Vec::new(), + links: &predefined_links, }; for (path, item) in type_registry.iter() { if item.category != ItemCategory::Predefined { @@ -901,7 +921,7 @@ fn convert_function_body(body: &FunctionBody) -> JsonFunctionBody { } fn convert_function(func: &Function, cx: &DocCx) -> JsonFunction { - let (doc, doc_links) = cx.convert(&func.doc); + let (doc, doc_links) = cx.convert(&func.doc, &func.location); JsonFunction { visibility: func.visibility.into(), name: func.name.clone(), @@ -947,7 +967,7 @@ fn convert_region( ) -> JsonRegion { let size = region.type_ref.size(type_registry).unwrap_or(0); let alignment = region.type_ref.alignment(type_registry).unwrap_or(1); - let (doc, doc_links) = cx.convert(®ion.doc); + let (doc, doc_links) = cx.convert(®ion.doc, ®ion.location); JsonRegion { visibility: region.visibility.into(), @@ -977,6 +997,7 @@ fn convert_type_definition( td: &TypeDefinition, type_registry: &TypeRegistry, cx: &DocCx, + item_location: &crate::span::ItemLocation, ) -> JsonTypeDefinition { // Calculate field offsets let mut current_offset = 0; @@ -990,7 +1011,7 @@ fn convert_type_definition( }) .collect(); - let (doc, doc_links) = cx.convert(&td.doc); + let (doc, doc_links) = cx.convert(&td.doc, item_location); JsonTypeDefinition { doc, doc_links, @@ -1012,7 +1033,7 @@ fn convert_type_definition( } fn convert_enum_variant(variant: &EnumVariant, cx: &DocCx) -> JsonEnumVariant { - let (doc, doc_links) = cx.convert(&variant.doc); + let (doc, doc_links) = cx.convert(&variant.doc, &variant.location); JsonEnumVariant { name: variant.name.clone(), value: variant.value, @@ -1027,8 +1048,9 @@ fn convert_enum_definition( type_registry: &TypeRegistry, parent_path: &ItemPath, cx: &DocCx, + item_location: &crate::span::ItemLocation, ) -> JsonEnumDefinition { - let (doc, doc_links) = cx.convert(&ed.doc); + let (doc, doc_links) = cx.convert(&ed.doc, item_location); let nested_items: Vec = type_registry .iter() .filter(|(p, _)| p.parent().as_ref() == Some(parent_path)) @@ -1058,7 +1080,7 @@ fn convert_enum_definition( } fn convert_bitflag_field(flag: &BitflagField, cx: &DocCx) -> JsonBitflag { - let (doc, doc_links) = cx.convert(&flag.doc); + let (doc, doc_links) = cx.convert(&flag.doc, &flag.location); JsonBitflag { name: flag.name.clone(), value: flag.value, @@ -1073,8 +1095,9 @@ fn convert_bitflags_definition( type_registry: &TypeRegistry, parent_path: &ItemPath, cx: &DocCx, + item_location: &crate::span::ItemLocation, ) -> JsonBitflagsDefinition { - let (doc, doc_links) = cx.convert(&bd.doc); + let (doc, doc_links) = cx.convert(&bd.doc, item_location); let nested_items: Vec = type_registry .iter() .filter(|(p, _)| p.parent().as_ref() == Some(parent_path)) @@ -1098,8 +1121,12 @@ fn convert_bitflags_definition( } } -fn convert_type_alias_definition(ta: &TypeAliasDefinition, cx: &DocCx) -> JsonTypeAliasDefinition { - let (doc, doc_links) = cx.convert(&ta.doc); +fn convert_type_alias_definition( + ta: &TypeAliasDefinition, + cx: &DocCx, + item_location: &crate::span::ItemLocation, +) -> JsonTypeAliasDefinition { + let (doc, doc_links) = cx.convert(&ta.doc, item_location); JsonTypeAliasDefinition { doc, doc_links, @@ -1107,8 +1134,12 @@ fn convert_type_alias_definition(ta: &TypeAliasDefinition, cx: &DocCx) -> JsonTy } } -fn convert_const_definition(cd: &SemanticConstDefinition, cx: &DocCx) -> JsonConstantDefinition { - let (doc, doc_links) = cx.convert(&cd.doc); +fn convert_const_definition( + cd: &SemanticConstDefinition, + cx: &DocCx, + item_location: &crate::span::ItemLocation, +) -> JsonConstantDefinition { + let (doc, doc_links) = cx.convert(&cd.doc, item_location); let value = convert_const_value(&cd.value, cx); JsonConstantDefinition { doc, @@ -1162,23 +1193,30 @@ fn convert_item( let kind = match &resolved.inner { - ItemDefinitionInner::Type(td) => { - JsonItemKind::Type(convert_type_definition(td, type_registry, cx)) - } - ItemDefinitionInner::Enum(ed) => { - JsonItemKind::Enum(convert_enum_definition(ed, type_registry, &item.path, cx)) - } + ItemDefinitionInner::Type(td) => JsonItemKind::Type(convert_type_definition( + td, + type_registry, + cx, + &item.location, + )), + ItemDefinitionInner::Enum(ed) => JsonItemKind::Enum(convert_enum_definition( + ed, + type_registry, + &item.path, + cx, + &item.location, + )), ItemDefinitionInner::Bitflags(bd) => JsonItemKind::Bitflags( - convert_bitflags_definition(bd, type_registry, &item.path, cx), + convert_bitflags_definition(bd, type_registry, &item.path, cx, &item.location), ), ItemDefinitionInner::TypeAlias(ta) => { - JsonItemKind::TypeAlias(convert_type_alias_definition(ta, cx)) + JsonItemKind::TypeAlias(convert_type_alias_definition(ta, cx, &item.location)) } ItemDefinitionInner::Constant(cd) => { - JsonItemKind::Constant(convert_const_definition(cd, cx)) + JsonItemKind::Constant(convert_const_definition(cd, cx, &item.location)) } ItemDefinitionInner::ExternValue(ev) => { - JsonItemKind::ExternValue(convert_extern_value_definition(ev, cx)) + JsonItemKind::ExternValue(convert_extern_value_definition(ev, cx, &item.location)) } }; @@ -1204,8 +1242,9 @@ fn convert_item( fn convert_extern_value_definition( ev: &SemanticExternValueDefinition, cx: &DocCx, + item_location: &crate::span::ItemLocation, ) -> JsonExternValueDefinition { - let (doc, doc_links) = cx.convert(&ev.doc); + let (doc, doc_links) = cx.convert(&ev.doc, item_location); JsonExternValueDefinition { doc, doc_links, @@ -1240,8 +1279,7 @@ fn build_module_hierarchy(semantic_state: &SemanticOutput) -> BTreeMap BTreeMap = module.splices.iter().map(convert_splice).collect(); - let (doc, doc_links) = cx.convert(module.doc()); + let (doc, doc_links) = cx.convert_module_doc(module.doc()); let json_module = JsonModule { doc, doc_links, diff --git a/src/semantic/doc_links.rs b/src/semantic/doc_links.rs index ec1e5421..3e1035c4 100644 --- a/src/semantic/doc_links.rs +++ b/src/semantic/doc_links.rs @@ -121,28 +121,52 @@ pub struct ResolvedDocLink { pub target: DocLinkTarget, } +/// Identity of one doc block within a module, for keying resolved links. +/// +/// The module's own (`//!`-style) doc block gets a dedicated variant rather +/// than being keyed by `Module::location()`: that location is a *proxy* +/// borrowed from the module's first item (see `Module::from_ast`), so using +/// it as a key would collide with that item's own doc block. Every other +/// doc-bearing node is keyed by its source location, which is unique — no two +/// distinct nodes share an identical full span. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DocBlockKey { + /// The module's own doc block. + Module, + /// A doc-bearing node (item, field, function, variant, flag, …) at this + /// source location. + Node(ItemLocation), +} + /// Every resolved intra-doc link in one module's documentation, keyed by the -/// location of the doc-bearing node (module, item, field, function, variant, -/// flag, …). Produced once by [`resolve_all`] during semantic analysis; -/// backends look their links up here rather than re-scanning and re-resolving -/// doc text with locally-reconstructed context. +/// owning doc block. Produced once by [`resolve_all`] during semantic +/// analysis; backends look their links up here rather than re-scanning and +/// re-resolving doc text with locally-reconstructed context. #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] pub struct ModuleDocLinks { - pub by_location: BTreeMap>, + pub by_block: BTreeMap>, } impl ModuleDocLinks { /// The resolved links of the doc block owned by the node at `location`. pub fn at(&self, location: &ItemLocation) -> &[ResolvedDocLink] { - self.by_location - .get(location) + self.by_block + .get(&DocBlockKey::Node(*location)) + .map(Vec::as_slice) + .unwrap_or_default() + } + + /// The resolved links of the module's own doc block. + pub fn module_doc(&self) -> &[ResolvedDocLink] { + self.by_block + .get(&DocBlockKey::Module) .map(Vec::as_slice) .unwrap_or_default() } /// Iterate every resolved link in the module. pub fn iter(&self) -> impl Iterator { - self.by_location.values().flatten() + self.by_block.values().flatten() } /// Absolute paths of every item/function/extern referenced by a link — the @@ -613,10 +637,14 @@ pub fn resolve_all( .map(|k| (k.clone(), ModuleDocLinks::default())) .collect(); + // `key` identifies the doc block in the output table; `location` anchors + // any resolution error (for the module's own doc block, its proxy + // location — see [`DocBlockKey`]). let mut record = |module_path: &ItemPath, doc: &[String], scope: &[ItemPath], enclosing: Option<&ItemPath>, + key: DocBlockKey, location: &ItemLocation| -> Result<()> { for (text, path) in extract_links(doc) { @@ -629,14 +657,13 @@ pub fn resolve_all( links .entry(module_path.clone()) .or_default() - .by_location - .entry(*location) + .by_block + .entry(key) .or_default() .push(ResolvedDocLink { text, target }); } Ok(()) }; - let scopes: BTreeMap<&ItemPath, Vec> = modules .iter() .map(|(path, module)| (path, module.scope())) @@ -644,9 +671,23 @@ pub fn resolve_all( for (module_path, module) in modules { let scope = &scopes[module_path]; - record(module_path, module.doc(), scope, None, module.location())?; + record( + module_path, + module.doc(), + scope, + None, + DocBlockKey::Module, + module.location(), + )?; for f in module.functions() { - record(module_path, &f.doc, scope, None, &f.location)?; + record( + module_path, + &f.doc, + scope, + None, + DocBlockKey::Node(f.location), + &f.location, + )?; } } @@ -660,7 +701,24 @@ pub fn resolve_all( let Some(scope) = scopes.get(&parent) else { continue; }; - walk_item_docs(type_registry, &parent, path, item, scope, None, &mut record)?; + walk_item_docs( + type_registry, + &parent, + path, + item, + scope, + None, + &mut |module_path, doc, scope, enclosing, location| { + record( + module_path, + doc, + scope, + enclosing, + DocBlockKey::Node(*location), + location, + ) + }, + )?; } Ok(links) From c1258d309fba652c058588cd3c457d5ee38040d5 Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 09:18:58 +0200 Subject: [PATCH 04/10] Rewrite Rust doc-link destinations from resolved targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the string-map link rewriting (leaf-name keyed nested_rewrites, exact-text cross-module matches, doc-driven `use` imports and `use Flat as Leaf` aliases) with target rendering: each link's resolved target — from the semantic link tables — is rendered as an absolute Rust path, flattening nested items and substituting extern-value accessors. This removes two latent failure modes of the string approach: leaf-name collisions (two types with same-named nested items shared one rewrite key, and any matching segment in an unrelated link was rewritten) and cross-module links written in non-canonical form matching no rewrite key and emitting broken rustdoc. It also rewrites docs that previously got no treatment at all (module functions, constants), and code shortcuts now become inline links so their written label survives instead of being mangled to the flattened name. The generated root allows rustdoc::redundant_explicit_links: rewriting is uniform, and knowing when rustdoc could have resolved the label alone would mean re-implementing rustdoc's resolution. --- codegen_tests/output/rust/consts.rs | 13 +- codegen_tests/output/rust/doc_self_links.rs | 14 +- codegen_tests/output/rust/extern_values.rs | 2 +- codegen_tests/output/rust/lib.rs | 3 +- codegen_tests/output/rust/nested_items.rs | 30 +- src/backends/rust/mod.rs | 401 ++++++++++---------- 6 files changed, 223 insertions(+), 240 deletions(-) diff --git a/codegen_tests/output/rust/consts.rs b/codegen_tests/output/rust/consts.rs index 3c1d68cf..266c51ad 100644 --- a/codegen_tests/output/rust/consts.rs +++ b/codegen_tests/output/rust/consts.rs @@ -1,8 +1,7 @@ #![cfg_attr(any(), rustfmt::skip)] -#[allow(unused_imports)] -use Player_Inventory as Inventory; crate::__bitflags! { - #[doc = " The permissive baseline is [`DEFAULT_MASK`](AccessFlags::DEFAULT_MASK)."] + #[doc = + " The permissive baseline is [`DEFAULT_MASK`](crate::consts::AccessFlags::DEFAULT_MASK)."] pub struct AccessFlags : u32 { const READ = 1usize as _; const WRITE = 2usize as _; } } fn _AccessFlags_size_check() { @@ -16,7 +15,7 @@ impl AccessFlags { } #[repr(u8)] #[derive(PartialEq, Eq, PartialOrd, Ord, Debug)] -/// Defaults to [`DEFAULT`](Color::DEFAULT). +/// Defaults to [`DEFAULT`](crate::consts::Color::DEFAULT). pub enum Color { Red = 0isize as _, Green = 1isize as _, @@ -114,9 +113,9 @@ pub const IDENTITY: crate::math::Matrix4 = crate::math::Matrix4 { pub const MAX_HEALTH: i32 = 100; #[repr(C, align(4))] pub struct Player { - /// New players begin with [`STARTING_GOLD`](Player::STARTING_GOLD) gold, - /// spawn at x=[`SPAWN_X`](Player::SPAWN_X), and can hold up to - /// [`MAX_SLOTS`](Player_Inventory::MAX_SLOTS) items. + /// New players begin with [`STARTING_GOLD`](crate::consts::Player::STARTING_GOLD) gold, + /// spawn at x=[`SPAWN_X`](crate::consts::Player::SPAWN_X), and can hold up to + /// [`MAX_SLOTS`](crate::consts::Player_Inventory::MAX_SLOTS) items. pub health: i32, } fn _Player_size_check() { diff --git a/codegen_tests/output/rust/doc_self_links.rs b/codegen_tests/output/rust/doc_self_links.rs index 652d16ad..3e60ac39 100644 --- a/codegen_tests/output/rust/doc_self_links.rs +++ b/codegen_tests/output/rust/doc_self_links.rs @@ -1,16 +1,14 @@ #![cfg_attr(any(), rustfmt::skip)] -//! Self-link testing module. Its own doc links [`Container`] — the module's +//! Self-link testing module. Its own doc links [`Container`](crate::doc_self_links::Container) — the module's //! doc block must keep its links separate from its first item's, since the //! module's source location is a proxy borrowed from that item. -#[allow(unused_imports)] -use Container_Nested as Nested; #[repr(C, align(4))] /// Test `Self::` links in type docs. /// -/// Link to a field as [`Self::field`](Self::field), to a method as -/// [`Self::method`](Self::method), to a nested type as -/// [`Self::Nested`](Container_Nested), and to a nested type's member as -/// [`Self::Nested::nested_field`](Container_Nested::nested_field). +/// Link to a field as [`Self::field`](crate::doc_self_links::Container::field), to a method as +/// [`Self::method`](crate::doc_self_links::Container::method), to a nested type as +/// [`Self::Nested`](crate::doc_self_links::Container_Nested), and to a nested type's member as +/// [`Self::Nested::nested_field`](crate::doc_self_links::Container_Nested::nested_field). pub struct Container { /// A field. pub field: u32, @@ -43,7 +41,7 @@ impl std::convert::AsMut for Container { } } #[repr(C, align(2))] -/// A nested type. Its field is [`Self::nested_field`](Self::nested_field). +/// A nested type. Its field is [`Self::nested_field`](crate::doc_self_links::Container_Nested::nested_field). pub struct Container_Nested { pub nested_field: u16, } diff --git a/codegen_tests/output/rust/extern_values.rs b/codegen_tests/output/rust/extern_values.rs index 0c84a7bd..6e6c30f8 100644 --- a/codegen_tests/output/rust/extern_values.rs +++ b/codegen_tests/output/rust/extern_values.rs @@ -59,7 +59,7 @@ impl RenderMode { unsafe { &mut *(0x4000 as *mut *mut crate::extern_values::RenderMode) } } } -/// The engine singleton, of type [`Engine`]. Advances with [`get_g_frame_count`]. +/// The engine singleton, of type [`Engine`](crate::extern_values::Engine). Advances with [`g_frame_count`](get_g_frame_count). pub unsafe fn get_g_engine() -> &'static mut *mut crate::extern_values::Engine { unsafe { &mut *(0x2000 as *mut *mut crate::extern_values::Engine) } } diff --git a/codegen_tests/output/rust/lib.rs b/codegen_tests/output/rust/lib.rs index 9c0061ca..26431c84 100644 --- a/codegen_tests/output/rust/lib.rs +++ b/codegen_tests/output/rust/lib.rs @@ -5,7 +5,8 @@ non_upper_case_globals, clippy::missing_safety_doc, clippy::unnecessary_cast, - clippy::module_inception + clippy::module_inception, + rustdoc::redundant_explicit_links )] #![cfg_attr(any(), rustfmt::skip)] #[macro_export] diff --git a/codegen_tests/output/rust/nested_items.rs b/codegen_tests/output/rust/nested_items.rs index a4f5a0c3..9cd1fd41 100644 --- a/codegen_tests/output/rust/nested_items.rs +++ b/codegen_tests/output/rust/nested_items.rs @@ -1,18 +1,10 @@ #![cfg_attr(any(), rustfmt::skip)] -#[allow(unused_imports)] -use Outer_InnerAlias as InnerAlias; -#[allow(unused_imports)] -use Outer_InnerEnum as InnerEnum; -#[allow(unused_imports)] -use Outer_InnerFlags as InnerFlags; -#[allow(unused_imports)] -use Outer_InnerType as InnerType; #[repr(C, align(4))] /// A type with nested declarations. /// -/// See [`Outer_InnerEnum`], [`Outer_InnerType`], [`Outer_InnerFlags`], and [`Outer_InnerAlias`]. +/// See [`InnerEnum`](crate::nested_items::Outer_InnerEnum), [`InnerType`](crate::nested_items::Outer_InnerType), [`InnerFlags`](crate::nested_items::Outer_InnerFlags), and [`InnerAlias`](crate::nested_items::Outer_InnerAlias). /// -/// You can also qualify them: [`Outer_InnerEnum`], [`Outer_InnerType`]. +/// You can also qualify them: [`Outer::InnerEnum`](crate::nested_items::Outer_InnerEnum), [`Outer::InnerType`](crate::nested_items::Outer_InnerType). pub struct Outer { pub field: u32, } @@ -33,13 +25,13 @@ impl std::convert::AsMut for Outer { self } } -/// A type alias nested inside [`Outer`]. +/// A type alias nested inside [`Outer`](crate::nested_items::Outer). pub type Outer_InnerAlias = u32; #[repr(u8)] #[derive(PartialEq, Eq, PartialOrd, Ord, Debug)] -/// An enum nested inside [`Outer`]. +/// An enum nested inside [`Outer`](crate::nested_items::Outer). /// -/// Variants: [`Outer_InnerEnum::A`], [`Outer_InnerEnum::B`], [`Outer_InnerEnum::C`]. +/// Variants: [`InnerEnum::A`](crate::nested_items::Outer_InnerEnum::A), [`InnerEnum::B`](crate::nested_items::Outer_InnerEnum::B), [`InnerEnum::C`](crate::nested_items::Outer_InnerEnum::C). pub enum Outer_InnerEnum { A = 0isize as _, B = 1isize as _, @@ -52,9 +44,11 @@ fn _Outer_InnerEnum_size_check() { unreachable!() } crate::__bitflags! { - #[doc = " Bitflags nested inside [`Outer`]."] #[doc = ""] #[doc = - " Members: [`Outer_InnerFlags::FLAG_A`], [`Outer_InnerFlags::FLAG_B`]."] pub struct - Outer_InnerFlags : u32 { const FLAG_A = 1usize as _; const FLAG_B = 2usize as _; } + #[doc = " Bitflags nested inside [`Outer`](crate::nested_items::Outer)."] #[doc = ""] + #[doc = + " Members: [`InnerFlags::FLAG_A`](crate::nested_items::Outer_InnerFlags::FLAG_A), [`InnerFlags::FLAG_B`](crate::nested_items::Outer_InnerFlags::FLAG_B)."] + pub struct Outer_InnerFlags : u32 { const FLAG_A = 1usize as _; const FLAG_B = 2usize + as _; } } fn _Outer_InnerFlags_size_check() { unsafe { @@ -63,9 +57,9 @@ fn _Outer_InnerFlags_size_check() { unreachable!() } #[repr(C, align(2))] -/// A type nested inside [`Outer`]. +/// A type nested inside [`Outer`](crate::nested_items::Outer). /// -/// Its field is [`Outer_InnerType::inner_field`]. +/// Its field is [`InnerType::inner_field`](crate::nested_items::Outer_InnerType::inner_field). pub struct Outer_InnerType { pub inner_field: u16, } diff --git a/src/backends/rust/mod.rs b/src/backends/rust/mod.rs index 1895464d..940d9609 100644 --- a/src/backends/rust/mod.rs +++ b/src/backends/rust/mod.rs @@ -11,6 +11,7 @@ use crate::{ grammar::ItemPath, semantic::{ Module, SemanticOutput, TypeRegistry, + doc_links::{DocLinkTarget, ModuleDocLinks, ResolvedDocLink}, types::{ Argument, BitflagsDefinition, ConstDefinition as SemanticConstDefinition, ConstValue, EnumDefinition, ExternValueDefinition as SemanticExternValueDefinition, Function, @@ -111,10 +112,17 @@ pub fn write_module( // on the root file (lib.rs / the mounted-subtree root); emitting them on // the root also overrides a stricter host crate (the innermost level // wins), keeping the whole generated subtree quiet. + // + // `rustdoc::redundant_explicit_links`: every resolved doc link's + // destination is rewritten to the absolute path of its semantic target + // (see `DocLinkCx`), uniformly. For links rustdoc could have resolved from + // the label alone this is "redundant" — deciding when that holds would + // mean re-implementing rustdoc's own resolution, so the lint is allowed + // instead. if key.is_empty() { writeln!( raw_output, - "#![allow(dead_code, non_snake_case, non_camel_case_types, non_upper_case_globals, clippy::missing_safety_doc, clippy::unnecessary_cast, clippy::module_inception)]" + "#![allow(dead_code, non_snake_case, non_camel_case_types, non_upper_case_globals, clippy::missing_safety_doc, clippy::unnecessary_cast, clippy::module_inception, rustdoc::redundant_explicit_links)]" )?; } // Disable rustfmt on generated files to prevent the prettyplease-formatted code being reformatted @@ -125,46 +133,23 @@ pub fn write_module( // Collect all module paths for flattening nested item names. let module_paths: BTreeSet = semantic_state.modules().keys().cloned().collect(); - // Compute doc link imports and nested item rewrites before rendering - // module docs, so doc link references can be rewritten. - let module_scope = module.scope(); - let doc_links = semantic_state.module_doc_links(key); - let doc_imports = doc_links.imports(); - let module_path_set: BTreeSet = semantic_state.modules().keys().cloned().collect(); - let mut cross_module_imports: Vec<&ItemPath> = Vec::new(); - let mut same_module_aliases: Vec<(&ItemPath, String)> = Vec::new(); - for p in &doc_imports { - let declaring_len = find_module_prefix_len(p, &module_path_set); - let declaring_module: ItemPath = p.iter().take(declaring_len).cloned().collect(); - if &declaring_module == key { - if p.len() > key.len() + 1 { - let flat = flatten_type_name(p, &module_path_set); - let leaf = p.last().map(|s| s.as_str().to_string()).unwrap_or_default(); - if flat != leaf { - same_module_aliases.push((p, flat)); - } - } - } else { - cross_module_imports.push(p); - } - } - let mut nested_rewrites: std::collections::HashMap = { - let mut map = std::collections::HashMap::new(); - for (p, flat) in &same_module_aliases { - // The unflattened leaf is the item path's last segment — not - // `flat.rsplit('_')`, which would truncate an item name that itself - // contains an underscore. - let leaf = p.last().map(|s| s.as_str()).unwrap_or(flat.as_str()); - map.insert(leaf.to_string(), flat.clone()); - } - map + // Doc-link rewriting context: every resolved link's destination is + // rendered from its semantic target as an absolute crate path, so rustdoc + // resolves it without any doc-driven imports or aliases. + let prefix = options.rust_module_prefix.as_ref(); + let doc_cx = DocLinkCx { + links: semantic_state.module_doc_links(key), + type_registry: semantic_state.type_registry(), + module_paths: &module_paths, + module_path: key, + prefix, + root: match prefix { + Some(prefix) => format!("crate::{prefix}"), + None => "crate".to_string(), + }, }; - writeln!( - raw_output, - "{}", - doc_to_tokens(true, module.doc(), Some(&nested_rewrites)) - )?; + writeln!(raw_output, "{}", doc_cx.module_doc(module.doc()))?; // Emit the freestanding `__bitflags!` macro definition exactly once, on the // crate root, when the crate contains any bitflags. It is @@ -202,41 +187,6 @@ pub fn write_module( writeln!(raw_output, "pub mod {child};")?; } - // Rewrite cross-module doc-link destinations to absolute crate paths so - // rustdoc resolves them without any `use` imports. The path is flattened - // for nested items (e.g. `module::Outer::InnerEnum` → - // `crate::module::Outer_InnerEnum`), using the declaring module's prefix. - let mut cross_module_imports: Vec = - cross_module_imports.into_iter().cloned().collect(); - cross_module_imports.retain(|p| { - !module_scope.contains(p) && p.last().is_some_and(|s| is_plain_ident(s.as_str())) - }); - let prefix = options.rust_module_prefix.as_ref(); - let root = match prefix { - Some(prefix) => format!("crate::{prefix}"), - None => "crate".to_string(), - }; - // Map a canonical item path to its absolute Rust path, flattening nested - // item names (`module::Outer::Inner` → `crate::module::Outer_Inner`). - let to_rust_path = |p: &ItemPath| -> String { - let module_len = find_module_prefix_len(p, &module_path_set); - if p.len() > module_len + 1 { - let type_segments: Vec<&str> = p.iter().skip(module_len).map(|s| s.as_str()).collect(); - let module_part: Vec<&str> = p.iter().take(module_len).map(|s| s.as_str()).collect(); - let flat_name = type_segments.join("_"); - if module_part.is_empty() { - format!("{root}::{flat_name}") - } else { - format!("{root}::{}::{flat_name}", module_part.join("::")) - } - } else { - format!("{root}::{p}") - } - }; - for p in &cross_module_imports { - nested_rewrites.insert(p.to_string(), to_rust_path(p)); - } - // Emit explicit `pub use` re-exports. Each re-export is canonicalized (past // any re-export chain) to the defining item and rendered as an absolute // `pub use crate::…;`, so consumers of the generated crate can reach the @@ -247,27 +197,11 @@ pub fn write_module( if !type_registry.contains(&canonical) { continue; } - writeln!(raw_output, "pub use {};", to_rust_path(&canonical))?; - } - - // Extern values emit as `get_` accessors (a free fn when module-level, - // an inherent method when nested), so a doc link written against the value's - // logical name won't resolve. Rewrite each such link's destination to the - // accessor's Rust path, reusing the same accessor naming the emitter uses. - for (text, value_path) in doc_links.extern_value_links() { - let rust_path = extern_value_accessor_doc_path(&value_path, key, &module_path_set, prefix); - nested_rewrites.insert(text.to_string(), rust_path); - } - - // Generate `use FlatName as LeafName;` aliases for same-module nested - // items referenced in doc links, so rustdoc resolves [`LeafName`] to - // the flattened Rust identifier. - if !same_module_aliases.is_empty() { - for (p, flat) in &same_module_aliases { - let leaf = p.last().map(|s| s.as_str()).unwrap_or(flat.as_str()); - writeln!(raw_output, "#[allow(unused_imports)]")?; - writeln!(raw_output, "use {flat} as {leaf};")?; - } + writeln!( + raw_output, + "pub use {};", + doc_cx.absolute_item_path(&canonical) + )?; } let cfg_pass = |cfg: &Option| match cfg { @@ -309,7 +243,7 @@ pub fn write_module( options, &extern_rust_names, &module_paths, - &nested_rewrites, + &doc_cx, )? )?; } @@ -320,7 +254,7 @@ pub fn write_module( .iter() .filter(|f| !f.is_internal()) .filter(|f| cfg_pass(&f.cfg)) - .map(|f| build_function(f, options, false, &module_paths)) + .map(|f| build_function(f, options, false, &module_paths, &doc_cx)) .collect::>>()?; for func in freestanding_functions { writeln!(raw_output, "{func}")?; @@ -378,7 +312,7 @@ fn build_item( options: &crate::BuildOptions, extern_rust_names: &HashMap, module_paths: &BTreeSet, - nested_rewrites: &std::collections::HashMap, + doc_cx: &DocLinkCx, ) -> Result { let resolved = definition .resolved() @@ -413,7 +347,7 @@ fn build_item( cfg_ctx, options, module_paths, - nested_rewrites, + doc_cx, ), IDI::Enum(ed) => build_enum( type_registry, @@ -425,7 +359,7 @@ fn build_item( cfg_ctx, options, module_paths, - nested_rewrites, + doc_cx, ), IDI::Bitflags(bd) => build_bitflags( type_registry, @@ -436,7 +370,7 @@ fn build_item( location, options.rust_module_prefix.as_ref(), module_paths, - nested_rewrites, + doc_cx, ), IDI::TypeAlias(ta) => build_type_alias( type_registry, @@ -447,16 +381,17 @@ fn build_item( type_parameters, options.rust_module_prefix.as_ref(), module_paths, - nested_rewrites, + doc_cx, ), - IDI::Constant(cd) => build_const(path, visibility, cd, location, module_paths), + IDI::Constant(cd) => build_const(path, visibility, cd, location, module_paths, doc_cx), IDI::ExternValue(ev) => build_extern_value( path, visibility, ev, + location, options.rust_module_prefix.as_ref(), module_paths, - nested_rewrites, + doc_cx, ), }, ItemCategory::Predefined => Ok(quote! {}), @@ -490,12 +425,12 @@ fn build_type( alignment: usize, visibility: Visibility, type_definition: &TypeDefinition, - _location: &ItemLocation, + location: &ItemLocation, type_parameters: &[String], cfg_ctx: &crate::parser::cfg::CfgContext, options: &crate::BuildOptions, module_paths: &BTreeSet, - nested_rewrites: &std::collections::HashMap, + doc_cx: &DocLinkCx, ) -> Result { let name = flatten_type_name(path, module_paths); let name = &name; @@ -516,7 +451,7 @@ fn build_type( } = type_definition; let visibility = visibility_to_tokens(visibility); - let doc = doc_to_tokens(false, doc, Some(nested_rewrites)); + let doc = doc_cx.node(doc, location); let mut fields = regions .iter() .map(|r| { @@ -539,7 +474,7 @@ fn build_type( let field_ident = str_to_ident(field_name); let visibility = visibility_to_tokens(*visibility); let syn_type = sa_type_to_syn_type(type_ref, prefix, Some(module_paths))?; - let doc = doc_to_tokens(false, doc, Some(nested_rewrites)); + let doc = doc_cx.node(doc, location); Ok(quote! { #doc #visibility #field_ident: #syn_type @@ -600,7 +535,7 @@ fn build_type( .iter() .filter(|f| !f.is_internal()) .filter(|f| cfg_pass(&f.cfg)) - .map(|f| build_function(f, options, true, module_paths)) + .map(|f| build_function(f, options, true, module_paths, doc_cx)) .collect::>>()?; let vftable_function_impl = vftable @@ -610,7 +545,7 @@ fn build_type( .iter() .filter(|f| !f.is_internal()) .filter(|f| cfg_pass(&f.cfg)) - .map(|f| build_function(f, options, true, module_paths)) + .map(|f| build_function(f, options, true, module_paths, doc_cx)) .collect::>>() }) .transpose()? @@ -753,10 +688,10 @@ fn build_type( }; // Emit nested constants as associated constants in an impl block - let nested_const_impls = build_nested_const_impls(type_registry, path, module_paths); + let nested_const_impls = build_nested_const_impls(type_registry, path, module_paths, doc_cx); // Emit nested extern values as associated `get_*` accessors in an impl block let nested_extern_value_impls = - build_nested_extern_value_impls(type_registry, path, module_paths, nested_rewrites); + build_nested_extern_value_impls(type_registry, path, module_paths, doc_cx); Ok(quote! { #derives @@ -785,11 +720,11 @@ fn build_enum( size: usize, visibility: Visibility, enum_definition: &EnumDefinition, - _location: &ItemLocation, + location: &ItemLocation, cfg_ctx: &crate::parser::cfg::CfgContext, options: &crate::BuildOptions, module_paths: &BTreeSet, - nested_rewrites: &std::collections::HashMap, + doc_cx: &DocLinkCx, ) -> Result { let name = flatten_type_name(path, module_paths); let name = &name; @@ -811,7 +746,7 @@ fn build_enum( let name_ident = str_to_ident(name.as_str()); let visibility = visibility_to_tokens(visibility); - let doc = doc_to_tokens(false, doc, Some(nested_rewrites)); + let doc = doc_cx.node(doc, location); let size_check_impl = generate_size_check(name.as_str(), size); @@ -862,7 +797,7 @@ fn build_enum( .iter() .filter(|f| !f.is_internal()) .filter(|f| cfg_pass(&f.cfg)) - .map(|f| build_function(f, options, true, module_paths)) + .map(|f| build_function(f, options, true, module_paths, doc_cx)) .collect::>>()?; let associated_impl = if !associated_functions_impl.is_empty() { @@ -876,10 +811,10 @@ fn build_enum( }; // Emit nested constants as associated constants in an impl block - let nested_const_impls = build_nested_const_impls(type_registry, path, module_paths); + let nested_const_impls = build_nested_const_impls(type_registry, path, module_paths, doc_cx); // Emit nested extern values as associated `get_*` accessors in an impl block let nested_extern_value_impls = - build_nested_extern_value_impls(type_registry, path, module_paths, nested_rewrites); + build_nested_extern_value_impls(type_registry, path, module_paths, doc_cx); Ok(quote! { #[repr(#syn_type)] @@ -903,10 +838,10 @@ fn build_bitflags( size: usize, visibility: Visibility, bitflags_definition: &BitflagsDefinition, - _location: &ItemLocation, + location: &ItemLocation, prefix: Option<&ItemPath>, module_paths: &BTreeSet, - nested_rewrites: &std::collections::HashMap, + doc_cx: &DocLinkCx, ) -> Result { let name = flatten_type_name(path, module_paths); let name = &name; @@ -924,7 +859,7 @@ fn build_bitflags( let name_ident = str_to_ident(name.as_str()); let visibility = visibility_to_tokens(visibility); - let doc = doc_to_tokens(false, doc, Some(nested_rewrites)); + let doc = doc_cx.node(doc, location); let size_check_impl = generate_size_check(name.as_str(), size); @@ -964,10 +899,10 @@ fn build_bitflags( }); // Emit nested constants as associated constants in an impl block - let nested_const_impls = build_nested_const_impls(type_registry, path, module_paths); + let nested_const_impls = build_nested_const_impls(type_registry, path, module_paths, doc_cx); // Emit nested extern values as associated `get_*` accessors in an impl block let nested_extern_value_impls = - build_nested_extern_value_impls(type_registry, path, module_paths, nested_rewrites); + build_nested_extern_value_impls(type_registry, path, module_paths, doc_cx); Ok(quote! { crate::__bitflags! { @@ -990,11 +925,11 @@ fn build_type_alias( path: &ItemPath, visibility: Visibility, type_alias_definition: &TypeAliasDefinition, - _location: &ItemLocation, + location: &ItemLocation, type_parameters: &[String], prefix: Option<&ItemPath>, module_paths: &BTreeSet, - nested_rewrites: &std::collections::HashMap, + doc_cx: &DocLinkCx, ) -> Result { let name = flatten_type_name(path, module_paths); let name = &name; @@ -1003,7 +938,7 @@ fn build_type_alias( let name_ident = str_to_ident(name.as_str()); let visibility = visibility_to_tokens(visibility); - let doc = doc_to_tokens(false, doc, Some(nested_rewrites)); + let doc = doc_cx.node(doc, location); let target_type = sa_type_to_syn_type(target, prefix, Some(module_paths))?; let generic_params = build_generic_params(type_parameters); @@ -1131,14 +1066,15 @@ fn build_const( path: &ItemPath, visibility: Visibility, const_definition: &SemanticConstDefinition, - _location: &ItemLocation, + location: &ItemLocation, module_paths: &BTreeSet, + doc_cx: &DocLinkCx, ) -> Result { let name = flatten_type_name(path, module_paths); let name_ident = str_to_ident(name.as_str()); let visibility = visibility_to_tokens(visibility); let type_ = sa_type_to_syn_type(&const_definition.type_, None, Some(module_paths))?; - let doc = doc_to_tokens(false, &const_definition.doc, None); + let doc = doc_cx.node(&const_definition.doc, location); let value_tokens = const_value_to_tokens( &const_definition.value, &const_definition.type_, @@ -1157,6 +1093,7 @@ fn build_nested_const_impls( type_registry: &TypeRegistry, parent_path: &ItemPath, module_paths: &BTreeSet, + doc_cx: &DocLinkCx, ) -> Option { use ItemDefinitionInner as IDI; @@ -1180,7 +1117,7 @@ fn build_nested_const_impls( Ok(t) => t, Err(_) => continue, }; - let doc = doc_to_tokens(false, &cd.doc, None); + let doc = doc_cx.node(&cd.doc, &item.location); let value_tokens = const_value_to_tokens(&cd.value, &cd.type_, module_paths); const_items.push(quote! { @@ -1206,6 +1143,7 @@ fn build_function( options: &crate::BuildOptions, in_impl: bool, module_paths: &BTreeSet, + doc_cx: &DocLinkCx, ) -> Result { let prefix = options.rust_module_prefix.as_ref(); // External-body methods declare their existence in pyxis but get their @@ -1217,7 +1155,7 @@ fn build_function( return Ok(proc_macro2::TokenStream::new()); } let name = str_to_ident(&function.name); - let doc = doc_to_tokens(false, &function.doc, None); + let doc = doc_cx.node(&function.doc, &function.location); let arguments = function .arguments @@ -1360,16 +1298,17 @@ fn build_extern_value( path: &ItemPath, visibility: Visibility, ev: &SemanticExternValueDefinition, + location: &ItemLocation, prefix: Option<&ItemPath>, module_paths: &BTreeSet, - nested_rewrites: &HashMap, + doc_cx: &DocLinkCx, ) -> Result { let name = flatten_type_name(path, module_paths); let visibility = visibility_to_tokens(visibility); let function_ident = str_to_ident(&extern_value_accessor_name(&name)); let type_ = sa_type_to_syn_type(&ev.type_, prefix, Some(module_paths))?; let address = hex_literal(ev.address); - let doc = doc_to_tokens(false, &ev.doc, Some(nested_rewrites)); + let doc = doc_cx.node(&ev.doc, location); Ok(quote! { #doc @@ -1387,7 +1326,7 @@ fn build_nested_extern_value_impls( type_registry: &TypeRegistry, parent_path: &ItemPath, module_paths: &BTreeSet, - nested_rewrites: &HashMap, + doc_cx: &DocLinkCx, ) -> Option { use ItemDefinitionInner as IDI; @@ -1411,7 +1350,7 @@ fn build_nested_extern_value_impls( Err(_) => continue, }; let address = hex_literal(ev.address); - let doc = doc_to_tokens(false, &ev.doc, Some(nested_rewrites)); + let doc = doc_cx.node(&ev.doc, &item.location); items.push(quote! { #doc @@ -1437,16 +1376,6 @@ fn str_to_ident(s: &str) -> syn::Ident { quote::format_ident!("{}", s) } -/// Whether `s` is a plain Rust identifier (no generics, operators, etc.), so it -/// can appear verbatim in a `use` path. -fn is_plain_ident(s: &str) -> bool { - let mut chars = s.chars(); - chars - .next() - .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') - && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') -} - /// Flatten a nested item path to a Rust-safe identifier by joining type-nesting /// segments with `_`. Module segments are identified by matching against known /// module paths; everything after the module prefix is a type segment. @@ -1741,13 +1670,16 @@ fn visibility_to_tokens(visibility: Visibility) -> proc_macro2::TokenStream { fn doc_to_tokens( is_module_doc: bool, doc: &[String], - nested_rewrites: Option<&std::collections::HashMap>, + links: Option<(&DocLinkCx, &[ResolvedDocLink])>, ) -> proc_macro2::TokenStream { if doc.is_empty() { return proc_macro2::TokenStream::new(); }; let doc_attrs = doc.iter().map(|line| { - let rewritten = rewrite_doc_links(line, nested_rewrites); + let rewritten = match links { + Some((cx, block)) => cx.rewrite_line(line, block), + None => line.clone(), + }; if is_module_doc { quote! { #![doc = #rewritten] } } else { @@ -1759,74 +1691,133 @@ fn doc_to_tokens( } } -/// Rewrite a single `::`-separated intra-doc link path to use flattened Rust -/// names for nested items. When a segment matches a rewrite key, it's replaced -/// with the flattened name and all preceding segments are dropped (they're the -/// parent-type prefix already baked into the flattened name), while trailing -/// member-access segments are preserved. For example, with `Inner → Outer_Inner`: -/// `Inner` → `Outer_Inner`, `Inner::A` → `Outer_Inner::A`, and -/// `Outer::Inner::CONST` → `Outer_Inner::CONST`. +/// Context for rewriting intra-doc links in emitted docs. /// -/// Cross-module paths are rewritten to absolute crate paths via an exact -/// full-path match before the per-segment loop. -fn rewrite_doc_link_path( - content: &str, - rewrites: &std::collections::HashMap, -) -> String { - // First, try an exact full-path match (for cross-module imports). - if let Some(target) = rewrites.get(content) { - return target.clone(); +/// Each link's *resolved target* — determined once during semantic analysis +/// and stored in the module's [`ModuleDocLinks`] table — is rendered as an +/// absolute Rust path (`crate::module::Outer_Inner::member`), flattening +/// nested-item names and substituting extern-value accessors. Rewriting from +/// the target rather than the written text means the destination is always +/// what the link actually resolved to: no leaf-name rewrite maps that can +/// collide, no doc-driven `use` imports for rustdoc's benefit. +struct DocLinkCx<'a> { + links: &'a ModuleDocLinks, + type_registry: &'a TypeRegistry, + module_paths: &'a BTreeSet, + /// The module being emitted; extern-value accessor paths in the same + /// module stay relative. + module_path: &'a ItemPath, + prefix: Option<&'a ItemPath>, + /// `crate` or `crate::`. + root: String, +} + +impl DocLinkCx<'_> { + /// Doc tokens for the doc block owned by the node at `location`. + fn node(&self, doc: &[String], location: &ItemLocation) -> proc_macro2::TokenStream { + doc_to_tokens(false, doc, Some((self, self.links.at(location)))) } - let segments: Vec<&str> = content.split("::").collect(); - if segments.len() == 1 { - return rewrites - .get(segments[0]) - .cloned() - .unwrap_or_else(|| content.to_string()); + + /// Doc tokens for the module's own (`//!`) doc block. + fn module_doc(&self, doc: &[String]) -> proc_macro2::TokenStream { + doc_to_tokens(true, doc, Some((self, self.links.module_doc()))) } - for (i, seg) in segments.iter().enumerate() { - if let Some(flat) = rewrites.get(*seg) { - let rest = &segments[i + 1..]; - return if rest.is_empty() { - flat.clone() + + /// The absolute Rust path of an item: `{root}::{module}::{FlatName}`, + /// flattening nested-item segments (`module::Outer::Inner` → + /// `crate::module::Outer_Inner`). + fn absolute_item_path(&self, path: &ItemPath) -> String { + let module_len = find_module_prefix_len(path, self.module_paths); + let root = &self.root; + if path.len() > module_len + 1 { + let flat_name = flatten_type_name(path, self.module_paths); + let module_part: Vec<&str> = path.iter().take(module_len).map(|s| s.as_str()).collect(); + if module_part.is_empty() { + format!("{root}::{flat_name}") } else { - format!("{}::{}", flat, rest.join("::")) - }; + format!("{root}::{}::{flat_name}", module_part.join("::")) + } + } else { + format!("{root}::{path}") } } - content.to_string() -} -/// Rewrite intra-doc link references in a doc comment line to use flattened -/// Rust names for nested items. Handles both the shortcut form (`` [`InnerEnum`] `` -/// → `` [`Outer_InnerEnum`] ``) and the inline form's destination -/// (`[label](Outer::InnerEnum)` → `[label](Outer_InnerEnum)`) — the latter -/// matters because rustdoc resolves the destination, not the label, and nested -/// types are emitted under flattened names. -/// -/// Link detection is shared with the compiler and LSP via -/// [`scan_links`](crate::semantic::doc_links::scan_links); each link's precise -/// `path_region` is substituted in place (right-to-left so earlier offsets stay -/// valid). Bare `[Path]` shortcuts are left alone — the compiler doesn't import -/// them, so rustdoc couldn't resolve a rewritten one anyway. -fn rewrite_doc_links( - line: &str, - nested_rewrites: Option<&std::collections::HashMap>, -) -> String { - use crate::semantic::doc_links::DocLinkSyntax; - let Some(rewrites) = nested_rewrites else { - return line.to_string(); - }; - let mut result = line.to_string(); - let mut links = crate::semantic::doc_links::scan_links(line); - links.retain(|l| l.syntax != DocLinkSyntax::PlainShortcut); - for link in links.into_iter().rev() { - let rewritten = rewrite_doc_link_path(&link.path, rewrites); - if rewritten != link.path { - result.replace_range(link.path_region.0..link.path_region.1, &rewritten); + /// Render a resolved target as the destination rustdoc should see, or + /// `None` to leave the written link untouched (predefined types, which + /// rustdoc resolves natively as primitives). + fn render_target(&self, target: &DocLinkTarget) -> Option { + use crate::semantic::doc_links::DocLinkMemberKind; + match target { + DocLinkTarget::Item(path) => { + let predefined = self + .type_registry + .get(path, &ItemLocation::internal()) + .is_ok_and(|i| i.category == crate::semantic::types::ItemCategory::Predefined); + if predefined { + return None; + } + Some(self.absolute_item_path(path)) + } + DocLinkTarget::Member { item, name, kind } => match kind { + DocLinkMemberKind::ExternValue => Some(self.accessor_path(item, name)), + _ => Some(format!("{}::{name}", self.absolute_item_path(item))), + }, + DocLinkTarget::Function { module, name } => Some(if module.is_empty() { + format!("{}::{name}", self.root) + } else { + format!("{}::{module}::{name}", self.root) + }), + DocLinkTarget::ExternValue { module, name } => Some(self.accessor_path(module, name)), + } + } + + /// The rustdoc path of an extern value's `get_` accessor, given the + /// value's parent (module or type) and name. + fn accessor_path(&self, parent: &ItemPath, name: &str) -> String { + let value_path = parent.join(crate::grammar::ItemPathSegment::from(name)); + extern_value_accessor_doc_path( + &value_path, + self.module_path, + self.module_paths, + self.prefix, + ) + } + + /// Rewrite every resolved link in `line` to its rendered destination. + /// + /// Link spans come from [`scan_links`](crate::semantic::doc_links::scan_links) + /// (shared with the compiler and LSP) and are substituted right-to-left so + /// earlier offsets stay valid. An inline link keeps its label and gets its + /// destination replaced; a code shortcut becomes an inline link so its + /// visible label survives the rewrite. Bare `[Path]` shortcuts aren't + /// resolved by the compiler and are left alone. + fn rewrite_line(&self, line: &str, block: &[ResolvedDocLink]) -> String { + use crate::semantic::doc_links::DocLinkSyntax; + if block.is_empty() { + return line.to_string(); + } + let mut result = line.to_string(); + let mut scanned = crate::semantic::doc_links::scan_links(line); + scanned.retain(|l| l.syntax != DocLinkSyntax::PlainShortcut); + for link in scanned.into_iter().rev() { + let Some(resolved) = block.iter().find(|r| r.text == link.path) else { + continue; + }; + let Some(dest) = self.render_target(&resolved.target) else { + continue; + }; + match link.syntax { + DocLinkSyntax::Inline => { + result.replace_range(link.path_region.0..link.path_region.1, &dest); + } + DocLinkSyntax::CodeShortcut | DocLinkSyntax::PlainShortcut => { + let label = &line[link.label_region.0..link.label_region.1]; + result.replace_range(link.link.0..link.link.1, &format!("[{label}]({dest})")); + } + } } + result } - result } fn hex_literal(value: impl Into) -> proc_macro2::Literal { From cf7fdc61ee637b43ae55aa2fa477b8a5e9cf31db Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 09:20:36 +0200 Subject: [PATCH 05/10] Resolve Self:: doc links in the LSP with enclosing-type context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LSP passed no enclosing type at its three doc-link resolution sites, so `Self::` links — valid to the compiler since #114 — were inert in the editor: no document-link, no hover/go-to-definition, and doc_link_occurrences missed them, leaving `Self::member` links stale after a member rename. `enclosing_type_for_doc_line` walks the file's parsed AST to find the item whose definition (or impl block) contains a doc line, mirroring the compiler's walk semantics: nested constants/extern values use the parent type, type aliases and module-level docs get none. --- tooling/lsp/src/handlers/doc_links.rs | 210 +++++++++++++++++++------- tooling/lsp/tests/structures_test.rs | 26 ++++ 2 files changed, 180 insertions(+), 56 deletions(-) diff --git a/tooling/lsp/src/handlers/doc_links.rs b/tooling/lsp/src/handlers/doc_links.rs index d203f750..6569e116 100644 --- a/tooling/lsp/src/handlers/doc_links.rs +++ b/tooling/lsp/src/handlers/doc_links.rs @@ -2,6 +2,96 @@ use super::*; use pyxis::semantic::doc_links::{DocLinkPath, DocLinkSyntax, DocLinkTarget, ScannedLink}; +impl ServerState { + /// The `Self`-enclosing item for a doc-comment line — the type/enum/ + /// bitflags whose definition or `impl` block contains the line. Mirrors + /// the compiler's doc walk (`doc_links::resolve_all`): nested constants + /// and extern values use their *parent* type (their emitted docs land in + /// the parent's impl block), type aliases and module-level docs get + /// `None`. + pub(crate) fn enclosing_type_for_doc_line( + &self, + uri: &Uri, + line_no: usize, + ) -> Option { + let doc = self.documents.get(uri)?; + let parsed = semantic::parse_file(&self.db, doc.source_file); + let module = parsed.module(&self.db); + let own_module = self.module_path_for(uri); + let item_path = |name: &str| match &own_module { + Some(mp) => mp.join(name.into()), + None => ItemPath::from(name), + }; + + for item in &module.items { + match item { + ModuleItem::Definition { definition } + if location_contains_line(&definition.location, line_no) => + { + return enclosing_in_definition( + definition, + &item_path(definition.name.as_str()), + None, + line_no, + ); + } + ModuleItem::Impl { impl_block } + if location_contains_line(&impl_block.location, line_no) => + { + // `impl Outer::Inner` targets a nested item; join every + // written segment onto the module path. + let mut path = item_path(impl_block.name.as_str()); + if let Some(np) = &impl_block.name_path { + for seg in np.iter() { + path = path.join(seg.clone()); + } + } + return Some(path); + } + _ => {} + } + } + None + } +} + +fn location_contains_line(location: &pyxis::span::ItemLocation, line_no: usize) -> bool { + location.span.start.line <= line_no && line_no <= location.span.end.line +} + +/// Walk into a definition to find the enclosing item for `line_no`, mirroring +/// the compiler's enclosing-type semantics. `parent_type` is the type this +/// definition is nested inside (`None` at module level). +fn enclosing_in_definition( + definition: &ItemDefinition, + path: &ItemPath, + parent_type: Option<&ItemPath>, + line_no: usize, +) -> Option { + use pyxis::grammar::ItemDefinitionInner as IDI; + match &definition.inner { + IDI::Type(td) => { + // A line inside a nested item's own span belongs to that item. + for statement in td.statements() { + if let TypeField::Item(nested) = &statement.field + && location_contains_line(&nested.location, line_no) + { + return enclosing_in_definition( + nested, + &path.join(nested.name.as_str().into()), + Some(path), + line_no, + ); + } + } + Some(path.clone()) + } + IDI::Enum(_) | IDI::Bitflags(_) => Some(path.clone()), + IDI::Constant(_) | IDI::ExternValue(_) => parent_type.cloned(), + IDI::TypeAlias(_) => None, + } +} + impl ServerState { /// Doc-comment links that reference `symbol`, returning the span of the /// symbol's name within each link's path (a type's name segment, or a @@ -64,9 +154,10 @@ impl ServerState { let Some(line) = lines.get(line_no - 1) else { continue; }; + let enclosing = self.enclosing_type_for_doc_line(uri, line_no); for dl in scan_doc_links(line) { if !resolver - .resolve(&scope, &DocLinkPath::parse(&dl.path), None) + .resolve(&scope, &DocLinkPath::parse(&dl.path), enclosing.as_ref()) .is_some_and(|t| matches(&t)) { continue; @@ -151,40 +242,43 @@ impl ServerState { let Some(line) = lines.get(line_no - 1) else { continue; }; + let enclosing = self.enclosing_type_for_doc_line(uri, line_no); for dl in scan_doc_links(line) { // Target file + 1-based line to anchor the link at, and a tooltip. - let Some((target_uri, target_line, tooltip)) = - (match resolver.resolve(&scope, &DocLinkPath::parse(&dl.path), None) { - Some(DocLinkTarget::Item(p)) => self - .resolved_definition(&p, type_registry, uri) - .map(|rd| (rd.uri, rd.name_span.start.line, p.to_string())), - Some(DocLinkTarget::Member { item, name, .. }) => self - .resolve_doc_member(&item, &name, uri) - .map(|(muri, mspan, _)| { - let path = item.join(name.as_str().into()); - (muri, mspan.start.line, path.to_string()) - }) - // Fall back to the owning type if the member's own - // declaration can't be located. - .or_else(|| { - self.resolved_definition(&item, type_registry, uri) - .map(|rd| (rd.uri, rd.name_span.start.line, item.to_string())) - }), - Some(DocLinkTarget::Function { module, name }) => self - .resolve_doc_module_item(&module, &name, uri, true) - .map(|(loc, _)| { - let path = module.join(name.as_str().into()); - (loc.uri, loc.range.start.line as usize + 1, path.to_string()) - }), - Some(DocLinkTarget::ExternValue { module, name }) => self - .resolve_doc_module_item(&module, &name, uri, false) - .map(|(loc, _)| { - let path = module.join(name.as_str().into()); - (loc.uri, loc.range.start.line as usize + 1, path.to_string()) - }), - None => None, - }) - else { + let Some((target_uri, target_line, tooltip)) = (match resolver.resolve( + &scope, + &DocLinkPath::parse(&dl.path), + enclosing.as_ref(), + ) { + Some(DocLinkTarget::Item(p)) => self + .resolved_definition(&p, type_registry, uri) + .map(|rd| (rd.uri, rd.name_span.start.line, p.to_string())), + Some(DocLinkTarget::Member { item, name, .. }) => self + .resolve_doc_member(&item, &name, uri) + .map(|(muri, mspan, _)| { + let path = item.join(name.as_str().into()); + (muri, mspan.start.line, path.to_string()) + }) + // Fall back to the owning type if the member's own + // declaration can't be located. + .or_else(|| { + self.resolved_definition(&item, type_registry, uri) + .map(|rd| (rd.uri, rd.name_span.start.line, item.to_string())) + }), + Some(DocLinkTarget::Function { module, name }) => self + .resolve_doc_module_item(&module, &name, uri, true) + .map(|(loc, _)| { + let path = module.join(name.as_str().into()); + (loc.uri, loc.range.start.line as usize + 1, path.to_string()) + }), + Some(DocLinkTarget::ExternValue { module, name }) => self + .resolve_doc_module_item(&module, &name, uri, false) + .map(|(loc, _)| { + let path = module.join(name.as_str().into()); + (loc.uri, loc.range.start.line as usize + 1, path.to_string()) + }), + None => None, + }) else { continue; }; let span = Span::new( @@ -433,6 +527,7 @@ impl ServerState { )) }; + let enclosing = self.enclosing_type_for_doc_line(uri, loc.line); for dl in scan_doc_links(line) { if col < dl.link.0 || col >= dl.link.1 { continue; @@ -441,31 +536,34 @@ impl ServerState { Location::new(loc.line, dl.link.0 + 1), Location::new(loc.line, dl.link.1 + 1), ); - let (location, hover) = - match resolver.resolve(&scope, &DocLinkPath::parse(&dl.path), None)? { - DocLinkTarget::Item(p) => to_type(self, &p)?, - DocLinkTarget::Member { item, name, .. } => { - match self.resolve_doc_member(&item, &name, uri) { - Some((muri, mspan, mhover)) => { - let mcontent = self.get_content(&muri)?; - ( - lsp_types::Location { - uri: muri.clone(), - range: pyxis_span_to_lsp_range(mcontent, &mspan), - }, - mhover, - ) - } - None => to_type(self, &item)?, + let (location, hover) = match resolver.resolve( + &scope, + &DocLinkPath::parse(&dl.path), + enclosing.as_ref(), + )? { + DocLinkTarget::Item(p) => to_type(self, &p)?, + DocLinkTarget::Member { item, name, .. } => { + match self.resolve_doc_member(&item, &name, uri) { + Some((muri, mspan, mhover)) => { + let mcontent = self.get_content(&muri)?; + ( + lsp_types::Location { + uri: muri.clone(), + range: pyxis_span_to_lsp_range(mcontent, &mspan), + }, + mhover, + ) } + None => to_type(self, &item)?, } - DocLinkTarget::Function { module, name } => { - self.resolve_doc_module_item(&module, &name, uri, true)? - } - DocLinkTarget::ExternValue { module, name } => { - self.resolve_doc_module_item(&module, &name, uri, false)? - } - }; + } + DocLinkTarget::Function { module, name } => { + self.resolve_doc_module_item(&module, &name, uri, true)? + } + DocLinkTarget::ExternValue { module, name } => { + self.resolve_doc_module_item(&module, &name, uri, false)? + } + }; return Some((link_span, location, hover)); } None diff --git a/tooling/lsp/tests/structures_test.rs b/tooling/lsp/tests/structures_test.rs index fcb7d416..7412e27c 100644 --- a/tooling/lsp/tests/structures_test.rs +++ b/tooling/lsp/tests/structures_test.rs @@ -1498,3 +1498,29 @@ fn rename_type_updates_splice_for_clause() { "rename should update def + splice `for` clause; got {total}" ); } + +#[test] +fn doc_links_resolve_self_prefixed_paths() { + // `Self::` doc links (issue #114) resolve against the enclosing type in + // the editor too: hover and go-to-definition work on a field doc's + // [`Self::member`] link, and inside an impl block's method docs. + let src = "pub type Foo {\n /// Pairs with [`Self::y`].\n pub x: u64,\n pub y: u64,\n}\nimpl Foo {\n /// Uses [`Self::x`].\n #[address(0x10)]\n pub fn go(&mut self);\n}\n"; + let st = ServerState::in_memory(&[("/p", 8, &[("m.pyxis", src)])]); + let uri = ServerState::document_uri("/p", "m.pyxis"); + + let field_col = src.lines().nth(1).unwrap().find("Self::y").unwrap() as u32 + 1; + assert!( + hover_text(&st, &uri, 1, field_col).contains("`y`"), + "hover on [`Self::y`] in a field doc resolves to the sibling field" + ); + assert!( + def_uri(&st, &uri, 1, field_col).is_some(), + "go-to-def on [`Self::y`] resolves" + ); + + let impl_col = src.lines().nth(6).unwrap().find("Self::x").unwrap() as u32 + 1; + assert!( + hover_text(&st, &uri, 6, impl_col).contains("`x`"), + "hover on [`Self::x`] in an impl method doc resolves to the field" + ); +} From 30c55ed3d82ed0e7c901848042fb107742964a44 Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 09:28:32 +0200 Subject: [PATCH 06/10] Emit doxygen-resolvable doc links from the C++ backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite each resolved intra-doc link in emitted C++ doc comments into doxygen markdown (`[label](@ref ns::Target)`), rendered from the link's semantic target under the C++ emission rules: namespaces for modules, genuine nesting for nested types, `get_()` accessors for extern values, and module-scope flattened names (`Parent_NAME`) for value items under enum/bitflags parents. Targets with no documented C++ entity (primitives, out-of-tree externs) flatten to their bare label instead of emitting a dead link. test.py gains a doxygen pass over the emitted corpus that fails on any warning — unresolvable `@ref`s in particular — mirroring the cargo doc gate for Rust. It skips with a warning when doxygen is missing; a new shell.nix provides it and CI installs it. Doxygen output is generated into a temp dir and discarded; the tracked C++ corpus shows the rewritten comments. --- .github/workflows/ci.yml | 3 + CONTRIBUTING.md | 2 +- codegen_tests/output/cpp/include/consts.hpp | 10 +- .../output/cpp/include/doc_self_links.hpp | 10 +- .../output/cpp/include/extern_values.hpp | 8 +- .../output/cpp/include/nested_items.hpp | 18 +- .../cpp/include/qualified_links_consumer.hpp | 4 +- .../output/cpp/include/world_consumer.hpp | 2 +- docs/cpp_backend.md | 12 + shell.nix | 14 ++ src/backends/cpp/mod.rs | 8 +- src/backends/cpp/render.rs | 221 ++++++++++++++++-- test.py | 65 ++++++ 13 files changed, 324 insertions(+), 53 deletions(-) create mode 100644 shell.nix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 065c3915..a4b4c547 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: with: python-version: "3.x" + # test.py verifies the emitted C++ corpus's doc links with doxygen. + - run: sudo apt-get update && sudo apt-get install -y doxygen + # test.py lints the viewer (tsc/eslint/prettier) but does not install; # provide the workspace deps here. - name: Set up Node.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d034c51..3d00e9da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ When you change the language (new attributes, syntax, types, etc.), you must aud python test.py ``` -runs the full test suite: clippy, fmt, the parser/semantic unit tests, `cargo run --example codegen_tests` (which emits the test corpus through every backend and rebuilds the emitted output), and `cargo doc --no-deps -p codegen_tests` (which catches unresolved doc link references in the generated Rust output). The cpp test corpus uses a regular host C++17 compiler (no MSVC ABI required) so CI doesn't need xwin. +runs the full test suite: clippy, fmt, the parser/semantic unit tests, `cargo run --example codegen_tests` (which emits the test corpus through every backend and rebuilds the emitted output), `cargo doc --no-deps -p codegen_tests` (which catches unresolved doc link references in the generated Rust output), and a doxygen pass over the emitted C++ corpus (which catches unresolvable `@ref`s in the rewritten C++ doc links — skipped with a warning if doxygen isn't installed; `nix-shell` provides it via `shell.nix`). The cpp test corpus uses a regular host C++17 compiler (no MSVC ABI required) so CI doesn't need xwin. Formatting relies on a nightly-only rustfmt feature (`imports_granularity`, configured in `rustfmt.toml`), so check it with nightly: diff --git a/codegen_tests/output/cpp/include/consts.hpp b/codegen_tests/output/cpp/include/consts.hpp index 3c3c9ba3..db4bb3e3 100644 --- a/codegen_tests/output/cpp/include/consts.hpp +++ b/codegen_tests/output/cpp/include/consts.hpp @@ -10,7 +10,7 @@ namespace consts { enum class Color : ::std::uint8_t; struct Player; - /// The permissive baseline is [`DEFAULT_MASK`](AccessFlags::DEFAULT_MASK). + /// The permissive baseline is [`DEFAULT_MASK`](@ref consts::AccessFlags_DEFAULT_MASK). enum class AccessFlags : ::std::uint32_t { READ = 0x1, WRITE = 0x2, @@ -30,7 +30,7 @@ namespace consts { static_assert(sizeof(AccessFlags) == 0x4); constexpr ::std::uint32_t AccessFlags_DEFAULT_MASK = 3; - /// Defaults to [`DEFAULT`](Color::DEFAULT). + /// Defaults to [`DEFAULT`](@ref consts::Color_DEFAULT). enum class Color : ::std::uint8_t { Red = 0, Green = 1, @@ -40,9 +40,9 @@ namespace consts { constexpr Color Color_DEFAULT = Color::Red; struct alignas(4) Player { - /// New players begin with [`STARTING_GOLD`](Player::STARTING_GOLD) gold, - /// spawn at x=[`SPAWN_X`](Player::SPAWN_X), and can hold up to - /// [`MAX_SLOTS`](Player::Inventory::MAX_SLOTS) items. + /// New players begin with [`STARTING_GOLD`](@ref consts::Player::STARTING_GOLD) gold, + /// spawn at x=[`SPAWN_X`](@ref consts::Player::SPAWN_X), and can hold up to + /// [`MAX_SLOTS`](@ref consts::Player::Inventory::MAX_SLOTS) items. ::std::int32_t health; static constexpr ::std::uint32_t STARTING_GOLD = 500; diff --git a/codegen_tests/output/cpp/include/doc_self_links.hpp b/codegen_tests/output/cpp/include/doc_self_links.hpp index 3e037c24..b7ed1c29 100644 --- a/codegen_tests/output/cpp/include/doc_self_links.hpp +++ b/codegen_tests/output/cpp/include/doc_self_links.hpp @@ -10,17 +10,17 @@ namespace doc_self_links { /// Test `Self::` links in type docs. /// - /// Link to a field as [`Self::field`](Self::field), to a method as - /// [`Self::method`](Self::method), to a nested type as - /// [`Self::Nested`](Self::Nested), and to a nested type's member as - /// [`Self::Nested::nested_field`](Self::Nested::nested_field). + /// Link to a field as [`Self::field`](@ref doc_self_links::Container::field), to a method as + /// [`Self::method`](@ref doc_self_links::Container::method), to a nested type as + /// [`Self::Nested`](@ref doc_self_links::Container::Nested), and to a nested type's member as + /// [`Self::Nested::nested_field`](@ref doc_self_links::Container::Nested::nested_field). struct alignas(4) Container { /// A field. ::std::uint32_t field; /// A method. void method() const; - /// A nested type. Its field is [`Self::nested_field`](Self::nested_field). + /// A nested type. Its field is [`Self::nested_field`](@ref doc_self_links::Container::Nested::nested_field). struct Nested { ::std::uint16_t nested_field; }; diff --git a/codegen_tests/output/cpp/include/extern_values.hpp b/codegen_tests/output/cpp/include/extern_values.hpp index 88cb9a6a..e676f36f 100644 --- a/codegen_tests/output/cpp/include/extern_values.hpp +++ b/codegen_tests/output/cpp/include/extern_values.hpp @@ -10,7 +10,7 @@ namespace extern_values { enum class RenderMode : ::std::uint8_t; struct Engine; - /// The active flag set is [`g_active`](DebugFlags::g_active). + /// The active flag set is [`g_active`](@ref extern_values::DebugFlags_get_g_active). enum class DebugFlags : ::std::uint32_t { WIREFRAME = 0x1, OVERDRAW = 0x2, @@ -31,7 +31,7 @@ namespace extern_values { DebugFlags*& DebugFlags_get_g_active(); struct alignas(4) Engine { - /// The live instance is [`g_instance`](Engine::g_instance). + /// The live instance is [`g_instance`](@ref extern_values::Engine::get_g_instance). ::std::uint32_t frame; static Engine*& get_g_instance(); @@ -39,7 +39,7 @@ namespace extern_values { static_assert(sizeof(Engine) == 0x4); static_assert(alignof(Engine) == 4); - /// The active mode is [`g_current`](RenderMode::g_current). + /// The active mode is [`g_current`](@ref extern_values::RenderMode_get_g_current). enum class RenderMode : ::std::uint8_t { Forward = 0, Deferred = 1, @@ -47,7 +47,7 @@ namespace extern_values { static_assert(sizeof(RenderMode) == 0x1); RenderMode*& RenderMode_get_g_current(); - /// The engine singleton, of type [`Engine`]. Advances with [`g_frame_count`]. + /// The engine singleton, of type [`Engine`](@ref extern_values::Engine). Advances with [`g_frame_count`](@ref extern_values::get_g_frame_count). Engine*& get_g_engine(); ::std::uint32_t& get_g_frame_count(); diff --git a/codegen_tests/output/cpp/include/nested_items.hpp b/codegen_tests/output/cpp/include/nested_items.hpp index 87faa165..b0829fbd 100644 --- a/codegen_tests/output/cpp/include/nested_items.hpp +++ b/codegen_tests/output/cpp/include/nested_items.hpp @@ -10,37 +10,37 @@ namespace nested_items { /// A type with nested declarations. /// - /// See [`InnerEnum`], [`InnerType`], [`InnerFlags`], and [`InnerAlias`]. + /// See [`InnerEnum`](@ref nested_items::Outer::InnerEnum), [`InnerType`](@ref nested_items::Outer::InnerType), [`InnerFlags`](@ref nested_items::Outer::InnerFlags), and [`InnerAlias`](@ref nested_items::Outer::InnerAlias). /// - /// You can also qualify them: [`Outer::InnerEnum`], [`Outer::InnerType`]. + /// You can also qualify them: [`Outer::InnerEnum`](@ref nested_items::Outer::InnerEnum), [`Outer::InnerType`](@ref nested_items::Outer::InnerType). struct alignas(4) Outer { ::std::uint32_t field; - /// An enum nested inside [`Outer`]. + /// An enum nested inside [`Outer`](@ref nested_items::Outer). /// - /// Variants: [`InnerEnum::A`], [`InnerEnum::B`], [`InnerEnum::C`]. + /// Variants: [`InnerEnum::A`](@ref nested_items::Outer::InnerEnum::A), [`InnerEnum::B`](@ref nested_items::Outer::InnerEnum::B), [`InnerEnum::C`](@ref nested_items::Outer::InnerEnum::C). enum class InnerEnum : ::std::uint8_t { A = 0, B = 1, C = 2, }; - /// A type nested inside [`Outer`]. + /// A type nested inside [`Outer`](@ref nested_items::Outer). /// - /// Its field is [`InnerType::inner_field`]. + /// Its field is [`InnerType::inner_field`](@ref nested_items::Outer::InnerType::inner_field). struct InnerType { ::std::uint16_t inner_field; }; - /// Bitflags nested inside [`Outer`]. + /// Bitflags nested inside [`Outer`](@ref nested_items::Outer). /// - /// Members: [`InnerFlags::FLAG_A`], [`InnerFlags::FLAG_B`]. + /// Members: [`InnerFlags::FLAG_A`](@ref nested_items::Outer::InnerFlags::FLAG_A), [`InnerFlags::FLAG_B`](@ref nested_items::Outer::InnerFlags::FLAG_B). struct InnerFlags { static constexpr ::std::uint32_t FLAG_A = 1; static constexpr ::std::uint32_t FLAG_B = 2; }; - /// A type alias nested inside [`Outer`]. + /// A type alias nested inside [`Outer`](@ref nested_items::Outer). using InnerAlias = ::std::uint32_t; }; static_assert(sizeof(Outer) == 0x4); diff --git a/codegen_tests/output/cpp/include/qualified_links_consumer.hpp b/codegen_tests/output/cpp/include/qualified_links_consumer.hpp index 3718f98d..478030e9 100644 --- a/codegen_tests/output/cpp/include/qualified_links_consumer.hpp +++ b/codegen_tests/output/cpp/include/qualified_links_consumer.hpp @@ -10,8 +10,8 @@ namespace qualified_links_consumer { /// Test module-qualified doc-links to functions and extern values. /// - /// See [`shared_function`](qualified_links_provider::shared_function) and - /// [`shared_extern`](qualified_links_provider::shared_extern). + /// See [`shared_function`](@ref qualified_links_provider::shared_function) and + /// [`shared_extern`](@ref qualified_links_provider::get_shared_extern). struct alignas(1) Consumer { ::std::uint8_t _marker; }; diff --git a/codegen_tests/output/cpp/include/world_consumer.hpp b/codegen_tests/output/cpp/include/world_consumer.hpp index c95bc9b8..7cec07b7 100644 --- a/codegen_tests/output/cpp/include/world_consumer.hpp +++ b/codegen_tests/output/cpp/include/world_consumer.hpp @@ -16,7 +16,7 @@ namespace world_consumer { struct WorldConsumer; /// Consumes the world. Doc-links a type from a deeper, un-imported module - /// ([`DeepMarker`](world::deep::marker::DeepMarker)) to exercise cross-module + /// ([`DeepMarker`](@ref world::deep::marker::DeepMarker)) to exercise cross-module /// doc-link resolution: the link destination must be rewritten to an absolute /// crate path so rustdoc resolves it without a `use` import. struct alignas(8) WorldConsumer { diff --git a/docs/cpp_backend.md b/docs/cpp_backend.md index 0e0b615b..e210cf89 100644 --- a/docs/cpp_backend.md +++ b/docs/cpp_backend.md @@ -270,6 +270,18 @@ with `CppBackendError::LayoutCycle` if it finds one. Break the cycle by introducing a pointer indirection or moving a type into a separate module. +## Doc comments and doxygen links + +Doc comments are emitted as `///` comments, which doxygen picks up natively. Rustdoc-style intra-doc links are rewritten from their resolved semantic targets into doxygen-markdown references: + +```cpp +/// Link to a field as [`Self::field`](@ref doc_self_links::Container::field). +``` + +Rendering follows the C++ emission rules rather than the written path: module segments become namespaces, nested types stay genuinely nested (`ns::Outer::Inner`), extern values map to their `get_()` accessor, and nested constants/extern values under an enum or bitflags parent — which the emitter flattens to module scope, since those have no struct body — become `ns::Parent_NAME` / `ns::Parent_get_name`. Links to predefined primitives and out-of-tree extern types have no documented entity to reference and are flattened to their bare label instead of emitting a dead link. + +`test.py` verifies the emitted corpus with doxygen (any warning, including an unresolvable `@ref`, fails the suite). The check is skipped with a warning when doxygen isn't installed; `shell.nix` provides it. + ## Building on Linux The emitted `CMakeLists.txt` is normative — no toolchain assumptions. diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..2b4e14e6 --- /dev/null +++ b/shell.nix @@ -0,0 +1,14 @@ +# Dev-shell extras for pyxis — tools `python test.py` uses beyond the Rust +# toolchain (which contributors typically manage via rustup). +# +# Currently that's doxygen, for the C++ doc-link check: test.py runs doxygen +# over the emitted C++ corpus and fails on unresolved `@ref`s. Without it the +# check is skipped with a warning. +{ + pkgs ? import { }, +}: +pkgs.mkShell { + packages = with pkgs; [ + doxygen + ]; +} diff --git a/src/backends/cpp/mod.rs b/src/backends/cpp/mod.rs index d13d82ed..ed309a13 100644 --- a/src/backends/cpp/mod.rs +++ b/src/backends/cpp/mod.rs @@ -672,7 +672,13 @@ fn write_module( let cfg_ctx = crate::parser::cfg::CfgContext { backend: crate::Backend::Cpp, }; - let ctx = render::RenderCtx::new(key, registry, bindings, cfg_ctx); + let ctx = render::RenderCtx::new( + key, + registry, + bindings, + cfg_ctx, + semantic_state.module_doc_links(key), + ); let module_deps = deps::collect_module_deps(key, module, registry, bindings); let splices = extract_cpp_splices(module); let body = render_module_body(key, module, registry, bindings, ctx)?; diff --git a/src/backends/cpp/render.rs b/src/backends/cpp/render.rs index 69c155ba..792f243e 100644 --- a/src/backends/cpp/render.rs +++ b/src/backends/cpp/render.rs @@ -40,6 +40,9 @@ pub struct RenderCtx<'a> { pub registry: &'a TypeRegistry, pub bindings: &'a BTreeMap, pub cfg_ctx: crate::parser::cfg::CfgContext, + /// Resolved intra-doc links of the module being rendered, for rewriting + /// doc-comment links into doxygen `@ref`s. + pub doc_links: &'a crate::semantic::doc_links::ModuleDocLinks, /// Member names of the class currently being rendered, if any. A /// same-module type reference whose leaf is in this set collides with a /// member and must be emitted qualified (see [`render_path`]). `None` @@ -53,12 +56,14 @@ impl<'a> RenderCtx<'a> { registry: &'a TypeRegistry, bindings: &'a BTreeMap, cfg_ctx: crate::parser::cfg::CfgContext, + doc_links: &'a crate::semantic::doc_links::ModuleDocLinks, ) -> Self { Self { module_path, registry, bindings, cfg_ctx, + doc_links, shadowed_members: None, } } @@ -134,9 +139,11 @@ pub fn render_item(item: &ItemDefinition, ctx: RenderCtx) -> Result { - let (mut decl, mut post_cpp) = render_enum(&name, ed, resolved.size, ctx)?; + let (mut decl, mut post_cpp) = + render_enum(&name, ed, resolved.size, ctx, &item.location)?; // Enums have no struct body for static members, so nested value items // are flattened to module scope: `constexpr` consts in the header, // extern-value getters declared in the header and defined in the `.cpp`. @@ -148,7 +155,8 @@ pub fn render_item(item: &ItemDefinition, ctx: RenderCtx) -> Result { - let (mut decl, mut post_cpp) = render_bitflags(&name, bd, resolved.size, ctx)?; + let (mut decl, mut post_cpp) = + render_bitflags(&name, bd, resolved.size, ctx, &item.location)?; render_nested_values_cpp_flat(&mut decl, &mut post_cpp, ctx, &item.path, &name)?; RenderedItem { decl, @@ -157,14 +165,14 @@ pub fn render_item(item: &ItemDefinition, ctx: RenderCtx) -> Result RenderedItem { - decl: render_type_alias(&name, ta, ctx, &item.type_parameters)?, + decl: render_type_alias(&name, ta, ctx, &item.type_parameters, &item.location)?, post_header: String::new(), post_cpp: String::new(), }, - ItemDefinitionInner::Constant(cd) => render_const(&name, cd, ctx)?, + ItemDefinitionInner::Constant(cd) => render_const(&name, cd, ctx, &item.location)?, ItemDefinitionInner::ExternValue(ev) => { let mut decl = String::new(); - render_doc(&mut decl, &ev.doc, 0)?; + render_doc(&mut decl, &ev.doc, 0, ctx, &item.location)?; decl.push_str(&render_extern_value_decl(&name, ev, ctx)?); let post_cpp = render_extern_value_definition(&name, ev, ctx)?; RenderedItem { @@ -189,6 +197,7 @@ fn template_clause(type_parameters: &[String]) -> String { format!("template <{params}>\n") } +#[allow(clippy::too_many_arguments)] fn render_struct( name: &str, td: &TypeDefinition, @@ -197,6 +206,7 @@ fn render_struct( ctx: RenderCtx, visibility: Visibility, type_parameters: &[String], + location: &ItemLocation, ) -> Result { let name = &*cpp_ident(name); let is_generic = !type_parameters.is_empty(); @@ -221,7 +231,7 @@ fn render_struct( let ctx = ctx.with_shadowed_members(&shadowed_members); let mut out = String::new(); - render_doc(&mut out, &td.doc, 0)?; + render_doc(&mut out, &td.doc, 0, ctx, location)?; if td.packed { writeln!(out, "#pragma pack(push, 1)")?; } @@ -337,7 +347,7 @@ fn render_struct( let nested_name = cpp_ident(&nested_name); match &nested_resolved.inner { ItemDefinitionInner::Type(nested_td) => { - render_doc(&mut body, &nested_td.doc, 1)?; + render_doc(&mut body, &nested_td.doc, 1, ctx, &nested_item.location)?; writeln!(body, " struct {nested_name} {{")?; let nested_had_fields = !nested_td.regions.is_empty(); for region in &nested_td.regions { @@ -371,7 +381,13 @@ fn render_struct( .map(|s| s.as_str().to_string()) .unwrap_or_default(); let nested_const_name = cpp_ident(&nested_const_name); - render_doc(&mut body, &nested_cd.doc, 2)?; + render_doc( + &mut body, + &nested_cd.doc, + 2, + ctx, + &nested_nested_item.location, + )?; let bf_type = render_type(&nested_cd.type_, ctx)?; let value_str = format_const_value(&nested_cd.value, &nested_cd.type_); @@ -384,7 +400,7 @@ fn render_struct( writeln!(body, " }};")?; } ItemDefinitionInner::Enum(nested_ed) => { - render_doc(&mut body, &nested_ed.doc, 1)?; + render_doc(&mut body, &nested_ed.doc, 1, ctx, &nested_item.location)?; writeln!( body, " enum class {nested_name} : {} {{", @@ -401,7 +417,7 @@ fn render_struct( writeln!(body, " }};")?; } ItemDefinitionInner::Bitflags(nested_bd) => { - render_doc(&mut body, &nested_bd.doc, 1)?; + render_doc(&mut body, &nested_bd.doc, 1, ctx, &nested_item.location)?; writeln!(body, " struct {nested_name} {{")?; let bf_type = render_type(&nested_bd.type_, ctx)?; for flag in &nested_bd.flags { @@ -415,7 +431,7 @@ fn render_struct( writeln!(body, " }};")?; } ItemDefinitionInner::TypeAlias(nested_ta) => { - render_doc(&mut body, &nested_ta.doc, 1)?; + render_doc(&mut body, &nested_ta.doc, 1, ctx, &nested_item.location)?; writeln!( body, " using {nested_name} = {};", @@ -423,7 +439,7 @@ fn render_struct( )?; } ItemDefinitionInner::Constant(nested_cd) => { - render_doc(&mut body, &nested_cd.doc, 1)?; + render_doc(&mut body, &nested_cd.doc, 1, ctx, &nested_item.location)?; let bf_type = render_type(&nested_cd.type_, ctx)?; let value_str = format_const_value(&nested_cd.value, &nested_cd.type_); // For scalar/POD types, use `static constexpr` with @@ -456,7 +472,7 @@ fn render_struct( // Declared here; defined out-of-class below (in the // `.cpp` for non-templates, like the singleton // accessor and member functions). - render_doc(&mut body, &nested_ev.doc, 1)?; + render_doc(&mut body, &nested_ev.doc, 1, ctx, &nested_item.location)?; let ev_type = render_type(&nested_ev.type_, ctx)?; writeln!(body, " static {ev_type}& get_{nested_name}();")?; } @@ -639,7 +655,7 @@ fn render_method_signature(out: &mut String, func: &Function, ctx: RenderCtx) -> if func.name.starts_with("_vfunc_") { return Ok(()); } - render_doc(out, &func.doc, 1)?; + render_doc(out, &func.doc, 1, ctx, &func.location)?; let (return_text, sig_args_text, const_qual) = method_sig_parts(func, ctx)?; let static_kw = if func_has_self(func) { "" } else { "static " }; // Method-level template parameters (e.g. `Y` in @@ -852,7 +868,7 @@ fn render_field_indented( indent: usize, ) -> Result<()> { let pad = " ".repeat(indent * 4); - render_doc(out, ®ion.doc, indent)?; + render_doc(out, ®ion.doc, indent, ctx, ®ion.location)?; let Some(field_name) = region.name.as_deref() else { // Should not happen post-resolution, but be defensive. writeln!(out, "{pad}// ")?; @@ -941,10 +957,11 @@ fn render_enum( ed: &EnumDefinition, size: usize, ctx: RenderCtx, + location: &ItemLocation, ) -> Result<(String, String)> { let name = &*cpp_ident(name); let mut out = String::new(); - render_doc(&mut out, &ed.doc, 0)?; + render_doc(&mut out, &ed.doc, 0, ctx, location)?; let underlying = render_type(&ed.type_, ctx)?; writeln!(out, "enum class {name} : {underlying} {{")?; for variant in &ed.variants { @@ -978,10 +995,11 @@ fn render_bitflags( bd: &BitflagsDefinition, size: usize, ctx: RenderCtx, + location: &ItemLocation, ) -> Result<(String, String)> { let name = &*cpp_ident(name); let mut out = String::new(); - render_doc(&mut out, &bd.doc, 0)?; + render_doc(&mut out, &bd.doc, 0, ctx, location)?; let underlying = render_type(&bd.type_, ctx)?; writeln!(out, "enum class {name} : {underlying} {{")?; for flag in &bd.flags { @@ -1049,10 +1067,11 @@ fn render_type_alias( ta: &TypeAliasDefinition, ctx: RenderCtx, type_parameters: &[String], + location: &ItemLocation, ) -> Result { let name = &*cpp_ident(name); let mut out = String::new(); - render_doc(&mut out, &ta.doc, 0)?; + render_doc(&mut out, &ta.doc, 0, ctx, location)?; let target = render_type(&ta.target, ctx)?; let template = template_clause(type_parameters); writeln!(out, "{template}using {name} = {target};")?; @@ -1091,7 +1110,7 @@ fn render_nested_values_cpp_flat( } _ => "constexpr", }; - render_doc(decl_out, &cd.doc, 0)?; + render_doc(decl_out, &cd.doc, 0, ctx, &item.location)?; writeln!(decl_out, "{storage} {type_str} {flat_name} = {value_str};")?; } ItemDefinitionInner::ExternValue(ev) => { @@ -1099,7 +1118,7 @@ fn render_nested_values_cpp_flat( // module-level extern-value getters and the singleton accessor. let flat_name = format!("{parent_name}_get_{}", cpp_ident(value_name)); let type_str = render_type(&ev.type_, ctx)?; - render_doc(decl_out, &ev.doc, 0)?; + render_doc(decl_out, &ev.doc, 0, ctx, &item.location)?; writeln!(decl_out, "{type_str}& {flat_name}();")?; writeln!(cpp_out, "{type_str}& {flat_name}() {{")?; writeln!( @@ -1217,10 +1236,15 @@ fn format_const_value(value: &ConstValue, type_: &Type) -> String { } } -fn render_const(name: &str, cd: &SemanticConstDefinition, ctx: RenderCtx) -> Result { +fn render_const( + name: &str, + cd: &SemanticConstDefinition, + ctx: RenderCtx, + location: &ItemLocation, +) -> Result { let name = &*cpp_ident(name); let mut decl = String::new(); - render_doc(&mut decl, &cd.doc, 0)?; + render_doc(&mut decl, &cd.doc, 0, ctx, location)?; let type_str = render_type(&cd.type_, ctx)?; let value_str = format_const_value(&cd.value, &cd.type_); // Use `constexpr` for scalar/POD types, `inline const` for @@ -1246,7 +1270,7 @@ fn render_const(name: &str, cd: &SemanticConstDefinition, ctx: RenderCtx) -> Res /// declaration whose body is supplied by the user's `backend cpp` block. pub fn render_free_function_decl(func: &Function, ctx: RenderCtx) -> Result> { let mut out = String::new(); - render_doc(&mut out, &func.doc, 0)?; + render_doc(&mut out, &func.doc, 0, ctx, &func.location)?; let name = cpp_ident(&func.name); match &func.body { FunctionBody::Address { .. } => { @@ -1353,7 +1377,14 @@ pub fn render_extern_value_definition( )) } -fn render_doc(out: &mut String, doc: &[String], indent_levels: usize) -> Result<()> { +fn render_doc( + out: &mut String, + doc: &[String], + indent_levels: usize, + ctx: RenderCtx, + location: &ItemLocation, +) -> Result<()> { + let links = ctx.doc_links.at(location); let pad = " ".repeat(indent_levels); for line in doc { let trimmed = line.trim(); @@ -1361,12 +1392,152 @@ fn render_doc(out: &mut String, doc: &[String], indent_levels: usize) -> Result< // Blank doc line - emit just `///` with no trailing space. writeln!(out, "{pad}///")?; } else { - writeln!(out, "{pad}/// {trimmed}")?; + let rewritten = rewrite_doc_links(trimmed, links, ctx); + writeln!(out, "{pad}/// {rewritten}")?; } } Ok(()) } +/// Rewrite each resolved intra-doc link in `line` into a doxygen-resolvable +/// form: the markdown destination becomes `@ref ` +/// (`[`field`](Self::field)` → `[`field`](@ref ns::Container::field)`), and a +/// code shortcut becomes an inline link so its written label survives. +/// Doxygen's markdown support resolves `[label](@ref target)` to the target's +/// documentation. +/// +/// Links whose target has no documented C++ entity (predefined primitives, +/// externs bound to out-of-tree types) are flattened to their bare label — +/// leaving the raw path as a markdown destination would render a dead +/// `href="Path::To"` link. +fn rewrite_doc_links( + line: &str, + links: &[crate::semantic::doc_links::ResolvedDocLink], + ctx: RenderCtx, +) -> String { + use crate::semantic::doc_links::{DocLinkSyntax, scan_links}; + if links.is_empty() { + return line.to_string(); + } + let mut result = line.to_string(); + let mut scanned = scan_links(line); + scanned.retain(|l| l.syntax != DocLinkSyntax::PlainShortcut); + for link in scanned.into_iter().rev() { + let Some(resolved) = links.iter().find(|r| r.text == link.path) else { + continue; + }; + let label = &line[link.label_region.0..link.label_region.1]; + let replacement = match doxygen_ref(&resolved.target, ctx) { + Some(target) => format!("[{label}](@ref {target})"), + None => label.to_string(), + }; + result.replace_range(link.link.0..link.link.1, &replacement); + } + result +} + +/// The fully-qualified C++ name of a resolved link target, for a doxygen +/// `@ref` — or `None` when no documented C++ entity exists for it. +/// +/// Module segments are namespaces and nested types stay genuinely nested in +/// C++, so the item path maps segment-for-segment (with identifier escaping). +/// Extern values map to their `get_` accessor. +fn doxygen_ref( + target: &crate::semantic::doc_links::DocLinkTarget, + ctx: RenderCtx, +) -> Option { + use crate::semantic::doc_links::{DocLinkMemberKind, DocLinkTarget}; + + let qualify = |path: &ItemPath| -> Option { + // Predefined items are C++ primitives; externs may be bound to + // out-of-tree types. Neither has documentation to reference. + if let Ok(item) = ctx + .registry + .get(path, &crate::span::ItemLocation::internal()) + { + if item.predefined.is_some() + || matches!(item.category, crate::semantic::types::ItemCategory::Extern) + { + return None; + } + } + let last = path.len().saturating_sub(1); + Some( + path.iter() + .enumerate() + .map(|(i, seg)| { + if i == last { + cpp_ident(seg.as_str()).into_owned() + } else { + cpp_namespace_ident(seg.as_str()).into_owned() + } + }) + .collect::>() + .join("::"), + ) + }; + + match target { + DocLinkTarget::Item(path) => qualify(path), + DocLinkTarget::Member { item, name, kind } => { + let base = qualify(item)?; + // Nested constants/extern values under an enum or bitflags parent + // have no struct body to live in; the emitter flattens them to + // module scope as `Parent_NAME` / `Parent_get_name()` (see + // `render_nested_values_cpp_flat`). Mirror that here. + let parent_is_bodyless = ctx + .registry + .get(item, &crate::span::ItemLocation::internal()) + .ok() + .and_then(|i| i.resolved()) + .is_some_and(|r| { + matches!( + r.inner, + ItemDefinitionInner::Enum(_) | ItemDefinitionInner::Bitflags(_) + ) + }); + let value_member = matches!( + kind, + DocLinkMemberKind::Constant | DocLinkMemberKind::ExternValue + ); + let accessor = |name: &str| match kind { + DocLinkMemberKind::ExternValue => format!("get_{}", cpp_ident(name)), + _ => cpp_ident(name).into_owned(), + }; + Some(if parent_is_bodyless && value_member { + format!("{base}_{}", accessor(name)) + } else { + format!("{base}::{}", accessor(name)) + }) + } + DocLinkTarget::Function { module, name } => { + let ns = module + .iter() + .map(|s| cpp_namespace_ident(s.as_str()).into_owned()) + .collect::>() + .join("::"); + Some(if ns.is_empty() { + cpp_ident(name).into_owned() + } else { + format!("{ns}::{}", cpp_ident(name)) + }) + } + DocLinkTarget::ExternValue { module, name } => { + let ns = module + .iter() + .map(|s| cpp_namespace_ident(s.as_str()).into_owned()) + .collect::>() + .join("::"); + let accessor = format!("get_{}", cpp_ident(name)); + Some(if ns.is_empty() { + accessor + } else { + format!("{ns}::{accessor}") + }) + } + } +} + /// Render a `Type` as a C++ type expression. For arrays the caller is /// responsible for placing the `[N]` suffix after the field name. pub fn render_type(ty: &Type, ctx: RenderCtx) -> Result { diff --git a/test.py b/test.py index 01f22fd6..8e9c272d 100644 --- a/test.py +++ b/test.py @@ -5,8 +5,10 @@ """ import os +import shutil import sys import subprocess +import tempfile def run_command(cmd, env=None, cwd=None, shell=False): @@ -24,6 +26,56 @@ def run_command(cmd, env=None, cwd=None, shell=False): return result +def check_cpp_docs(): + """Run doxygen over codegen_tests/output/cpp and fail on any warning. + + The emitted headers carry doxygen-markdown doc links (`[label](@ref + ns::Target)`); an unresolvable `@ref` surfaces as a doxygen warning, so + warnings are treated as errors. `EXTRACT_ALL` keeps undocumented items + from warning — only genuine doc problems remain. + """ + print(f"\n{'=' * 60}") + print("Running: doxygen (C++ doc-link check on codegen_tests/output/cpp)") + print(f"{'=' * 60}\n") + + cpp_dir = os.path.abspath(os.path.join("codegen_tests", "output", "cpp")) + with tempfile.TemporaryDirectory(prefix="pyxis-doxygen-") as tmp: + warn_log = os.path.join(tmp, "warnings.log") + doxyfile = os.path.join(tmp, "Doxyfile") + with open(doxyfile, "w") as f: + f.write( + "\n".join( + [ + "PROJECT_NAME = pyxis-codegen-tests", + f"OUTPUT_DIRECTORY = {tmp}", + f"INPUT = {os.path.join(cpp_dir, 'include')}", + "FILE_PATTERNS = *.hpp", + "RECURSIVE = YES", + "EXTRACT_ALL = YES", + "GENERATE_HTML = YES", + "GENERATE_LATEX = NO", + "QUIET = YES", + "WARNINGS = YES", + "WARN_IF_UNDOCUMENTED = NO", + "WARN_IF_DOC_ERROR = YES", + f"WARN_LOGFILE = {warn_log}", + ] + ) + + "\n" + ) + result = subprocess.run(["doxygen", doxyfile]) + warnings = "" + if os.path.exists(warn_log): + with open(warn_log) as f: + warnings = f.read().strip() + if result.returncode != 0 or warnings: + if warnings: + print(warnings) + print("\n[FAIL] doxygen reported problems in the emitted C++ docs") + sys.exit(1) + print("\n[PASS] Command succeeded") + + def main(): # Get command line arguments for cargo test test_args = sys.argv[1:] @@ -76,6 +128,19 @@ def main(): doc_env["RUSTDOCFLAGS"] = "-Dwarnings" run_command(["cargo", "doc", "--no-deps", "-p", "codegen_tests"], env=doc_env) + # Run doxygen over the emitted C++ corpus (catches unresolved `@ref`s in + # the rewritten doc links — the C++ analogue of the cargo doc gate above). + # The output is generated into a temp dir and discarded; only the warnings + # matter. Skipped with a warning when doxygen isn't installed (it's in + # shell.nix, and CI installs it). + if shutil.which("doxygen"): + check_cpp_docs() + else: + print(f"\n{'=' * 60}") + print("[SKIP] doxygen not found on PATH — C++ doc-link check skipped.") + print(" Install doxygen (see shell.nix) to run it locally.") + print(f"{'=' * 60}\n") + # Lint the viewer (tsc + eslint + prettier). This assumes the workspace # deps are already installed — CI runs `npm ci` before test.py, and local # devs are expected to `npm install` themselves. `shell=True` on Windows so From 4ed21bded04209e3686e33ca53748672c1e66405 Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 09:28:36 +0200 Subject: [PATCH 07/10] Prune dead doc-link API and refresh doc-link documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove DocLinkTarget::import_path/extern_value_path and the ModuleDocLinks imports/extern-value accessors — all consumers now render targets directly. Update docs/rust_backend.md and docs/language.md for the resolve-once pipeline, Self:: and module-qualified link forms, and per-backend target rendering. --- docs/language.md | 4 ++- docs/rust_backend.md | 10 ++++--- src/semantic/doc_links.rs | 61 ++++----------------------------------- 3 files changed, 15 insertions(+), 60 deletions(-) diff --git a/docs/language.md b/docs/language.md index 4713e60d..d7c67f14 100644 --- a/docs/language.md +++ b/docs/language.md @@ -900,9 +900,11 @@ pub type Outer { Link forms: - `[`Item`]` - shortcut link to an item in scope. - `[`Item::Field`]` - link to a field, variant, or method. +- `[`Self::member`]` - link to a member of the enclosing type. Inside a nested item, `Self` refers to the nested item; in a nested constant or extern value's doc, it refers to the parent type. +- `[`module::function`]` - module-qualified link to a freestanding function or extern value. - `[`display text`](Path)` - inline link with custom display text. -The Rust backend rewrites links to fully-qualified `crate::` paths at emission time, adding cross-module imports as needed. The JSON backend resolves links to absolute paths and attaches them as structured `doc_links` data so the viewer can render clickable references. +Every link is resolved once during semantic analysis (an unresolvable link is a build error), and each backend renders the resolved target in its own idiom: the Rust backend rewrites destinations to fully-qualified `crate::` paths, the C++ backend rewrites them to doxygen `[label](@ref ns::Target)` references, and the JSON backend attaches them as structured `doc_links` data so the viewer can render clickable references. ### Unicode diff --git a/docs/rust_backend.md b/docs/rust_backend.md index ff4ebc16..1ba948ab 100644 --- a/docs/rust_backend.md +++ b/docs/rust_backend.md @@ -124,15 +124,17 @@ Rust permits multiple `impl Foo` blocks, so the user's epilogue can host its own ## Doc link rewriting -Rustdoc-style intra-doc links (`[`Item`]`, `[`Item::Field`]`, `[`display text`](Path)`) are rewritten to fully-qualified `crate::` paths at emission time. +Rustdoc-style intra-doc links (`[`Item`]`, `[`Item::Field`]`, `[`Self::member`]`, `[`display text`](Path)`) are rewritten at emission time by rendering each link's *resolved target* as an absolute Rust path. The process: -1. The semantic resolver collects all doc links in each module and resolves them to target item paths. -2. Cross-module links trigger imports - the target's defining module is added to the current module's `use` list. -3. Nested items (types declared inside other types) are flattened in Rust (`Outer::Inner` → `Outer_Inner`), so doc links referencing nested items are rewritten to their flattened names. +1. Semantic analysis resolves every doc link once, crate-wide, and stores the results per doc block (`doc_links::resolve_all`). Unresolvable links fail the build there. +2. The backend renders each resolved target as an absolute `crate::` path: nested items are flattened (`Outer::Inner` → `crate::module::Outer_Inner`), extern values become their `get_` accessor path, and members append to their owner's rendered path. Links to predefined types (`u32`, ...) are left alone — rustdoc resolves primitives natively. +3. An inline link keeps its label and has its destination replaced; a code shortcut (`` [`Inner`] ``) becomes an inline link (`` [`Inner`](crate::module::Outer_Inner) ``) so the written label survives. 4. The rewritten links are emitted in the `#[doc = "..."]` attribute strings. +Because every destination is absolute, no doc-driven `use` imports or aliases are emitted, and rewriting never depends on how the link was written — only on what it resolved to. The generated root allows `rustdoc::redundant_explicit_links`, since the uniform rewrite is "redundant" for the subset of links rustdoc could have resolved from the label alone. + The link detection is shared between the compiler and the LSP via `scan_links`, so hover previews and generated docs see the same link resolution. ## Extern type bindings diff --git a/src/semantic/doc_links.rs b/src/semantic/doc_links.rs index 3e1035c4..d87f8edb 100644 --- a/src/semantic/doc_links.rs +++ b/src/semantic/doc_links.rs @@ -1,10 +1,12 @@ //! Resolution of rustdoc-style intra-doc links embedded in doc comments, e.g. -//! `[`Type`]`, `[`Type::method`]`, `[`Enum::VARIANT`]`, and the inline form +//! `[`Type`]`, `[`Type::method`]`, `[`Self::member`]`, and the inline form //! `` [label](Type::method) ``. //! -//! Resolution happens at the semantic layer (after every item and function is -//! resolved) so links are validated up-front; the resolver is then reused by -//! the backends to surface the resolved targets. +//! Every link is resolved exactly once, during semantic analysis +//! ([`resolve_all`]) — validation is that pass's failure path, and the +//! resulting per-module [`ModuleDocLinks`] tables (keyed by doc block) are +//! what the backends consume to rewrite or surface links. The [`DocLinkResolver`] +//! itself is also used live by the LSP to resolve links at edit time. use std::collections::{BTreeMap, BTreeSet}; @@ -36,43 +38,6 @@ pub enum DocLinkTarget { ExternValue { module: ItemPath, name: String }, } -impl DocLinkTarget { - /// The absolute path of the base item/function/extern to import so a Rust - /// consumer (rustdoc) can resolve the link. - pub fn import_path(&self) -> ItemPath { - match self { - DocLinkTarget::Item(path) => path.clone(), - DocLinkTarget::Member { item, .. } => item.clone(), - DocLinkTarget::Function { module, name } - | DocLinkTarget::ExternValue { module, name } => { - module.join(ItemPathSegment::from(name.clone())) - } - } - } - - /// If this link points at an extern value, its full item path — `module::name` - /// for a module-level one, `Parent::name` for a nested one. `None` for any - /// other target. - /// - /// Extern values emit as `get_` accessors rather than an item named - /// ``, so a backend can't just resolve the logical path; it needs the - /// value's path to compute the accessor's path (see the Rust backend's doc - /// link rewriting). - pub fn extern_value_path(&self) -> Option { - match self { - DocLinkTarget::ExternValue { module, name } => { - Some(module.join(ItemPathSegment::from(name.clone()))) - } - DocLinkTarget::Member { - item, - name, - kind: DocLinkMemberKind::ExternValue, - } => Some(item.join(ItemPathSegment::from(name.clone()))), - _ => None, - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DocLinkMemberKind { Method, @@ -168,20 +133,6 @@ impl ModuleDocLinks { pub fn iter(&self) -> impl Iterator { self.by_block.values().flatten() } - - /// Absolute paths of every item/function/extern referenced by a link — the - /// set the Rust backend imports so rustdoc resolves the links. - pub fn imports(&self) -> BTreeSet { - self.iter().map(|l| l.target.import_path()).collect() - } - - /// `(written link text, extern-value item path)` for each link pointing at - /// an extern value. The Rust backend rewrites these destinations to the - /// emitted `get_` accessor rather than the value's logical name. - pub fn extern_value_links(&self) -> impl Iterator { - self.iter() - .filter_map(|l| Some((l.text.as_str(), l.target.extern_value_path()?))) - } } /// The resolved doc links of every module in the crate, keyed by module path. From 2bc8ce1b7610fadcb51798782657b7428caf50d9 Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 19:12:19 +0200 Subject: [PATCH 08/10] Anchor same-module preference at the module, not the scope head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolution scopes are not just module paths: docs inside a type resolve under an augmented scope with the type's own path prepended (for bare nested-item references), plus use-imported item paths. find_item and find_in_modules assumed "first scope entry = current module", so for type-scoped doc blocks same-module preference anchored at the *type* path and the crate-wide bare-name search fell through to alphabetical order — picking a same-named type from another module and failing the member lookup. Caught by pyxis-defs in CI (`RenderContext::…` resolving to graphics_engine's RenderContext instead of ui::scaleform's). The resolver now locates the current module explicitly as the first scope entry that is a module. --- src/semantic/doc_links.rs | 22 +++++++++--- src/semantic/tests/doc_links.rs | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/semantic/doc_links.rs b/src/semantic/doc_links.rs index d87f8edb..8acbd25a 100644 --- a/src/semantic/doc_links.rs +++ b/src/semantic/doc_links.rs @@ -424,16 +424,30 @@ impl DocLinkResolver { None } + /// The current module within a resolution scope: the first entry that + /// actually is a module. + /// + /// A scope is not just module paths — resolving a doc block inside a type + /// prepends the type's own path (so bare references to its nested items + /// resolve), and the tail carries `use`-imported item paths. Assuming + /// "first entry = current module" mis-anchors same-module preference at + /// the type path for those blocks, silently changing which of several + /// same-named crate-wide candidates wins. + fn current_module<'s>(&self, scope: &'s [ItemPath]) -> Option<&'s ItemPath> { + scope + .iter() + .find(|ip| self.module_functions.contains_key(ip)) + } + /// Find the module that declares a member with `name` in `by_module`, - /// preferring the current module (scope's first entry). + /// preferring the current module. fn find_in_modules( &self, scope: &[ItemPath], by_module: &BTreeMap>, name: &str, ) -> Option { - scope - .first() + self.current_module(scope) .filter(|m| { by_module .get(m) @@ -453,7 +467,7 @@ impl DocLinkResolver { /// imports doc-referenced types regardless of the current `use`s; a /// `::`-qualified name is resolved root- or scope-relative. fn find_item(&self, scope: &[ItemPath], segments: &[ItemPathSegment]) -> Option { - let from_module = scope.first(); + let from_module = self.current_module(scope); if segments.len() == 1 { // 1. A type directly imported into scope wins. diff --git a/src/semantic/tests/doc_links.rs b/src/semantic/tests/doc_links.rs index 99d078a3..0b9d6697 100644 --- a/src/semantic/tests/doc_links.rs +++ b/src/semantic/tests/doc_links.rs @@ -696,3 +696,65 @@ fn resolves_self_in_nested_type_field_doc() { } ); } + +#[test] +fn prefers_same_module_candidate_from_type_scoped_docs() { + // Two modules each define a type named `Context`; only module `b`'s has + // the field. A doc on a member of another type in `b` resolves under the + // *augmented* type scope (the type's own path is prepended for nested-item + // references), so the resolver must still anchor same-module preference at + // the module — not at whatever sits first in the scope list. Regression: + // the alphabetically-first `a::Context` (no such field) was picked and the + // link failed to resolve. + let module_a = M::new().with_definitions([ID::new( + (V::Public, "Context"), + TD::new([ + TS::field((V::Public, "unrelated"), T::ident("u32")).with_attributes([A::address(0)]) + ]) + .with_attributes([A::size(4), A::align(4)]), + )]); + let module_b = M::new() + .with_definitions([ + ID::new( + (V::Public, "Context"), + TD::new([TS::field((V::Public, "latch"), T::ident("u32")) + .with_attributes([A::address(0)])]) + .with_attributes([A::size(4), A::align(4)]), + ), + ID::new( + (V::Public, "Handle"), + TD::new([TS::field((V::Public, "data"), T::ident("u32")) + .with_attributes([A::address(0)])]) + .with_attributes([A::size(4), A::align(4)]), + ), + ]) + .with_impls([FB::new( + "Handle", + [F::new((V::Public, "consume"), [Ar::mut_self()]) + .with_attributes([A::address(0x10)]) + .with_doc_comments(vec![ + " Short-circuits on [`latch`](Context::latch).".to_string(), + ])], + )]); + + let mut builder = SemanticBuilder::new(pointer_size()); + builder.add_module(&module_a, &IP::from("a")).unwrap(); + builder.add_module(&module_b, &IP::from("b")).unwrap(); + let state = builder + .build() + .expect("Context::latch resolves to b::Context"); + + let link = state + .module_doc_links(&IP::from("b")) + .iter() + .find(|l| l.text == "Context::latch") + .expect("link recorded"); + assert_eq!( + link.target, + DocLinkTarget::Member { + item: IP::from("b::Context"), + name: "latch".to_string(), + kind: DocLinkMemberKind::Field, + } + ); +} From 11cdcacf8e92a08881c1fb990983476288886a74 Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 19:12:19 +0200 Subject: [PATCH 09/10] Pin HAVE_DOT off in the doxygen doc-link check Some doxygen builds (Ubuntu's, on CI runners with graphviz present) auto-enable dot and then fail on their own generated map files. The check only cares about ref resolution, not diagrams. --- test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test.py b/test.py index 8e9c272d..20bf201f 100644 --- a/test.py +++ b/test.py @@ -59,6 +59,12 @@ def check_cpp_docs(): "WARN_IF_UNDOCUMENTED = NO", "WARN_IF_DOC_ERROR = YES", f"WARN_LOGFILE = {warn_log}", + # Only ref resolution matters here — never generate + # graphs, even if the environment has graphviz (some + # doxygen builds auto-enable dot and then fail on + # their own map files, e.g. Ubuntu's on CI runners). + "HAVE_DOT = NO", + "CLASS_GRAPH = NO", ] ) + "\n" From c6c66338bb84fa5312ace8a4102f4673332af2fd Mon Sep 17 00:00:00 2001 From: Philpax Date: Fri, 24 Jul 2026 19:14:14 +0200 Subject: [PATCH 10/10] Make doxygen dot graphs work instead of disabling them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin HAVE_DOT = YES — distro doxygen packages disagree on the default (Ubuntu's assumes dot exists, nix's assumes it doesn't), which is how CI failed while local runs passed — and provide graphviz alongside doxygen in both shell.nix and CI. --- .github/workflows/ci.yml | 5 +++-- shell.nix | 7 ++++--- test.py | 13 +++++++------ 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4b4c547..9653eece 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,9 @@ jobs: with: python-version: "3.x" - # test.py verifies the emitted C++ corpus's doc links with doxygen. - - run: sudo apt-get update && sudo apt-get install -y doxygen + # test.py verifies the emitted C++ corpus's doc links with doxygen; + # graphviz supplies the dot binary its graph generation needs. + - run: sudo apt-get update && sudo apt-get install -y doxygen graphviz # test.py lints the viewer (tsc/eslint/prettier) but does not install; # provide the workspace deps here. diff --git a/shell.nix b/shell.nix index 2b4e14e6..546ab6bd 100644 --- a/shell.nix +++ b/shell.nix @@ -1,14 +1,15 @@ # Dev-shell extras for pyxis — tools `python test.py` uses beyond the Rust # toolchain (which contributors typically manage via rustup). # -# Currently that's doxygen, for the C++ doc-link check: test.py runs doxygen -# over the emitted C++ corpus and fails on unresolved `@ref`s. Without it the -# check is skipped with a warning. +# Currently that's doxygen (plus graphviz for its dot graphs), for the C++ +# doc-link check: test.py runs doxygen over the emitted C++ corpus and fails +# on unresolved `@ref`s. Without doxygen the check is skipped with a warning. { pkgs ? import { }, }: pkgs.mkShell { packages = with pkgs; [ doxygen + graphviz ]; } diff --git a/test.py b/test.py index 20bf201f..7e2fbda3 100644 --- a/test.py +++ b/test.py @@ -59,12 +59,13 @@ def check_cpp_docs(): "WARN_IF_UNDOCUMENTED = NO", "WARN_IF_DOC_ERROR = YES", f"WARN_LOGFILE = {warn_log}", - # Only ref resolution matters here — never generate - # graphs, even if the environment has graphviz (some - # doxygen builds auto-enable dot and then fail on - # their own map files, e.g. Ubuntu's on CI runners). - "HAVE_DOT = NO", - "CLASS_GRAPH = NO", + # Pin dot ON so every environment behaves the same: + # distro doxygen packages disagree on the default + # (Ubuntu's assumes dot exists, nix's assumes it + # doesn't), and half-enabled graph generation fails on + # its own map files. Graphviz comes from shell.nix + # locally and apt on CI, alongside doxygen itself. + "HAVE_DOT = YES", ] ) + "\n"