diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a0a39c1930..f6573f1b3b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -218,7 +218,7 @@ jobs: repository: fe-lang/fe-docs token: ${{ secrets.DOC_DEPLOY_TOKEN }} path: fe-docs - ref: gh-pages + ref: main - name: Deploy docs run: | diff --git a/crates/fe-web/assets/fe-doc-item.js b/crates/fe-web/assets/fe-doc-item.js index 804ed8b93a..c8f7d94b2f 100644 --- a/crates/fe-web/assets/fe-doc-item.js +++ b/crates/fe-web/assets/fe-doc-item.js @@ -38,11 +38,15 @@ var _ITEM_KIND = { }; var _CHILD_KIND = { - field: { plural: "Fields", anchor: "field", order: 1 }, - variant: { plural: "Variants", anchor: "variant", order: 0 }, - method: { plural: "Methods", anchor: "tymethod", order: 4 }, - assoc_type: { plural: "Associated Types", anchor: "associatedtype", order: 2 }, - assoc_const: { plural: "Associated Constants", anchor: "associatedconstant", order: 3 }, + field: { plural: "Fields", anchor: "field", order: 1 }, + variant: { plural: "Variants", anchor: "variant", order: 0 }, + method: { plural: "Methods", anchor: "tymethod", order: 6 }, + assoc_type: { plural: "Associated Types", anchor: "associatedtype", order: 4 }, + assoc_const: { plural: "Associated Constants", anchor: "associatedconstant", order: 5 }, + // Contract-specific child kinds (emitted by crates/fe/src/extract.rs for + // Contract items — see DocChildKind::{Init,RecvHandler}). + init: { plural: "Initializer", anchor: "init", order: 2 }, + recv_handler: { plural: "Message Handlers", anchor: "handler", order: 3 }, }; function _diKindStr(kind) { return (_ITEM_KIND[kind] || {}).str || kind; } @@ -304,7 +308,8 @@ class FeDocItem extends HTMLElement { _renderBreadcrumbs(item) { var segments = item.path.split("::"); - var base = this.getAttribute("base") || ""; + var index = this._getIndex(); + var items = (index && index.items) || []; var html = '"; @@ -371,11 +384,17 @@ class FeDocItem extends HTMLElement { for (var j = 0; j < group.items.length; j++) { var child = group.items[j]; var anchorId = info.anchor + "." + child.name; + var rowHref = this._anchorHref(parentUrl, anchorId); html += '
'; - html += '
'; + html += ''; + html += '"; + html += ""; if (child.docs) { var childHtml = child.docs.html_body || _diEsc(child.docs.body || child.docs.summary || ""); html += '
' + childHtml + "
"; diff --git a/crates/fe-web/assets/fe-doc-viewer.js b/crates/fe-web/assets/fe-doc-viewer.js index c63fe0a98a..d60f06d579 100644 --- a/crates/fe-web/assets/fe-doc-viewer.js +++ b/crates/fe-web/assets/fe-doc-viewer.js @@ -337,12 +337,23 @@ class FeDocViewer extends HTMLElement { ? "#" + CSS.escape(anchorId) + " { background: var(--target-bg, rgba(99,102,241,0.08)); }" : ""; + // Retry a few times so the scroll lands even when the caller races the + // content render (e.g. initial page load with #path~anchor in the URL — + // _showItem rebuilds content asynchronously, so the target element may + // not exist at the first tick). var self = this; - setTimeout(function () { + var attempts = 0; + function tryScroll() { if (!self._contentEl) return; var el = self._contentEl.querySelector("#" + CSS.escape(anchorId)); - if (el) el.scrollIntoView({ behavior: "smooth" }); - }, 100); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + attempts++; + if (attempts < 20) setTimeout(tryScroll, 50); + } + setTimeout(tryScroll, 50); } // ---- SCIP Ambient Highlighting ---- @@ -572,6 +583,7 @@ class FeDocViewer extends HTMLElement { mod: "module", fn: "function", struct: "struct", enum: "enum", trait: "trait", contract: "contract", type: "type_alias", "const": "const", impl: "impl", + msg: "msg", msg_variant: "msg_variant", }; var kindName = kindMap[kindSuffix]; if (kindName) { diff --git a/crates/fe-web/assets/fe-scip-store.js b/crates/fe-web/assets/fe-scip-store.js index 38efc9df00..cd69570313 100644 --- a/crates/fe-web/assets/fe-scip-store.js +++ b/crates/fe-web/assets/fe-scip-store.js @@ -112,6 +112,47 @@ function feMigrate(data) { data.schema_version = 2; } + if (v < 3) { + // v2 → v3: msg variants are no longer top-level DocItems; they live as + // `kind: "variant"` children of their parent msg DocItem (mirrors enum + // variants). SCIP doc_url for msg variants moved from + // `/msg_variant` to `/msg~variant.`. + // + // For v<3 data: drop stale top-level msg_variant items from index.items + // and rewrite any lingering `/msg_variant` SCIP doc_urls to the anchor + // form. Best-effort — consumers holding v<3 docs.json should regenerate. + if (data.index && data.index.items) { + data.index.items = data.index.items.filter(function (it) { + return it && it.kind !== "msg_variant"; + }); + } + if (data.scip && data.scip.symbols) { + var syms = data.scip.symbols; + for (var k in syms) { + if (!syms.hasOwnProperty(k)) continue; + var url = syms[k].doc_url; + if (!url) continue; + // /msg_variant → parent /msg~variant.. Drop any legacy + // sub-anchor (e.g. ~field.x) — the router splits on the first ~, + // so a doubled tilde would produce a hash that matches no element + // ID. Migrated deep-links land on the variant row instead. + var m = url.match(/^(.*)::([^:]+)\/msg_variant(~.*)?$/); + if (m) { + syms[k].doc_url = m[1] + "/msg~variant." + m[2]; + } + } + } + data.schema_version = 3; + } + + if (v < 4) { + // v3 → v4: contract pages now emit `init` and `recv_handler` children + // (the init block and each recv arm). No structural rewrite is needed + // for old data; downstream consumers just won't see these rows until + // they regenerate docs.json. + data.schema_version = 4; + } + return data; } diff --git a/crates/fe-web/assets/fe-search.js b/crates/fe-web/assets/fe-search.js index 1bb343d8a1..ca751b067c 100644 --- a/crates/fe-web/assets/fe-search.js +++ b/crates/fe-web/assets/fe-search.js @@ -69,12 +69,18 @@ class FeSearch extends HTMLElement { if (scip) { try { var results = JSON.parse(scip.search(query)); - if (results.length > 0) { - for (var k = 0; k < results.length; k++) { - var r = results[k]; + // Drop results without a doc_url — they would render as unclickable + // links that land back on the current page. Users hate those. + var clickable = []; + for (var ck = 0; ck < results.length; ck++) { + if (results[ck].doc_url) clickable.push(results[ck]); + } + if (clickable.length > 0) { + for (var k = 0; k < clickable.length; k++) { + var r = clickable[k]; var a = document.createElement("a"); a.className = "search-result"; - a.href = "#" + (r.doc_url || ""); + a.href = "#" + r.doc_url; a.setAttribute("role", "option"); var badge = document.createElement("span"); @@ -104,6 +110,7 @@ class FeSearch extends HTMLElement { module: "mod", function: "fn", struct: "struct", enum: "enum", trait: "trait", contract: "contract", type_alias: "type", const: "const", impl: "impl", impl_trait: "impl", + msg: "msg", }; var q = query.toLowerCase(); diff --git a/crates/fe-web/assets/fe-web.js b/crates/fe-web/assets/fe-web.js index fc53d369cb..2c76535cc1 100644 --- a/crates/fe-web/assets/fe-web.js +++ b/crates/fe-web/assets/fe-web.js @@ -92,6 +92,7 @@ const: { str: "const", plural: "Constants", order: 7 }, impl: { str: "impl", plural: "Implementations", order: 8 }, impl_trait: { str: "impl", plural: "Trait Implementations", order: 9 }, + msg: { str: "msg", plural: "Messages", order: 1 }, }; function kindStr(kind) { @@ -140,9 +141,14 @@ var CHILD_KIND_INFO = { field: { plural: "Fields", anchor: "field", order: 1 }, variant: { plural: "Variants", anchor: "variant", order: 0 }, - method: { plural: "Methods", anchor: "tymethod", order: 4 }, - assoc_type: { plural: "Associated Types", anchor: "associatedtype", order: 2 }, - assoc_const: { plural: "Associated Constants", anchor: "associatedconstant", order: 3 }, + method: { plural: "Methods", anchor: "tymethod", order: 6 }, + assoc_type: { plural: "Associated Types", anchor: "associatedtype", order: 4 }, + assoc_const: { plural: "Associated Constants", anchor: "associatedconstant", order: 5 }, + // Contract init block & recv arm handlers (see crates/fe/src/extract.rs + // DocChildKind::{Init,RecvHandler}). Kept in sync with _CHILD_KIND in + // fe-doc-item.js. + init: { plural: "Initializer", anchor: "init", order: 2 }, + recv_handler: { plural: "Message Handlers", anchor: "handler", order: 3 }, }; // ============================================================================ @@ -313,6 +319,8 @@ function renderBreadcrumbs(item) { var segments = item.path.split("::"); + var index = window.FE_DOC_INDEX || { items: [] }; + var items = index.items || []; var html = '"; @@ -726,6 +743,7 @@ mod: "module", fn: "function", struct: "struct", enum: "enum", trait: "trait", contract: "contract", type: "type_alias", const: "const", impl: "impl", + msg: "msg", msg_variant: "msg_variant", }; var kindName = kindMap[kindSuffix]; if (kindName) { diff --git a/crates/fe-web/assets/styles.css b/crates/fe-web/assets/styles.css index b8bc6267bd..c58688c28a 100644 --- a/crates/fe-web/assets/styles.css +++ b/crates/fe-web/assets/styles.css @@ -3,8 +3,11 @@ /* Safari/WebKit needs this to suppress native disclosure triangles */ summary::-webkit-details-marker { display: none; } -/* Light mode (default) */ +/* Light mode (default). + Theming knobs for downstream consumers — override any of these in a + stylesheet loaded *after* this one to customize the doc viewer. */ :root { + /* Palette */ --bg: #ffffff; --bg-secondary: #f8fafc; --text: #1e293b; @@ -15,6 +18,36 @@ summary::-webkit-details-marker { display: none; } --border: #e2e8f0; --code-bg: var(--fe-code-bg); + /* Fonts — these variables drive every text surface so themers can + swap a single value to retheme. */ + --fe-body-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --fe-heading-font: var(--fe-body-font); + --fe-mono-font: "JetBrains Mono", "Fira Code", monospace; + + /* Weights — pixel-style fonts may want 400 everywhere to avoid the + wider-at-bold jump that bitmap faces introduce. */ + --fe-body-weight: 400; + --fe-heading-weight: 600; + /* Sidebar chrome (module names, kind headers, current-item accent). + Pixel themes pin to 400 and use --fe-emphasis-text-shadow for visual + weight instead, since bitmap bold faces change glyph advance. */ + --fe-sidebar-weight: 600; + --fe-current-weight: 600; + + /* Pixel / retro rendering knobs. Defaults are browser-native. */ + --fe-font-smoothing: auto; /* maps to -webkit-font-smoothing */ + --fe-moz-font-smoothing: auto; /* maps to -moz-osx-font-smoothing */ + --fe-text-rendering: auto; + --fe-heading-text-shadow: none; + --fe-emphasis-text-shadow: none; /* applied to current nav items */ + --fe-letter-spacing: normal; + + /* Layout */ + --fe-content-max-width: 900px; + --fe-sidebar-min-width: 240px; + --fe-sidebar-max-width: 25vw; + + /* Highlight colors driven by accent */ --hl-ref-bg: rgba(79, 70, 229, 0.08); --hl-def-bg: rgba(79, 70, 229, 0.15); --hl-def-underline: rgba(79, 70, 229, 0.4); @@ -62,18 +95,60 @@ summary::-webkit-details-marker { display: none; } * { box-sizing: border-box; margin: 0; padding: 0; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: var(--fe-body-font); + font-weight: var(--fe-body-weight); + letter-spacing: var(--fe-letter-spacing); background: var(--bg); color: var(--text); line-height: 1.6; + scrollbar-width: thin; + scrollbar-color: var(--border) var(--bg); + -webkit-font-smoothing: var(--fe-font-smoothing); + -moz-osx-font-smoothing: var(--fe-moz-font-smoothing); + text-rendering: var(--fe-text-rendering); +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--fe-heading-font); + font-weight: var(--fe-heading-weight); + text-shadow: var(--fe-heading-text-shadow); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--bg); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +::-webkit-scrollbar-corner { + background: var(--bg); } .doc-layout { display: grid; - grid-template-columns: 280px 1fr; + grid-template-columns: auto 1fr; min-height: 100vh; } +.doc-layout > .doc-sidebar, +.doc-layout > nav { + width: max-content; + min-width: var(--fe-sidebar-min-width); + max-width: var(--fe-sidebar-max-width); +} + .doc-sidebar { background: var(--bg-secondary); border-right: 1px solid var(--border); @@ -203,12 +278,12 @@ body { .doc-content { padding: 1.5rem 2rem; - max-width: 900px; + max-width: var(--fe-content-max-width); } /* Breadcrumb navigation */ .breadcrumb { - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); font-size: 0.875rem; color: var(--text-muted); margin-bottom: 0.75rem; @@ -254,7 +329,7 @@ body { font-size: 0.875rem; color: var(--text-muted); text-decoration: none; - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); } .src-link:hover { @@ -390,7 +465,7 @@ body { background: var(--code-bg); padding: 0.125rem 0.375rem; border-radius: 4px; - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); font-size: 0.875em; } @@ -468,7 +543,7 @@ h2 { background: var(--code-bg); padding: 0.25rem 0.5rem; border-radius: 4px; - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); } .not-found-hint { @@ -609,7 +684,7 @@ h2:hover .anchor { } .impl-header code { - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); color: var(--accent); } @@ -652,7 +727,7 @@ fe-code-block.impl-signature { } .code-header code { - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); color: var(--text); } @@ -713,7 +788,7 @@ fe-code-block.impl-signature { } .item-name code { - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); font-size: 0.875rem; } @@ -770,7 +845,7 @@ fe-code-block.impl-signature { } .implementor-sig { - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); font-size: 0.875rem; color: var(--text); } @@ -849,8 +924,14 @@ fe-code-block.impl-signature { align-items: baseline; } +.member-header-link { + color: inherit; + text-decoration: none; + display: block; +} + .member-signature { - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); font-size: 0.875rem; color: var(--text); } @@ -921,7 +1002,7 @@ fe-code-block { /* custom element */ .fe-sig { - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); font-size: 0.875rem; white-space: pre; color: var(--text); @@ -1027,7 +1108,7 @@ fe-code-block { .outline-list li.outline-level-2 a { font-size: 0.75rem; - font-family: var(--font-mono, ui-monospace, monospace); + font-family: var(--fe-mono-font); } /* Doc sections (Examples, Panics, Safety, etc.) */ @@ -1070,7 +1151,7 @@ fe-code-block { background: var(--code-bg); padding: 0.125rem 0.375rem; border-radius: 4px; - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--fe-mono-font); font-size: 0.875em; } @@ -1126,7 +1207,7 @@ fe-code-block { .fe-doc-viewer-layout { display: grid; - grid-template-columns: 280px 1fr; + grid-template-columns: auto 1fr; height: calc(100vh - 3rem); width: 100%; } @@ -1138,6 +1219,9 @@ fe-code-block { border-right: 1px solid var(--border); height: 100%; overflow-y: auto; + width: max-content; + min-width: var(--fe-sidebar-min-width); + max-width: var(--fe-sidebar-max-width); } .fe-doc-viewer-sidebar .fe-doc-nav { @@ -1154,7 +1238,7 @@ fe-code-block { } .fe-doc-viewer-content > * { - max-width: 900px; + max-width: var(--fe-content-max-width); } fe-doc-viewer, .fe-doc-viewer { @@ -1212,6 +1296,27 @@ fe-doc-viewer, .fe-doc-viewer { top: 0; height: 100vh; overflow-y: auto; + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) var(--bg-secondary); +} + +.fe-doc-nav::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.fe-doc-nav::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +.fe-doc-nav::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.fe-doc-nav::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); } .fe-doc-nav-search { @@ -1246,8 +1351,9 @@ fe-doc-viewer, .fe-doc-viewer { } .fe-nav-mod-name { - font-weight: 600; + font-weight: var(--fe-sidebar-weight); font-size: 0.9rem; + white-space: nowrap; } .fe-nav-mod-name a { @@ -1274,7 +1380,7 @@ fe-doc-viewer, .fe-doc-viewer { .fe-nav-kind-header { font-size: 0.7rem; - font-weight: 600; + font-weight: var(--fe-sidebar-weight); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; @@ -1301,6 +1407,7 @@ fe-doc-viewer, .fe-doc-viewer { gap: 0.4rem; padding: 0.1rem 0.25rem; border-radius: 3px; + white-space: nowrap; } .fe-nav-items a:hover { @@ -1308,9 +1415,14 @@ fe-doc-viewer, .fe-doc-viewer { background: rgba(255, 255, 255, 0.05); } +.fe-nav-items li.current a, +.fe-nav-mod-name.current a { + font-weight: var(--fe-current-weight); + text-shadow: var(--fe-emphasis-text-shadow); +} + .fe-nav-items li.current a { color: var(--accent); - font-weight: 600; } .fe-nav-badge { @@ -1380,7 +1492,10 @@ fe-doc-viewer, .fe-doc-viewer { position: fixed; top: 0; left: 0; + /* Reset the desktop max-content / minmax so mobile uses a fixed width */ width: 280px; + min-width: 280px; + max-width: 280px; height: 100vh; z-index: 1000; border-right: 1px solid var(--border); diff --git a/crates/fe-web/src/model.rs b/crates/fe-web/src/model.rs index 3023c7c3f1..3e1b48c46a 100644 --- a/crates/fe-web/src/model.rs +++ b/crates/fe-web/src/model.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; /// any type reachable from them. When you bump it, you MUST also: /// 1. Update the snapshot test in this module (cargo test, accept new snap). /// 2. Add a migration case in fe-scip-store.js feMigrate(). -pub const SCHEMA_VERSION: u32 = 2; +pub const SCHEMA_VERSION: u32 = 4; // ============================================================================ // Rich Signature Types (for rendering signatures with embedded links) @@ -384,6 +384,10 @@ pub enum DocChildKind { Method, AssocType, AssocConst, + /// A contract `init(...)` block. + Init, + /// A single arm of a contract `recv Msg { Variant ... }` block. + RecvHandler, } impl DocChildKind { @@ -394,6 +398,8 @@ impl DocChildKind { DocChildKind::Method => "Method", DocChildKind::AssocType => "Associated Type", DocChildKind::AssocConst => "Associated Constant", + DocChildKind::Init => "Initializer", + DocChildKind::RecvHandler => "Handler", } } @@ -405,6 +411,8 @@ impl DocChildKind { DocChildKind::Method => "Methods", DocChildKind::AssocType => "Associated Types", DocChildKind::AssocConst => "Associated Constants", + DocChildKind::Init => "Initializer", + DocChildKind::RecvHandler => "Message Handlers", } } @@ -413,9 +421,11 @@ impl DocChildKind { match self { DocChildKind::Variant => 0, DocChildKind::Field => 1, - DocChildKind::AssocType => 2, - DocChildKind::AssocConst => 3, - DocChildKind::Method => 4, + DocChildKind::Init => 2, + DocChildKind::RecvHandler => 3, + DocChildKind::AssocType => 4, + DocChildKind::AssocConst => 5, + DocChildKind::Method => 6, } } @@ -427,6 +437,8 @@ impl DocChildKind { DocChildKind::Method => "tymethod", DocChildKind::AssocType => "associatedtype", DocChildKind::AssocConst => "associatedconstant", + DocChildKind::Init => "init", + DocChildKind::RecvHandler => "handler", } } } diff --git a/crates/fe-web/src/snapshots/fe_web__model__tests__doc_index_schema.snap b/crates/fe-web/src/snapshots/fe_web__model__tests__doc_index_schema.snap index fb51cf1f19..0419414d22 100644 --- a/crates/fe-web/src/snapshots/fe_web__model__tests__doc_index_schema.snap +++ b/crates/fe-web/src/snapshots/fe_web__model__tests__doc_index_schema.snap @@ -1,6 +1,6 @@ --- source: crates/fe-web/src/model.rs -assertion_line: 1229 +assertion_line: 1241 expression: value --- { @@ -56,14 +56,32 @@ expression: value }, "DocChildKind": { "description": "Kind of child item", - "enum": [ - "field", - "variant", - "method", - "assoc_type", - "assoc_const" - ], - "type": "string" + "oneOf": [ + { + "enum": [ + "field", + "variant", + "method", + "assoc_type", + "assoc_const" + ], + "type": "string" + }, + { + "description": "A contract `init(...)` block.", + "enum": [ + "init" + ], + "type": "string" + }, + { + "description": "A single arm of a contract `recv Msg { Variant ... }` block.", + "enum": [ + "recv_handler" + ], + "type": "string" + } + ] }, "DocContent": { "description": "Parsed documentation content with sections", diff --git a/crates/fe/src/doc.rs b/crates/fe/src/doc.rs index 314b9f0679..d7ff5ba7f9 100644 --- a/crates/fe/src/doc.rs +++ b/crates/fe/src/doc.rs @@ -161,6 +161,7 @@ pub fn generate_docs( output: Option<&Utf8PathBuf>, builtins: bool, stdlib_path: Option<&Utf8PathBuf>, + include_tests: bool, action: Option<&crate::DocAction>, ) { // First, check if there's a running LSP with docs server @@ -213,7 +214,7 @@ pub fn generate_docs( let git_root = detect_git_root(path.as_std_path()); let index = if path.is_file() && path.extension() == Some("fe") { - extract_single_file(&mut db, path, git_root.as_deref()) + extract_single_file(&mut db, path, git_root.as_deref(), include_tests) } else if path.is_dir() { // Check if this is a workspace (fe.toml with [workspace] section) let fe_toml = path.join("fe.toml"); @@ -222,15 +223,21 @@ pub fn generate_docs( if let Ok(common::config::Config::Workspace(ws_config)) = common::config::Config::parse(&content) { - extract_workspace(&mut db, path, &ws_config, git_root.as_deref()) + extract_workspace( + &mut db, + path, + &ws_config, + git_root.as_deref(), + include_tests, + ) } else { - extract_ingot(&mut db, path, git_root.as_deref()) + extract_ingot(&mut db, path, git_root.as_deref(), include_tests) } } else { - extract_ingot(&mut db, path, git_root.as_deref()) + extract_ingot(&mut db, path, git_root.as_deref(), include_tests) } } else { - extract_ingot(&mut db, path, git_root.as_deref()) + extract_ingot(&mut db, path, git_root.as_deref(), include_tests) } } else { eprintln!("Error: Path must be either a .fe file or a directory containing fe.toml"); @@ -254,7 +261,9 @@ pub fn generate_docs( continue; } - let extractor = make_extractor(&db, git_root.as_deref()); + // Builtins never include tests regardless of the flag — stdlib + // test fns have no place in user-facing docs. + let extractor = make_extractor(&db, git_root.as_deref(), false); for top_mod in builtin_ingot.all_modules(&db) { for item in top_mod.children_nested(&db) { if let Some(doc_item) = extractor.extract_item_for_ingot(item, builtin_ingot) { @@ -414,19 +423,20 @@ pub fn generate_docs( fn make_extractor<'db>( db: &'db dyn hir::SpannedHirDb, git_root: Option<&std::path::Path>, + include_tests: bool, ) -> DocExtractor<'db> { - let extractor = DocExtractor::new(db); + let mut extractor = DocExtractor::new(db).with_include_tests(include_tests); if let Some(root) = git_root { - extractor.with_root_path(root.to_path_buf()) - } else { - extractor + extractor = extractor.with_root_path(root.to_path_buf()); } + extractor } fn extract_single_file( db: &mut DriverDataBase, file_path: &Utf8PathBuf, git_root: Option<&std::path::Path>, + include_tests: bool, ) -> Option { let canonical = file_path.canonicalize_utf8().ok()?; let file_url = Url::from_file_path(&canonical).ok()?; @@ -444,7 +454,7 @@ fn extract_single_file( diags.emit(db); } - let extractor = make_extractor(db, git_root); + let extractor = make_extractor(db, git_root, include_tests); Some(extractor.extract_module(top_mod)) } @@ -453,6 +463,7 @@ fn extract_workspace( workspace_root: &Utf8PathBuf, ws_config: &common::config::WorkspaceConfig, git_root: Option<&std::path::Path>, + include_tests: bool, ) -> Option { use common::config::WorkspaceMemberSelection; @@ -525,7 +536,7 @@ fn extract_workspace( diags.emit(db); } - let extractor = make_extractor(db, git_root); + let extractor = make_extractor(db, git_root, include_tests); for top_mod in ingot.all_modules(db) { for item in top_mod.children_nested(db) { if let Some(doc_item) = extractor.extract_item_for_ingot(item, ingot) { @@ -555,6 +566,7 @@ fn extract_ingot( db: &mut DriverDataBase, dir_path: &Utf8PathBuf, git_root: Option<&std::path::Path>, + include_tests: bool, ) -> Option { let canonical_path = dir_path.canonicalize_utf8().ok()?; let ingot_url = Url::from_directory_path(canonical_path.as_str()).ok()?; @@ -573,7 +585,7 @@ fn extract_ingot( diags.emit(db); } - let extractor = make_extractor(db, git_root); + let extractor = make_extractor(db, git_root, include_tests); let mut index = DocIndex::new(); // Extract items from all modules with ingot-qualified paths (like LSP does) diff --git a/crates/fe/src/extract.rs b/crates/fe/src/extract.rs index ea9310aa47..ef0d755fca 100644 --- a/crates/fe/src/extract.rs +++ b/crates/fe/src/extract.rs @@ -26,6 +26,9 @@ pub struct DocExtractor<'db> { db: &'db dyn SpannedHirDb, /// Root path for computing relative display paths root_path: Option, + /// Include `#[test]` functions in the output. + /// Off by default — test fns pollute the public API sidebar. + include_tests: bool, } impl<'db> DocExtractor<'db> { @@ -33,6 +36,7 @@ impl<'db> DocExtractor<'db> { Self { db, root_path: None, + include_tests: false, } } @@ -43,6 +47,24 @@ impl<'db> DocExtractor<'db> { self } + /// Include `#[test]` functions in the extracted output. + pub fn with_include_tests(mut self, include: bool) -> Self { + self.include_tests = include; + self + } + + /// Returns true if `item` is a `#[test(...)]` function that should be + /// filtered out unless `include_tests` is enabled. + fn is_filtered_test_item(&self, item: ItemKind<'db>) -> bool { + if self.include_tests { + return false; + } + matches!(item, ItemKind::Func(_)) + && item + .attrs(self.db) + .is_some_and(|attrs| attrs.has_attr(self.db, "test")) + } + /// Rewrite a path to use the ingot's config name instead of "lib". /// Delegates to the shared `qualify_path_with_ingot_name` function. fn qualify_path_with_ingot(&self, path: &str, ingot: Ingot<'db>) -> String { @@ -58,6 +80,18 @@ impl<'db> DocExtractor<'db> { let mut doc_item = self.extract_item(item)?; doc_item.path = self.qualify_path_with_ingot(&doc_item.path, ingot); + // If this is the ingot root module (name is literally "lib" from + // `lib.fe`), substitute the ingot's configured name so pages don't + // render `

lib

`. Match on the qualified path being exactly + // the ingot name so a nested `foo::lib` submodule is left alone. + if matches!(doc_item.kind, DocItemKind::Module) + && doc_item.name == "lib" + && let Some(cfg_name) = ingot.config(self.db).and_then(|c| c.metadata.name.clone()) + && doc_item.path == cfg_name.as_str() + { + doc_item.name = cfg_name.to_string(); + } + // The display_file from get_source_location is already relative to workspace root, // which includes the ingot directory. No need to prepend ingot name again. @@ -237,12 +271,43 @@ impl<'db> DocExtractor<'db> { _ => {} } + // Skip `#[test]` fns unless explicitly requested. Doc pages should + // focus on the public API surface by default. + if self.is_filtered_test_item(item) { + return None; + } + + // Skip the synthetic pieces produced by `msg` desugaring — the + // per-variant struct and its accompanying impl blocks. The `msg` Mod + // itself is the sole DocItem for that block; variants appear inline + // on its page (see `extract_children` for `ItemKind::Mod(_)`). + match item { + ItemKind::Struct(s) if is_desugared_msg_variant_struct(self.db, s) => return None, + ItemKind::ImplTrait(it) if is_desugared_msg_impl_trait(self.db, it) => return None, + ItemKind::Impl(i) + if matches!( + span::impl_ast(self.db, i), + HirOrigin::Desugared(DesugaredOrigin::Msg(_)) + ) => + { + return None; + } + _ => {} + } + // Skip items nested inside containers (traits, structs, enums, impls) — // they are already captured as children of their parent DocItem. - if let Some(parent) = item.scope().parent_item(self.db) - && crate::index_util::is_container_item(parent) - { - return None; + // For a `msg` Mod the variants are also rendered as children, so + // treat that Mod as a container too. + if let Some(parent) = item.scope().parent_item(self.db) { + if crate::index_util::is_container_item(parent) { + return None; + } + if let ItemKind::Mod(m) = parent + && is_desugared_msg_mod(self.db, m) + { + return None; + } } let scope = item.scope(); @@ -282,10 +347,7 @@ impl<'db> DocExtractor<'db> { match item { ItemKind::TopMod(_) => Some(DocItemKind::Module), ItemKind::Mod(m) => { - if matches!( - span::mod_ast(self.db, m), - HirOrigin::Desugared(DesugaredOrigin::Msg(_)) - ) { + if is_desugared_msg_mod(self.db, m) { Some(DocItemKind::Msg) } else { Some(DocItemKind::Module) @@ -293,10 +355,7 @@ impl<'db> DocExtractor<'db> { } ItemKind::Func(_) => Some(DocItemKind::Function), ItemKind::Struct(s) => { - if matches!( - span::struct_ast(self.db, s), - HirOrigin::Desugared(DesugaredOrigin::Msg(m)) if m.variant_idx.is_some() - ) { + if is_desugared_msg_variant_struct(self.db, s) { Some(DocItemKind::MsgVariant) } else { Some(DocItemKind::Struct) @@ -384,6 +443,17 @@ impl<'db> DocExtractor<'db> { /// to be resolved. Instead we span from the name token start to the type /// node end, which exactly matches the signature text layout. fn field_sig_span(&self, field_view: FieldView<'db>) -> Option { + // Desugared msg variant struct fields: the HIR field spans inherit + // the variant block's DesugaredOrigin, so `.fields().field(i).name()` + // collapses to the entire variant body. Skip the signature span so + // the renderer shows the plain signature text (`name: Type`) without + // overlaying SCIP occurrences on a span that would shift positions. + if let hir::hir_def::FieldParent::Struct(s) = field_view.parent + && is_desugared_msg_variant_struct(self.db, s) + { + return None; + } + let name_span = match field_view.parent { hir::hir_def::FieldParent::Struct(s) => s .span() @@ -454,15 +524,184 @@ impl<'db> DocExtractor<'db> { fn extract_children(&self, item: ItemKind<'db>) -> Vec { match item { ItemKind::Struct(s) => self.extract_struct_fields(s), - ItemKind::Contract(c) => self.extract_contract_fields(c), + ItemKind::Contract(c) => { + let mut children = self.extract_contract_fields(c); + // init block (if any) + if let Some(init_child) = self.extract_contract_init(c) { + children.push(init_child); + } + // recv handler arms + children.extend(self.extract_contract_recv_handlers(c)); + children + } ItemKind::Enum(e) => self.extract_enum_variants(e), ItemKind::Trait(t) => self.extract_trait_members(t), ItemKind::Impl(i) => self.extract_impl_members(i), ItemKind::ImplTrait(it) => self.extract_impl_trait_members(it), + ItemKind::Mod(m) if is_desugared_msg_mod(self.db, m) => self.extract_msg_variants(m), _ => Vec::new(), } } + /// Extract the contract `init(...)` block as a `DocChild` of kind `Init`. + /// + /// The signature is the source text spanning the `init` keyword through + /// the closing paren of the params (and `uses (...)` clause if present), + /// i.e. everything up to the opening `{` of the body. + fn extract_contract_init(&self, c: Contract<'db>) -> Option { + let _init = c.init(self.db)?; + + // Full span of the init block AST (includes body). We'll trim to the + // header by cutting at the first `{` so the signature is stable even + // if the body uses nested braces. + let init_span = c.span().init_block().resolve(self.db)?; + let start: usize = init_span.range.start().into(); + let end: usize = init_span.range.end().into(); + let file_text = init_span.file.text(self.db); + let slice = file_text.get(start..end)?; + // Cut at the first `{` to get the header. + let header_end_rel = slice.find('{').unwrap_or(slice.len()); + let header = slice[..header_end_rel].trim_end().to_string(); + + let file_url = init_span.file.url(self.db)?; + let signature_span = Some(SignatureSpanData { + file_url: file_url.to_string(), + byte_start: start, + byte_end: start + header_end_rel, + }); + + Some(DocChild { + kind: DocChildKind::Init, + name: "init".to_string(), + docs: None, + signature: header, + rich_signature: vec![], + signature_span, + sig_scope: None, + visibility: DocVisibility::Public, + }) + } + + /// Extract each arm of every `recv` block in the contract as a + /// `DocChild` of kind `RecvHandler`. + /// + /// Signature is the source text of the arm header (pattern through the + /// optional return type and `uses (...)` clause), stopping at the body's + /// opening brace. + fn extract_contract_recv_handlers(&self, c: Contract<'db>) -> Vec { + let mut out = Vec::new(); + let recvs = c.recvs(self.db); + for (recv_idx, recv) in recvs.data(self.db).iter().enumerate() { + let msg_type_name = recv + .msg_path + .and_then(|p| p.ident(self.db).to_opt()) + .map(|id| id.data(self.db).to_string()); + + for (arm_idx, arm) in recv.arms.data(self.db).iter().enumerate() { + let arm_lazy = c.span().recv(recv_idx).arms().arm(arm_idx); + let Some(arm_span) = arm_lazy.clone().resolve(self.db) else { + continue; + }; + + let start: usize = arm_span.range.start().into(); + let end: usize = arm_span.range.end().into(); + let file_text = arm_span.file.text(self.db); + let Some(slice) = file_text.get(start..end) else { + continue; + }; + + // Header goes up to the body's opening `{`. Use the body + // span's start (if resolvable) to find it precisely — a + // naive `slice.find('{')` would stop at the first record + // pattern brace like `Lock { challenge }`. + let header_end_abs = arm_lazy + .body() + .resolve(self.db) + .map(|bs| usize::from(bs.range.start())) + .unwrap_or(end); + let header_end_rel = header_end_abs.saturating_sub(start).min(slice.len()); + let header = slice[..header_end_rel].trim_end().to_string(); + + // Name: variant identifier when available; fallback to "_" + // for wildcard arms. + let name = arm + .variant_path(self.db) + .and_then(|p| p.ident(self.db).to_opt()) + .map(|id| id.data(self.db).to_string()) + .unwrap_or_else(|| { + if arm.is_fallback(self.db) { + "_".to_string() + } else { + format!("arm{}_{}", recv_idx, arm_idx) + } + }); + + // Disambiguate duplicate arm names across multiple recv blocks + // (rare, but possible if the same msg type is handled twice). + let qualified_name = if let Some(ref msg) = msg_type_name { + format!("{}::{}", msg, name) + } else { + name.clone() + }; + + let file_url = arm_span.file.url(self.db); + let signature_span = file_url.map(|u| SignatureSpanData { + file_url: u.to_string(), + byte_start: start, + byte_end: start + header_end_rel, + }); + + out.push(DocChild { + kind: DocChildKind::RecvHandler, + name: qualified_name, + docs: None, + signature: header, + rich_signature: vec![], + signature_span, + sig_scope: None, + visibility: DocVisibility::Public, + }); + } + } + out + } + + /// Extract variants from a desugared `msg` Mod as `DocChild::Variant`. + /// + /// A `msg` block desugars into a `Mod` whose children are the per-variant + /// structs (plus internal trait impls). For docs we surface each variant + /// struct as a child of the msg DocItem so the `msg` page renders like an + /// `enum` page — variants listed inline rather than as separate items. + fn extract_msg_variants(&self, m: hir::hir_def::Mod<'db>) -> Vec { + let mut out = Vec::new(); + for child in m.children_non_nested(self.db) { + let ItemKind::Struct(s) = child else { continue }; + if !is_desugared_msg_variant_struct(self.db, s) { + continue; + } + let Some(name_ident) = s.name(self.db).to_opt() else { + continue; + }; + let name = name_ident.data(self.db).to_string(); + let docs = self + .get_docstring(s.scope()) + .map(|s| DocContent::from_raw(&s)); + let (signature, signature_span) = self.get_signature_with_span(child); + + out.push(DocChild { + kind: DocChildKind::Variant, + name, + docs, + signature, + rich_signature: vec![], + signature_span, + sig_scope: None, + visibility: DocVisibility::Public, + }); + } + out + } + fn extract_struct_fields(&self, s: Struct<'db>) -> Vec { // Use FieldParent to access fields through the public API let parent = FieldParent::Struct(s); @@ -761,6 +1000,52 @@ impl<'db> DocExtractor<'db> { vec![self.build_module_node_for_ingot(ingot, root_mod)] } + /// Should this child be skipped when populating a parent module's item + /// list in the nav tree? Excludes msg-desugared synthetic impls and the + /// per-variant structs (which are siblings of the msg Mod in the HIR but + /// should only appear as children of the Msg item, not as top-level + /// entries in the enclosing module's sidebar). + fn should_skip_module_item(&self, item: ItemKind<'db>) -> bool { + // Filter out `#[test]` functions by default so the nav tree is not + // dominated by test_* entries. + if self.is_filtered_test_item(item) { + return true; + } + match item { + ItemKind::Struct(s) if is_desugared_msg_variant_struct(self.db, s) => true, + ItemKind::ImplTrait(it) if is_desugared_msg_impl_trait(self.db, it) => true, + ItemKind::Impl(i) + if matches!( + span::impl_ast(self.db, i), + HirOrigin::Desugared(DesugaredOrigin::Msg(_)) + ) => + { + true + } + _ => false, + } + } + + /// Emit a DocModuleItem entry into `items` for `child`. + fn push_item_entry( + &self, + ingot: Ingot<'db>, + child: ItemKind<'db>, + items: &mut Vec, + ) { + if let (Some(name), Some(kind)) = (child.name(self.db), self.item_kind_to_doc_kind(child)) { + let raw_child_path = child.scope().pretty_path(self.db).unwrap_or_default(); + let child_path = self.qualify_path_with_ingot(&raw_child_path, ingot); + let summary = self.get_summary(child.scope()); + items.push(DocModuleItem { + name: name.data(self.db).to_string(), + path: child_path, + kind, + summary, + }); + } + } + /// Build a module node including file-based children from the ingot's module tree fn build_module_node_for_ingot( &self, @@ -792,6 +1077,13 @@ impl<'db> DocExtractor<'db> { // Get inline children (defined in this file) for child in top_mod.children_non_nested(self.db) { match child { + ItemKind::Mod(m) if is_desugared_msg_mod(self.db, m) => { + // A msg block desugars to a Mod; surface it as an item + // (kind: msg) in the parent rather than a sub-module, so + // nav links resolve to the msg DocItem instead of a + // non-existent `.../mod` URL. + self.push_item_entry(ingot, child, &mut items); + } ItemKind::Mod(_) => { // Use ingot-aware builder for inline modules too children.push(self.build_module_node_for_ingot_inline(ingot, child)); @@ -801,19 +1093,10 @@ impl<'db> DocExtractor<'db> { | ItemKind::Body(_) | ItemKind::TopMod(_) => {} _ => { - if let (Some(name), Some(kind)) = - (child.name(self.db), self.item_kind_to_doc_kind(child)) - { - let raw_child_path = child.scope().pretty_path(self.db).unwrap_or_default(); - let child_path = self.qualify_path_with_ingot(&raw_child_path, ingot); - let summary = self.get_summary(child.scope()); - items.push(DocModuleItem { - name: name.data(self.db).to_string(), - path: child_path, - kind, - summary, - }); + if self.should_skip_module_item(child) { + continue; } + self.push_item_entry(ingot, child, &mut items); } } } @@ -867,24 +1150,18 @@ impl<'db> DocExtractor<'db> { for child in direct_children { match child { + ItemKind::Mod(m) if is_desugared_msg_mod(self.db, m) => { + self.push_item_entry(ingot, child, &mut items); + } ItemKind::Mod(_) | ItemKind::TopMod(_) => { children.push(self.build_module_node_for_ingot_inline(ingot, child)); } ItemKind::StaticAssert(_) | ItemKind::Use(_) | ItemKind::Body(_) => {} _ => { - if let (Some(name), Some(kind)) = - (child.name(self.db), self.item_kind_to_doc_kind(child)) - { - let raw_child_path = child.scope().pretty_path(self.db).unwrap_or_default(); - let child_path = self.qualify_path_with_ingot(&raw_child_path, ingot); - let summary = self.get_summary(child.scope()); - items.push(DocModuleItem { - name: name.data(self.db).to_string(), - path: child_path, - kind, - summary, - }); + if self.should_skip_module_item(child) { + continue; } + self.push_item_entry(ingot, child, &mut items); } } } @@ -926,11 +1203,28 @@ impl<'db> DocExtractor<'db> { for child in direct_children { match child { + ItemKind::Mod(m) if is_desugared_msg_mod(self.db, m) => { + if let (Some(name), Some(kind)) = + (child.name(self.db), self.item_kind_to_doc_kind(child)) + { + let child_path = child.scope().pretty_path(self.db).unwrap_or_default(); + let summary = self.get_summary(child.scope()); + items.push(DocModuleItem { + name: name.data(self.db).to_string(), + path: child_path, + kind, + summary, + }); + } + } ItemKind::Mod(_) | ItemKind::TopMod(_) => { children.push(self.build_module_node(child)); } ItemKind::StaticAssert(_) | ItemKind::Use(_) | ItemKind::Body(_) => {} _ => { + if self.should_skip_module_item(child) { + continue; + } if let (Some(name), Some(kind)) = (child.name(self.db), self.item_kind_to_doc_kind(child)) { @@ -966,6 +1260,38 @@ impl<'db> DocExtractor<'db> { } } +/// Returns true if `m` is a `Mod` synthesized from a `msg` block. +/// +/// A `msg` block desugars into a `Mod` containing per-variant structs and +/// their `impl MsgVariant` blocks. We treat that `Mod` as a single +/// `DocItemKind::Msg` item (not as a navigable sub-module) so the user sees +/// one "Message" entry in the sidebar rather than a phantom sub-module. +fn is_desugared_msg_mod<'db>(db: &'db dyn SpannedHirDb, m: hir::hir_def::Mod<'db>) -> bool { + matches!( + span::mod_ast(db, m), + HirOrigin::Desugared(DesugaredOrigin::Msg(_)) + ) +} + +/// Returns true if `s` is a `Struct` synthesized from one variant of a `msg` +/// block (the per-variant payload struct). +fn is_desugared_msg_variant_struct<'db>(db: &'db dyn SpannedHirDb, s: Struct<'db>) -> bool { + matches!( + span::struct_ast(db, s), + HirOrigin::Desugared(DesugaredOrigin::Msg(msg)) if msg.variant_idx.is_some() + ) +} + +/// Returns true if `it` is an `ImplTrait` synthesized by the msg desugaring +/// (the per-variant `impl MsgVariant for V` block). These are internal and +/// should not appear in docs. +fn is_desugared_msg_impl_trait<'db>(db: &'db dyn SpannedHirDb, it: ImplTrait<'db>) -> bool { + matches!( + span::impl_trait_ast(db, it), + HirOrigin::Desugared(DesugaredOrigin::Msg(_)) + ) +} + /// Extract the simple name from a potentially qualified/generic type. /// "mod::MyStruct" -> "MyStruct" /// "Option" -> "Option" diff --git a/crates/fe/src/main.rs b/crates/fe/src/main.rs index c7fa5b96e3..508ec108ef 100644 --- a/crates/fe/src/main.rs +++ b/crates/fe/src/main.rs @@ -194,6 +194,12 @@ pub enum Command { /// The directory should contain `core/` and `std/` subdirectories. #[arg(long)] stdlib_path: Option, + /// Include `#[test]` functions in generated docs. + /// + /// Off by default to keep sidebars focused on the public API surface; + /// turn on for a test-centric overview of an ingot. + #[arg(long)] + include_tests: bool, #[command(subcommand)] action: Option, }, @@ -545,6 +551,7 @@ pub fn run(opts: &Options) { output, builtins, stdlib_path, + include_tests, action, } => { if let Some(DocAction::Bundle { with_css }) = action { @@ -560,6 +567,7 @@ pub fn run(opts: &Options) { output.as_ref(), *builtins, stdlib_path.as_ref(), + *include_tests, action.as_ref(), ); } diff --git a/crates/fe/src/scip_index.rs b/crates/fe/src/scip_index.rs index 165fc81c6a..c17f4a9f47 100644 --- a/crates/fe/src/scip_index.rs +++ b/crates/fe/src/scip_index.rs @@ -471,14 +471,22 @@ fn process_module<'db>( let child_kind = child_symbol_kind(child_scope); // Compute child doc URL: parent_url~anchor_prefix.child_name + // + // If the parent URL already contains an anchor (e.g. msg variants, + // which live as `...msg~variant.Name`), we can't nest a second `~` + // because the viewer's anchor extraction uses the first tilde. + // In that case the field link degrades to the variant anchor — + // a click lands on the variant row rather than a specific field. let child_sym_kind = SymbolKind::from(child_scope); if let (Some(parent_url), Some(anchor)) = (&item_doc_url, child_sym_kind.doc_anchor_prefix()) { - doc_urls.insert( - child_symbol.clone(), - format!("{}~{}.{}", parent_url, anchor, child_name), - ); + let url = if parent_url.contains('~') { + parent_url.clone() + } else { + format!("{}~{}.{}", parent_url, anchor, child_name) + }; + doc_urls.insert(child_symbol.clone(), url); } if let Some(doc) = documents.get_mut(&doc_url) { @@ -1356,6 +1364,19 @@ fn scip_range_to_byte_range(line_index: &LineIndex, range: &[i32]) -> (usize, us } } +/// Score a SCIP symbol by how specific its trailing descriptor is. Used as a +/// tiebreaker when two occurrences share the same byte range so that, e.g., +/// `RegistryMsg::Claim#` wins over the enclosing `RegistryMsg:` namespace. +fn symbol_specificity(symbol: &str) -> u8 { + match symbol.chars().last() { + Some('#') | Some('.') => 3, // type / term — most specific + Some(')') => 2, // method signature + Some(']') => 1, // type parameter + Some(':') | Some('/') => 0, // namespace / package — least specific + _ => 1, + } +} + /// Build `rich_signature` parts by overlaying SCIP occurrences on a signature span. /// /// Finds non-definition occurrences within the signature's byte range, maps their @@ -1382,18 +1403,51 @@ fn overlay_occurrences( return vec![]; }; + // Compute byte ranges of `#[...]` attribute regions within the signature + // text (relative to sig_text, i.e. offset 0 == span.byte_start). + // + // Attribute-internal occurrences — e.g. inside `#[selector = sol(...)]`, + // where the type checker may attribute the `sol(...)` call to an + // `Encode::encode_to_ptr` resolution — produce phantom rich-signature + // links that anchor to nothing on the target page. Exclude them so the + // attribute renders as plain text. + let attr_ranges: Vec<(usize, usize)> = find_attr_ranges(sig_text); + // Filter to non-definition occurrences within the signature's byte range - // that have known doc URLs. + // that have known doc URLs, and that don't fall inside an attribute + // (`#[...]`) region. let mut sig_occs: Vec<&ByteOccurrence> = occs .iter() .filter(|o| { - o.byte_start >= span.byte_start - && o.byte_end <= span.byte_end - && !o.is_definition - && symbol_urls.contains_key(&o.symbol) + if o.byte_start < span.byte_start + || o.byte_end > span.byte_end + || o.is_definition + || !symbol_urls.contains_key(&o.symbol) + { + return false; + } + let rel_start = o.byte_start - span.byte_start; + // Drop any occurrence that starts inside a `#[...]` attribute. + // Simple `contained_in` isn't enough: the type checker sometimes + // attributes the `sol(...)` call to a range that extends past + // the closing `]` onto the following variant, producing a + // phantom `encode_to_ptr` link. + !attr_ranges + .iter() + .any(|&(s, e)| rel_start >= s && rel_start < e) }) .collect(); - sig_occs.sort_by_key(|o| o.byte_start); + // Sort by (byte_start asc, specificity desc). When two occurrences + // share a byte range the more specific symbol wins: SCIP descriptors + // ending in `#` (type) or `.` (term) target the concrete item, while + // ones ending in `:` are namespace-shaped (the enclosing msg mod). + // Without this tiebreak the namespace symbol lands first and the + // `occ_start < pos` guard below drops the variant/type link. + sig_occs.sort_by(|a, b| { + a.byte_start + .cmp(&b.byte_start) + .then_with(|| symbol_specificity(&b.symbol).cmp(&symbol_specificity(&a.symbol))) + }); if sig_occs.is_empty() { return vec![]; @@ -1431,6 +1485,58 @@ fn overlay_occurrences( parts } +/// Find byte ranges of `#[...]` attribute regions within `text`. +/// +/// Returns `(start, end)` pairs (relative to `text`) where `end` is the byte +/// offset just past the matching `]`. Brackets inside string literals are +/// respected — `#[sel = sol("f()")]` nests fine. Unterminated attributes are +/// skipped. +/// +/// Used to mask occurrence overlays over attribute tokens so the rich +/// signature renders them as plain text. +fn find_attr_ranges(text: &str) -> Vec<(usize, usize)> { + let bytes = text.as_bytes(); + let mut ranges = Vec::new(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'#' && bytes[i + 1] == b'[' { + let start = i; + let mut depth: i32 = 1; + let mut j = i + 2; + let mut in_str: Option = None; + while j < bytes.len() && depth > 0 { + let c = bytes[j]; + match (in_str, c) { + (Some(q), x) if x == q => { + in_str = None; + } + (Some(_), b'\\') => { + // Skip escaped next char. + j += 1; + } + (None, b'"') | (None, b'\'') => { + in_str = Some(c); + } + (None, b'[') => depth += 1, + (None, b']') => depth -= 1, + _ => {} + } + j += 1; + } + if depth == 0 { + ranges.push((start, j)); + i = j; + continue; + } else { + // Unterminated — stop scanning to avoid runaway masking. + break; + } + } + i += 1; + } + ranges +} + /// Convert a byte offset within a signature text to (line, col) relative to the /// signature start. Line 0 is the first line; col is byte offset from last newline. fn byte_offset_to_sig_line_col(sig_text: &str, byte_offset: usize) -> (i32, i32) { diff --git a/crates/hir/src/core/semantic/symbol.rs b/crates/hir/src/core/semantic/symbol.rs index 570767ef93..461ded8f46 100644 --- a/crates/hir/src/core/semantic/symbol.rs +++ b/crates/hir/src/core/semantic/symbol.rs @@ -523,17 +523,52 @@ pub fn scope_to_doc_path(db: &dyn SpannedHirDb, scope: ScopeId) -> Option` anchor so clicks scroll to the variant section. + if kind_suffix == "msg_variant" + && let Some(sep) = qualified_path.rfind("::") + { + let parent = &qualified_path[..sep]; + let name = &qualified_path[sep + 2..]; + return Some(format!("{}/msg~variant.{}", parent, name)); + } Some(format!("{}/{}", qualified_path, kind_suffix)) } /// Map HIR ItemKind to URL suffix string. -pub fn item_kind_to_url_suffix(item: ItemKind) -> Option<&'static str> { +/// +/// `msg` blocks and their per-variant structs are detected via their +/// `HirOrigin::Desugared(Msg(_))` marker so their doc URLs use `/msg` and +/// `/msg_variant` rather than the generic `/mod` and `/struct` that would +/// point to non-existent DocItems. +pub fn item_kind_to_url_suffix(db: &dyn SpannedHirDb, item: ItemKind) -> Option<&'static str> { + use crate::span::{DesugaredOrigin, HirOrigin, mod_ast, struct_ast}; match item { - ItemKind::TopMod(_) | ItemKind::Mod(_) => Some("mod"), + ItemKind::TopMod(_) => Some("mod"), + ItemKind::Mod(m) => { + if matches!( + mod_ast(db, m), + HirOrigin::Desugared(DesugaredOrigin::Msg(_)) + ) { + Some("msg") + } else { + Some("mod") + } + } ItemKind::Func(_) => Some("fn"), - ItemKind::Struct(_) => Some("struct"), + ItemKind::Struct(s) => { + if matches!( + struct_ast(db, s), + HirOrigin::Desugared(DesugaredOrigin::Msg(msg)) if msg.variant_idx.is_some() + ) { + Some("msg_variant") + } else { + Some("struct") + } + } ItemKind::Enum(_) => Some("enum"), ItemKind::Trait(_) => Some("trait"), ItemKind::Contract(_) => Some("contract"),