phase 12: tags — lexer, AST, index, anchor resolution, ws/symbol
CI / cargo fmt --check (push) Successful in 18s
CI / cargo clippy (push) Successful in 1m3s
CI / cargo test (push) Successful in 1m11s

Vimwiki `:tag1:tag2:` syntax across the full stack. P11 resolved as
strict-vimwiki — no markdown `#tag` alias.

AST (nuwiki-core):
- `TagScope { File, Heading(usize), Standalone }` mirrors vimwiki's
  three placement-determined meanings: lines 0-1 → File; one or two
  lines below the most recent heading → Heading(idx); otherwise
  Standalone.
- `TagNode { span, tags: Vec<String>, scope }` + `BlockNode::Tag`.
- `PageMetadata.tags: Vec<String>` accumulates the file-scope tag
  names — convenience projection of the Tag blocks whose scope is
  File. New field is additive (Default derive) so existing consumers
  using `..Default::default()` keep working.
- `Visitor::visit_tag` lands with a default body and `walk_block`
  dispatches the new arm.

Lexer (`syntax/vimwiki/lexer.rs`):
- `VimwikiTokenKind::Tag(Vec<String>)` block-level token.
- `try_lex_tag` runs in the normal-line dispatcher right after heading
  recognition; demands the *whole trimmed line* match `:name:name…:`,
  with non-empty whitespace-free names between adjacent colons. Empty
  segments (`::tag::`), whitespace inside a name, or single-colon
  lines are all rejected. Doesn't fight `Term::` (definition-list
  marker) because that pattern requires text before the `::`.
- Indentation is tolerated; the span covers the trimmed run.

Parser (`syntax/vimwiki/parser.rs`):
- `ParseState` gains `headings_seen` + `last_heading_line`.
- `parse_heading` bumps both on every closed heading so the tag
  parser can detect the heading window.
- `parse_tag` reads the Tag token, computes the scope via
  `scope_for_tag(tag_line)`, eats the trailing newline, and emits the
  TagNode.
- `parse_document` post-processes each block: when a Tag with
  `scope == File` is emitted, its names are pushed (de-duped) into
  `metadata.tags`.

Renderer (`render/html.rs`):
- `render_tag` emits `<div class="tags"><span class="tag"
  id="tag-name">name</span>...</div>`. The `id` is what makes
  `[[Page#tag-name]]` jump to the rendered tag.

Semantic tokens (`semantic_tokens.rs`):
- New `TOKEN_TAG = 19` / `vimwikiTag` so editors highlight tag lines
  distinctly. `emit_block` adds the Tag arm.

Workspace index (`index.rs`):
- `IndexedPage.tags: Vec<TagInfo>` records each tag occurrence on a
  page with its span + scope.
- `WorkspaceIndex.tags_by_name: HashMap<String, Vec<TagOccurrence>>`
  is the reverse map for cross-page anchor resolution + the Phase 13
  `nuwiki.tags.search` command.
- `upsert` populates both; `remove` scrubs the reverse map and drops
  empty entries; `tags_for(name)` is the public lookup.

LSP handlers (`lib.rs`):
- `heading_range_in` falls back to tag matching when the anchor
  isn't a heading slug — so `[[Page#some-tag]]` lands on the matching
  TagNode. Heading anchors win over tag anchors when both match (the
  vimwiki precedence).
- `workspace/symbol` includes tags alongside headings, named `:tag:`
  and kinded `PROPERTY` so editor UIs can distinguish them.

Tests (20 new):
- Syntax (15 in `vimwiki_tags.rs`):
  lexer recognition + rejection of `::tag::`, whitespace-in-name,
  Term::-conflict; parser scope paths (file, heading-1-below,
  heading-2-below, standalone, no-heading, line-1 priority);
  metadata dedup; renderer HTML + escaping.
- LSP index (5 in `tags_index_and_lsp.rs`):
  per-page tag population, cross-page `tags_by_name` aggregation,
  re-upsert replaces stale tags, `remove` cleans the reverse map,
  heading-scope tag indexed but not in file metadata.

All 191 v1.0 + Phase 11 tests still pass — backward compatible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 14:54:55 +00:00
parent ea574f23f8
commit 8b29d4e9b9
12 changed files with 550 additions and 11 deletions
+18 -1
View File
@@ -19,7 +19,7 @@ use crate::ast::{
HeadingNode, HorizontalRuleNode, InlineNode, ItalicNode, Keyword, KeywordNode, LinkKind,
LinkTarget, ListItemNode, ListNode, MathBlockNode, MathInlineNode, ParagraphNode,
PreformattedNode, RawUrlNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode,
TableNode, TableRowNode, TextNode, TransclusionNode, WikiLinkNode,
TableNode, TableRowNode, TagNode, TextNode, TransclusionNode, WikiLinkNode,
};
use super::Renderer;
@@ -107,10 +107,27 @@ impl HtmlRenderer {
BlockNode::DefinitionList(n) => self.render_definition_list(n, w),
BlockNode::Table(n) => self.render_table(n, w),
BlockNode::Comment(n) => self.render_comment(n, w),
BlockNode::Tag(n) => self.render_tag(n, w),
BlockNode::Error(n) => self.render_error(n, w),
}
}
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
// `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag.
w.write_all(b"<div class=\"tags\">")?;
for (i, name) in n.tags.iter().enumerate() {
if i > 0 {
w.write_all(b" ")?;
}
w.write_all(b"<span class=\"tag\" id=\"tag-")?;
write_escaped(name, w)?;
w.write_all(b"\">")?;
write_escaped(name, w)?;
w.write_all(b"</span>")?;
}
w.write_all(b"</div>\n")
}
fn render_heading(&self, n: &HeadingNode, w: &mut dyn Write) -> io::Result<()> {
let level = n.level.clamp(1, 6);
let class = if n.centered {