fix(review): close all 2026-06-03 codebase-review findings (R1-R18)
Resolves the 18 findings from the parallel codebase review, tracked in development/vimwiki-gap.md. Correctness / perf: - Wiki.config -> Arc<WikiConfig> so cloning a Wiki is a refcount bump (R5) - WorkspaceIndex::remove is no longer O(n^2): a per-source contributions map limits the scan to buckets the source actually wrote into (R6) - render_color now expands color_tag_template (__STYLE__/__CONTENT__), consuming the previously-dead field; ColorNode documented as an extension point; 3 renderer tests added (R3/R4) - wiki_root_for returns empty/nil on no-match instead of falling back to the first wiki (R2); auto_header honours links_space_char (R7) Cleanup / dedup: - Remove dead #[allow(dead_code)] stubs + uncalled pub helpers, narrow imports (R10/R11) - Dedup span_of_inline x3 -> InlineNode::span() (R12) - diary_step single read lock; page_captions single pass (R13) - Lua auto_header loop -> wiki_list(); detect_current_symbol cleanup (R14/R18) Client / docs: - :VimwikiNormalizeLink Vim cmds -> <q-args> (R17); ftplugin header fix (R16); vars.vim multi-wiki limitation documented (R15) - Document 19 config options in README.md + doc/nuwiki.txt; fix list_margin/shiftwidth doc and stale comments (R1/R9) - R8 investigated, confirmed not a real bug (documented) Verified: Neovim harness 307, Vim harness 301/18/21, Rust suite 568, all 0 failed; clippy clean; fresh parallel-agent audit found no regressions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,31 @@ pub enum InlineNode {
|
||||
SoftBreak(SoftBreakNode),
|
||||
}
|
||||
|
||||
impl InlineNode {
|
||||
/// The source [`Span`] this node covers. Every variant wraps a node with
|
||||
/// its own `span` field; this is the one place that maps variant → span.
|
||||
pub fn span(&self) -> Span {
|
||||
match self {
|
||||
InlineNode::Text(n) => n.span,
|
||||
InlineNode::Bold(n) => n.span,
|
||||
InlineNode::Italic(n) => n.span,
|
||||
InlineNode::BoldItalic(n) => n.span,
|
||||
InlineNode::Strikethrough(n) => n.span,
|
||||
InlineNode::Code(n) => n.span,
|
||||
InlineNode::Superscript(n) => n.span,
|
||||
InlineNode::Subscript(n) => n.span,
|
||||
InlineNode::MathInline(n) => n.span,
|
||||
InlineNode::Keyword(n) => n.span,
|
||||
InlineNode::Color(n) => n.span,
|
||||
InlineNode::WikiLink(n) => n.span,
|
||||
InlineNode::ExternalLink(n) => n.span,
|
||||
InlineNode::Transclusion(n) => n.span,
|
||||
InlineNode::RawUrl(n) => n.span,
|
||||
InlineNode::SoftBreak(n) => n.span,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TextNode {
|
||||
pub span: Span,
|
||||
@@ -106,6 +131,15 @@ pub struct KeywordNode {
|
||||
pub keyword: Keyword,
|
||||
}
|
||||
|
||||
/// A named colour span (`color` is a `color_dic` key, `children` the wrapped
|
||||
/// inline content).
|
||||
///
|
||||
/// This is an **extension point**, not a node the vimwiki parser emits: in
|
||||
/// the editor, colour comes from the `:Colorize` family writing literal
|
||||
/// `<span style="color:…">` markup, which round-trips through export via
|
||||
/// `valid_html_tags`. `ColorNode` exists so an alternate syntax (or a future
|
||||
/// `[color]` markup) can feed the renderer's `color_dic` / `color_tag_template`
|
||||
/// path directly; see `HtmlRenderer::render_color`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ColorNode {
|
||||
pub span: Span,
|
||||
|
||||
@@ -46,6 +46,11 @@ pub struct HtmlRenderer {
|
||||
/// names fall through to the default `class="color-<name>"`
|
||||
/// rendering. Matches vimwiki's `color_dic`.
|
||||
colors: HashMap<String, String>,
|
||||
/// vimwiki `color_tag_template`: the HTML emitted for a colour span whose
|
||||
/// name resolves in `colors`. `__STYLE__` expands to the inline style
|
||||
/// (`color:<css>`) and `__CONTENT__` to the rendered inner HTML. The
|
||||
/// default matches upstream's `<span style="…">…</span>`.
|
||||
color_template: String,
|
||||
/// vimwiki `html_header_numbering`: the heading level at which automatic
|
||||
/// section numbering begins (`0` = off, the default). When `>= 1`, every
|
||||
/// heading at that level or deeper is prefixed with a dotted section
|
||||
@@ -82,6 +87,7 @@ impl HtmlRenderer {
|
||||
template: None,
|
||||
vars: HashMap::new(),
|
||||
colors: HashMap::new(),
|
||||
color_template: "<span style=\"__STYLE__\">__CONTENT__</span>".to_string(),
|
||||
header_numbering: 0,
|
||||
header_numbering_sym: String::new(),
|
||||
valid_html_tags: Vec::new(),
|
||||
@@ -156,6 +162,18 @@ impl HtmlRenderer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the colour-span template (vimwiki `color_tag_template`).
|
||||
/// `__STYLE__` is replaced with the resolved `color:<css>` style and
|
||||
/// `__CONTENT__` with the rendered inner HTML. An empty string keeps the
|
||||
/// built-in default.
|
||||
pub fn with_color_template(mut self, template: impl Into<String>) -> Self {
|
||||
let template = template.into();
|
||||
if !template.is_empty() {
|
||||
self.color_template = template;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable vimwiki-style HTML section numbering. `level` is the heading
|
||||
/// level at which numbering starts (`0` disables it); `sym` is appended
|
||||
/// after the number. Mirrors `html_header_numbering` /
|
||||
@@ -311,7 +329,11 @@ impl HtmlRenderer {
|
||||
|
||||
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<p>")?;
|
||||
let sb = if self.text_ignore_newline { " " } else { "<br />" };
|
||||
let sb = if self.text_ignore_newline {
|
||||
" "
|
||||
} else {
|
||||
"<br />"
|
||||
};
|
||||
self.render_inlines_break(&n.children, sb, w)?;
|
||||
w.write_all(b"</p>\n")
|
||||
}
|
||||
@@ -389,7 +411,11 @@ impl HtmlRenderer {
|
||||
}
|
||||
None => w.write_all(b"<li>")?,
|
||||
}
|
||||
let sb = if self.list_ignore_newline { " " } else { "<br />" };
|
||||
let sb = if self.list_ignore_newline {
|
||||
" "
|
||||
} else {
|
||||
"<br />"
|
||||
};
|
||||
self.render_inlines_break(&n.children, sb, w)?;
|
||||
if let Some(sublist) = &n.sublist {
|
||||
w.write_all(b"\n")?;
|
||||
@@ -664,17 +690,32 @@ impl HtmlRenderer {
|
||||
}
|
||||
|
||||
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
// Render the inner content up front so it can be slotted into the
|
||||
// colour template.
|
||||
let mut content = Vec::new();
|
||||
self.render_inlines(&n.children, &mut content)?;
|
||||
let content = String::from_utf8_lossy(&content);
|
||||
|
||||
if let Some(css) = self.colors.get(&n.color) {
|
||||
w.write_all(b"<span style=\"color:")?;
|
||||
write_escaped(css, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
// Known colour name: expand `color_tag_template` with the resolved
|
||||
// inline style and rendered content.
|
||||
let mut style = b"color:".to_vec();
|
||||
write_escaped(css, &mut style)?;
|
||||
let style = String::from_utf8_lossy(&style);
|
||||
let html = self
|
||||
.color_template
|
||||
.replace("__STYLE__", &style)
|
||||
.replace("__CONTENT__", &content);
|
||||
w.write_all(html.as_bytes())
|
||||
} else {
|
||||
// Unknown name: fall back to a class hook the stylesheet can target
|
||||
// (the template only models the inline-style case).
|
||||
w.write_all(b"<span class=\"color-")?;
|
||||
write_escaped(&n.color, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
w.write_all(content.as_bytes())?;
|
||||
w.write_all(b"</span>")
|
||||
}
|
||||
self.render_inlines(&n.children, w)?;
|
||||
w.write_all(b"</span>")
|
||||
}
|
||||
|
||||
fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
|
||||
@@ -26,12 +26,6 @@ impl SyntaxRegistry {
|
||||
self.plugins.push(Arc::new(plugin));
|
||||
}
|
||||
|
||||
/// Register a pre-arc'd plugin. Useful when the same plugin instance
|
||||
/// also needs to be held elsewhere.
|
||||
pub fn register_arc(&mut self, plugin: Arc<dyn SyntaxPlugin>) {
|
||||
self.plugins.push(plugin);
|
||||
}
|
||||
|
||||
/// Look up a plugin by its `id`.
|
||||
pub fn get(&self, id: &str) -> Option<&dyn SyntaxPlugin> {
|
||||
self.plugins
|
||||
|
||||
@@ -37,9 +37,9 @@ use crate::ast::{
|
||||
BlockNode, BlockquoteNode, BoldNode, CodeNode, CommentNode, DefinitionItemNode,
|
||||
DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, HeadingNode, HorizontalRuleNode,
|
||||
InlineNode, ItalicNode, KeywordNode, LinkKind, LinkTarget, ListItemNode, ListNode, ListSymbol,
|
||||
MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode, Span,
|
||||
StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, TableNode, TableRowNode,
|
||||
SoftBreakNode, TagNode, TagScope, TextNode, TransclusionNode, WikiLinkNode,
|
||||
MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode,
|
||||
SoftBreakNode, Span, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode,
|
||||
TableNode, TableRowNode, TagNode, TagScope, TextNode, TransclusionNode, WikiLinkNode,
|
||||
};
|
||||
use crate::syntax::{Parser, TokenStream};
|
||||
|
||||
@@ -1426,30 +1426,9 @@ fn strip_directory(s: &str) -> (String, bool) {
|
||||
// ===== Span helpers =====
|
||||
|
||||
fn span_start_of_inline(node: &InlineNode) -> Option<crate::ast::Position> {
|
||||
Some(span_of_inline(node).start)
|
||||
Some(node.span().start)
|
||||
}
|
||||
|
||||
fn span_end_of_inline(node: &InlineNode) -> Option<crate::ast::Position> {
|
||||
Some(span_of_inline(node).end)
|
||||
}
|
||||
|
||||
fn span_of_inline(node: &InlineNode) -> Span {
|
||||
match node {
|
||||
InlineNode::Text(n) => n.span,
|
||||
InlineNode::Bold(n) => n.span,
|
||||
InlineNode::Italic(n) => n.span,
|
||||
InlineNode::BoldItalic(n) => n.span,
|
||||
InlineNode::Strikethrough(n) => n.span,
|
||||
InlineNode::Code(n) => n.span,
|
||||
InlineNode::Superscript(n) => n.span,
|
||||
InlineNode::Subscript(n) => n.span,
|
||||
InlineNode::MathInline(n) => n.span,
|
||||
InlineNode::Keyword(n) => n.span,
|
||||
InlineNode::Color(n) => n.span,
|
||||
InlineNode::WikiLink(n) => n.span,
|
||||
InlineNode::ExternalLink(n) => n.span,
|
||||
InlineNode::Transclusion(n) => n.span,
|
||||
InlineNode::RawUrl(n) => n.span,
|
||||
InlineNode::SoftBreak(n) => n.span,
|
||||
}
|
||||
Some(node.span().end)
|
||||
}
|
||||
|
||||
@@ -328,6 +328,64 @@ fn template_substitutes_vimwiki_percent_placeholders() {
|
||||
assert!(!out.contains('%'), "no placeholder left: {out}");
|
||||
}
|
||||
|
||||
// ===== Colour spans =====
|
||||
|
||||
// `ColorNode` is an extension point the vimwiki parser never emits, so build
|
||||
// a document around one by hand and render it directly.
|
||||
fn doc_with_color(color: &str) -> DocumentNode {
|
||||
use nuwiki_core::ast::{ColorNode, ParagraphNode};
|
||||
let color_node = InlineNode::Color(ColorNode {
|
||||
span: Span::default(),
|
||||
color: color.to_string(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: "hi".to_string(),
|
||||
})],
|
||||
});
|
||||
DocumentNode {
|
||||
children: vec![BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![color_node],
|
||||
})],
|
||||
..DocumentNode::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_dic_name_uses_inline_style_via_default_template() {
|
||||
let doc = doc_with_color("red");
|
||||
let renderer =
|
||||
HtmlRenderer::new().with_colors([("red".to_string(), "crimson".to_string())].into());
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains("<span style=\"color:crimson\">hi</span>"),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_tag_template_override_is_honored() {
|
||||
let doc = doc_with_color("red");
|
||||
let renderer = HtmlRenderer::new()
|
||||
.with_colors([("red".to_string(), "crimson".to_string())].into())
|
||||
.with_color_template("<em data-style=\"__STYLE__\">__CONTENT__</em>");
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains("<em data-style=\"color:crimson\">hi</em>"),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_color_name_falls_back_to_class() {
|
||||
let doc = doc_with_color("plaid");
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains("<span class=\"color-plaid\">hi</span>"),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== End-to-end smoke =====
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -499,24 +499,19 @@ fn diary_step(backend: &Backend, args: Vec<Value>, forward: bool) -> Result<Opti
|
||||
let Some(wiki) = backend.wiki_for_uri(&p.uri) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Pivot period: prefer the current page's own period (any flavour),
|
||||
// falling back to the wiki's configured frequency for "today" when
|
||||
// the cursor isn't on a diary page. This makes <C-Down> on a weekly
|
||||
// entry jump to the next weekly entry, not the next daily one.
|
||||
let pivot: nuwiki_core::date::DiaryPeriod = {
|
||||
let idx = wiki
|
||||
.index
|
||||
.read()
|
||||
.map_err(|_| "index lock poisoned".to_string())?;
|
||||
idx.page(&p.uri)
|
||||
.and_then(|page| page.diary_period)
|
||||
.unwrap_or_else(|| wiki.config.diary_calendar().today())
|
||||
};
|
||||
let next = {
|
||||
let idx = wiki
|
||||
.index
|
||||
.read()
|
||||
.map_err(|_| "index lock poisoned".to_string())?;
|
||||
// Pivot period: prefer the current page's own period (any flavour),
|
||||
// falling back to the wiki's configured frequency for "today" when
|
||||
// the cursor isn't on a diary page. This makes <C-Down> on a weekly
|
||||
// entry jump to the next weekly entry, not the next daily one.
|
||||
let pivot: nuwiki_core::date::DiaryPeriod = idx
|
||||
.page(&p.uri)
|
||||
.and_then(|page| page.diary_period)
|
||||
.unwrap_or_else(|| wiki.config.diary_calendar().today());
|
||||
if forward {
|
||||
crate::diary::next_period(&idx, &pivot)
|
||||
} else {
|
||||
@@ -1911,10 +1906,12 @@ pub mod ops {
|
||||
pub fn page_captions(
|
||||
index: &crate::index::WorkspaceIndex,
|
||||
) -> std::collections::BTreeMap<String, String> {
|
||||
// Single pass over the page table — no per-name name→uri→page double
|
||||
// lookup. The BTreeMap keeps the output name-sorted.
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
for name in index.page_names() {
|
||||
if let Some(h) = index.page_by_name(&name).and_then(|p| p.headings.first()) {
|
||||
map.insert(name, h.title.clone());
|
||||
for page in index.pages_by_uri.values() {
|
||||
if let Some(h) = page.headings.first() {
|
||||
map.insert(page.name.clone(), h.title.clone());
|
||||
}
|
||||
}
|
||||
map
|
||||
@@ -3267,7 +3264,6 @@ pub mod ops {
|
||||
// `tag_links_edit` powers `nuwiki.tags.generateLinks`; the rebuild
|
||||
// command is a direct re-walk of the wiki root in the dispatcher.
|
||||
|
||||
use crate::index::TagOccurrence;
|
||||
use nuwiki_core::ast::TagScope;
|
||||
|
||||
/// One row returned by `nuwiki.tags.search`. Serialised as
|
||||
@@ -3460,11 +3456,6 @@ pub mod ops {
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the import live for downstream use even when the local module
|
||||
// doesn't reference it directly.
|
||||
#[allow(dead_code)]
|
||||
fn _retain_tag_occurrence_import(_o: &TagOccurrence) {}
|
||||
|
||||
fn whole_doc_span(text: &str) -> Span {
|
||||
let bytes = text.as_bytes();
|
||||
let mut line = 0u32;
|
||||
@@ -3495,7 +3486,7 @@ pub mod ops {
|
||||
/// Kept out of `ops` because everything here is side-effecting — pure
|
||||
/// rendering / templating lives in [`crate::export`].
|
||||
pub mod export_ops {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::DocumentNode;
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
@@ -3876,9 +3867,4 @@ pub mod export_ops {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Re-export so the dispatcher doesn't have to know about the export
|
||||
/// module's path manipulation primitives.
|
||||
#[allow(dead_code)]
|
||||
pub fn _silence_unused(_: &Path) {}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use tower_lsp::lsp_types::{InitializeParams, Url};
|
||||
use tower_lsp::lsp_types::InitializeParams;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
@@ -198,9 +198,11 @@ pub struct HtmlConfig {
|
||||
/// vimwiki `user_htmls`: basenames of HTML files with no wiki source that
|
||||
/// `allToHtml` must not prune. Empty by default.
|
||||
pub user_htmls: Vec<String>,
|
||||
/// vimwiki `color_tag_template`: HTML template for a colour span. The
|
||||
/// `__COLORFG__` / `__COLORBG__` / `__CONTENT__` placeholders are filled
|
||||
/// from `color_dic`. Default matches upstream's `<span style=…>` template.
|
||||
/// vimwiki `color_tag_template`: HTML template for a colour span whose name
|
||||
/// resolves in `color_dic`. `__STYLE__` expands to the inline style
|
||||
/// (`color:<css>`) and `__CONTENT__` to the rendered inner HTML. Default
|
||||
/// matches upstream's `<span style="…">…</span>` template. Consumed by
|
||||
/// `HtmlRenderer::render_color`.
|
||||
pub color_tag_template: String,
|
||||
}
|
||||
|
||||
@@ -1070,6 +1072,3 @@ pub fn config_from_json(value: serde_json::Value) -> Config {
|
||||
cfg.apply_change(&value);
|
||||
cfg
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _ensure_url_is_used(_u: &Url) {}
|
||||
|
||||
@@ -88,14 +88,6 @@ impl WorkspaceEditBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn edits<I>(&mut self, uri: Url, edits: I) -> &mut Self
|
||||
where
|
||||
I: IntoIterator<Item = TextEdit>,
|
||||
{
|
||||
self.changes.entry(uri).or_default().extend(edits);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self {
|
||||
self.ops.push(op);
|
||||
self
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
//! above wired up; returns the final HTML string.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode, LinkKind};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
|
||||
use crate::config::{HtmlConfig, WikiConfig};
|
||||
use crate::config::HtmlConfig;
|
||||
|
||||
/// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML
|
||||
/// output path under `html_path`. Honours
|
||||
@@ -251,6 +251,9 @@ pub fn render_page_html(
|
||||
if !cfg.color_dic.is_empty() {
|
||||
r = r.with_colors(cfg.color_dic.clone());
|
||||
}
|
||||
if !cfg.color_tag_template.is_empty() {
|
||||
r = r.with_color_template(cfg.color_tag_template.clone());
|
||||
}
|
||||
if cfg.html_header_numbering > 0 {
|
||||
r = r.with_header_numbering(
|
||||
cfg.html_header_numbering,
|
||||
@@ -425,9 +428,3 @@ code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
|
||||
table{border-collapse:collapse}\
|
||||
table td,table th{border:1px solid #ccc;padding:.25em .5em}\
|
||||
ul.toc{padding-left:1.5em}\n";
|
||||
|
||||
/// Convenience wrapper exposed for the dispatcher: convert a wiki config
|
||||
/// to the absolute output dir.
|
||||
pub fn html_output_dir(cfg: &WikiConfig) -> &Path {
|
||||
&cfg.html.html_path
|
||||
}
|
||||
|
||||
@@ -123,6 +123,18 @@ pub struct WorkspaceIndex {
|
||||
/// the nav handlers (tag-as-anchor `definition`, `workspace/symbol`
|
||||
/// inclusion) and the `nuwiki.tags.search` command.
|
||||
pub tags_by_name: HashMap<String, Vec<TagOccurrence>>,
|
||||
/// For each source URI, the backlink-bucket keys and tag-bucket names it
|
||||
/// contributed. Lets `remove` touch only the affected buckets instead of
|
||||
/// scanning every bucket in the workspace (O(touched), not O(total)).
|
||||
contributions: HashMap<Url, SourceContributions>,
|
||||
}
|
||||
|
||||
/// The bucket keys a single source page wrote into, so they can be undone
|
||||
/// in `remove` without a full scan. See [`WorkspaceIndex::contributions`].
|
||||
#[derive(Debug, Default)]
|
||||
struct SourceContributions {
|
||||
backlink_keys: Vec<String>,
|
||||
tag_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl WorkspaceIndex {
|
||||
@@ -175,15 +187,20 @@ impl WorkspaceIndex {
|
||||
};
|
||||
index_blocks(&ast.children, &mut page);
|
||||
|
||||
let mut contrib = SourceContributions::default();
|
||||
let ext = self.file_extension.as_deref();
|
||||
for link in &page.outgoing {
|
||||
if let Some(tgt) = &link.target_page {
|
||||
let key = canonical_link_name(tgt, &name, link.is_absolute, ext);
|
||||
self.backlinks.entry(key).or_default().push(Backlink {
|
||||
source_uri: uri.clone(),
|
||||
source_span: link.span,
|
||||
target_anchor: link.anchor.clone(),
|
||||
});
|
||||
self.backlinks
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.push(Backlink {
|
||||
source_uri: uri.clone(),
|
||||
source_span: link.span,
|
||||
target_anchor: link.anchor.clone(),
|
||||
});
|
||||
contrib.backlink_keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +214,9 @@ impl WorkspaceIndex {
|
||||
span: tag.span,
|
||||
scope: tag.scope,
|
||||
});
|
||||
contrib.tag_names.push(tag.name.clone());
|
||||
}
|
||||
self.contributions.insert(uri.clone(), contrib);
|
||||
|
||||
self.pages_by_name.insert(name, uri.clone());
|
||||
self.pages_by_uri.insert(uri, page);
|
||||
@@ -207,14 +226,26 @@ impl WorkspaceIndex {
|
||||
if let Some(page) = self.pages_by_uri.remove(uri) {
|
||||
self.pages_by_name.remove(&page.name);
|
||||
}
|
||||
for entries in self.backlinks.values_mut() {
|
||||
entries.retain(|b| &b.source_uri != uri);
|
||||
// Only revisit the buckets this source actually wrote into, instead
|
||||
// of scanning every backlink/tag bucket in the workspace.
|
||||
if let Some(contrib) = self.contributions.remove(uri) {
|
||||
for key in contrib.backlink_keys {
|
||||
if let Some(entries) = self.backlinks.get_mut(&key) {
|
||||
entries.retain(|b| &b.source_uri != uri);
|
||||
if entries.is_empty() {
|
||||
self.backlinks.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in contrib.tag_names {
|
||||
if let Some(entries) = self.tags_by_name.get_mut(&name) {
|
||||
entries.retain(|t| &t.uri != uri);
|
||||
if entries.is_empty() {
|
||||
self.tags_by_name.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.backlinks.retain(|_, v| !v.is_empty());
|
||||
for entries in self.tags_by_name.values_mut() {
|
||||
entries.retain(|t| &t.uri != uri);
|
||||
}
|
||||
self.tags_by_name.retain(|_, v| !v.is_empty());
|
||||
}
|
||||
|
||||
/// All occurrences of a tag across the workspace. Empty slice when the
|
||||
@@ -306,21 +337,6 @@ impl WorkspaceIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a parsed [`DiaryDate`] when `uri` is a daily diary entry —
|
||||
/// the path lives under `<root>/<diary_rel_path>/` and its stem parses
|
||||
/// as `YYYY-MM-DD`. For weekly/monthly/yearly entries see
|
||||
/// [`diary_period_for_uri`].
|
||||
pub fn diary_date_for_uri(
|
||||
uri: &Url,
|
||||
root: Option<&Path>,
|
||||
diary_rel_path: Option<&str>,
|
||||
) -> Option<DiaryDate> {
|
||||
match diary_period_for_uri(uri, root, diary_rel_path)? {
|
||||
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of
|
||||
/// the four supported frequencies. Recognises:
|
||||
///
|
||||
@@ -329,9 +345,8 @@ pub fn diary_date_for_uri(
|
||||
/// `YYYY-MM` → Month
|
||||
/// `YYYY` → Year
|
||||
///
|
||||
/// As with `diary_date_for_uri`, the path must sit under
|
||||
/// `<root>/<diary_rel_path>/` to be accepted; without a `root` we accept
|
||||
/// any stem (so ad-hoc test setups still work).
|
||||
/// The path must sit under `<root>/<diary_rel_path>/` to be accepted;
|
||||
/// without a `root` we accept any stem (so ad-hoc test setups still work).
|
||||
pub fn diary_period_for_uri(
|
||||
uri: &Url,
|
||||
root: Option<&Path>,
|
||||
|
||||
@@ -110,9 +110,8 @@ pub async fn run_stdio() -> io::Result<()> {
|
||||
struct DocumentState {
|
||||
text: String,
|
||||
ast: DocumentNode,
|
||||
/// Last version we observed from the client. Held even
|
||||
/// though nothing reads it back yet.
|
||||
#[allow(dead_code)]
|
||||
/// Last version we observed from the client. Echoed back on the
|
||||
/// diagnostics we publish for this document.
|
||||
version: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ fn find_in_list_item(item: &ListItemNode, line: u32, col: u32) -> Option<&Inline
|
||||
|
||||
fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> {
|
||||
for n in inlines {
|
||||
if !span_contains(span_of_inline(n), line, col) {
|
||||
if !span_contains(n.span(), line, col) {
|
||||
continue;
|
||||
}
|
||||
// Descend into inline containers so we return the *innermost* hit.
|
||||
@@ -127,24 +127,3 @@ fn span_contains(s: Span, line: u32, col: u32) -> bool {
|
||||
let p = (line, col);
|
||||
p >= start && p < end
|
||||
}
|
||||
|
||||
pub fn span_of_inline(node: &InlineNode) -> Span {
|
||||
match node {
|
||||
InlineNode::Text(n) => n.span,
|
||||
InlineNode::Bold(n) => n.span,
|
||||
InlineNode::Italic(n) => n.span,
|
||||
InlineNode::BoldItalic(n) => n.span,
|
||||
InlineNode::Strikethrough(n) => n.span,
|
||||
InlineNode::Code(n) => n.span,
|
||||
InlineNode::Superscript(n) => n.span,
|
||||
InlineNode::Subscript(n) => n.span,
|
||||
InlineNode::MathInline(n) => n.span,
|
||||
InlineNode::Keyword(n) => n.span,
|
||||
InlineNode::Color(n) => n.span,
|
||||
InlineNode::WikiLink(n) => n.span,
|
||||
InlineNode::ExternalLink(n) => n.span,
|
||||
InlineNode::Transclusion(n) => n.span,
|
||||
InlineNode::RawUrl(n) => n.span,
|
||||
InlineNode::SoftBreak(n) => n.span,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ fn emit_block(block: &BlockNode, text: &str, idx: &LineIndex, out: &mut Vec<RawT
|
||||
for item in &dl.items {
|
||||
if let Some(term) = &item.term {
|
||||
if let (Some(first), Some(last)) = (term.first(), term.last()) {
|
||||
let s = Span::new(span_of_inline(first).start, span_of_inline(last).end);
|
||||
let s = Span::new(first.span().start, last.span().end);
|
||||
split_span(s, text, idx, TOKEN_DEFINITION_TERM, 0, out);
|
||||
}
|
||||
}
|
||||
@@ -251,27 +251,6 @@ fn emit_inlines(inlines: &[InlineNode], text: &str, idx: &LineIndex, out: &mut V
|
||||
}
|
||||
}
|
||||
|
||||
fn span_of_inline(node: &InlineNode) -> Span {
|
||||
match node {
|
||||
InlineNode::Text(n) => n.span,
|
||||
InlineNode::Bold(n) => n.span,
|
||||
InlineNode::Italic(n) => n.span,
|
||||
InlineNode::BoldItalic(n) => n.span,
|
||||
InlineNode::Strikethrough(n) => n.span,
|
||||
InlineNode::Code(n) => n.span,
|
||||
InlineNode::Superscript(n) => n.span,
|
||||
InlineNode::Subscript(n) => n.span,
|
||||
InlineNode::MathInline(n) => n.span,
|
||||
InlineNode::Keyword(n) => n.span,
|
||||
InlineNode::Color(n) => n.span,
|
||||
InlineNode::WikiLink(n) => n.span,
|
||||
InlineNode::ExternalLink(n) => n.span,
|
||||
InlineNode::Transclusion(n) => n.span,
|
||||
InlineNode::RawUrl(n) => n.span,
|
||||
InlineNode::SoftBreak(n) => n.span,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `Span` (potentially multi-line) into one or more single-line
|
||||
/// `RawToken`s. The LSP wire format can't express tokens that span newlines,
|
||||
/// so we split on line boundaries.
|
||||
|
||||
@@ -19,7 +19,10 @@ pub struct WikiId(pub u32);
|
||||
#[derive(Clone)]
|
||||
pub struct Wiki {
|
||||
pub id: WikiId,
|
||||
pub config: WikiConfig,
|
||||
// `Arc` so cloning a `Wiki` (done on every handler entry that picks a
|
||||
// wiki out of the list) is a refcount bump, not a deep copy of the whole
|
||||
// config — color_dic, templates, header strings, and so on.
|
||||
pub config: Arc<WikiConfig>,
|
||||
pub index: Arc<RwLock<WorkspaceIndex>>,
|
||||
}
|
||||
|
||||
@@ -29,7 +32,11 @@ impl Wiki {
|
||||
.with_diary_rel_path(Some(config.diary_rel_path.clone()))
|
||||
.with_file_extension(Some(config.file_extension.clone()));
|
||||
let index = Arc::new(RwLock::new(index));
|
||||
Self { id, config, index }
|
||||
Self {
|
||||
id,
|
||||
config: Arc::new(config),
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `uri` resolves to a file under this wiki's root.
|
||||
|
||||
@@ -254,7 +254,8 @@ fn remove_checkbox_none_without_checkbox() {
|
||||
fn change_level_indents_single_item() {
|
||||
let src = "- one\n- two\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &[]).expect("edit");
|
||||
let edit =
|
||||
ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &[]).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1);
|
||||
// delta=1 → +2 spaces
|
||||
@@ -286,7 +287,10 @@ fn cycle_bullets_rotates_glyph_on_indent() {
|
||||
.map(|e| e.new_text.clone())
|
||||
.collect();
|
||||
assert!(texts.contains(&" ".to_string()), "indent edit: {texts:?}");
|
||||
assert!(texts.contains(&"*".to_string()), "glyph rotated to *: {texts:?}");
|
||||
assert!(
|
||||
texts.contains(&"*".to_string()),
|
||||
"glyph rotated to *: {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -301,14 +305,18 @@ fn cycle_bullets_off_leaves_glyph() {
|
||||
.map(|e| e.new_text.clone())
|
||||
.collect();
|
||||
// Only the indent edit; no glyph rewrite.
|
||||
assert!(!texts.iter().any(|t| t == "*"), "no glyph change: {texts:?}");
|
||||
assert!(
|
||||
!texts.iter().any(|t| t == "*"),
|
||||
"no glyph change: {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_level_whole_subtree_walks_descendants() {
|
||||
let src = "- parent\n - child a\n - child b\n- sibling\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true, false, &[]).expect("edit");
|
||||
let edit =
|
||||
ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true, false, &[]).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
// Parent + 2 children get indented. Sibling stays.
|
||||
assert!(edits.len() >= 3, "got {} edits", edits.len());
|
||||
|
||||
@@ -287,7 +287,10 @@ fn render_page_html_ignore_newline_controls_soft_breaks() {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(br.contains("alpha<br />beta"), "para <br>: {br}");
|
||||
assert!(br.contains("one<br />two") || br.contains("one<br /> two"), "list <br>: {br}");
|
||||
assert!(
|
||||
br.contains("one<br />two") || br.contains("one<br /> two"),
|
||||
"list <br>: {br}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -328,7 +328,10 @@ fn generated_links_caption_emits_first_heading() {
|
||||
assert!(out.contains("- [[Notes]]"), "fallback: {out}");
|
||||
// Without the caption map, both are bare.
|
||||
let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
|
||||
assert!(bare.contains("- [[About]]") && !bare.contains("About Us"), "bare: {bare}");
|
||||
assert!(
|
||||
bare.contains("- [[About]]") && !bare.contains("About Us"),
|
||||
"bare: {bare}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user