feat: initial nuwiki Rust workspace (core, lsp, ls)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
//! Diagnostic sources beyond parse-time `ErrorNode`s.
|
||||
//!
|
||||
//! The `nuwiki.link` source emits broken-link warnings. Walks
|
||||
//! every `WikiLinkNode` in the AST and emits one diagnostic per:
|
||||
//! - `Wiki` target that doesn't resolve to a page in the workspace index,
|
||||
//! - `Wiki`/`AnchorOnly` target with an anchor that matches neither a
|
||||
//! heading nor a `:tag:` on the target page,
|
||||
//! - `File`/`Local` target whose resolved path doesn't exist on disk.
|
||||
//!
|
||||
//! Interwiki, Diary, Raw, and external links are not diagnosed —
|
||||
//! interwiki resolution and diary handling live elsewhere, and we
|
||||
//! don't fetch URLs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Url};
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, ListItemNode, ListNode,
|
||||
TableNode, WikiLinkNode,
|
||||
};
|
||||
|
||||
use crate::config::LinkSeverity;
|
||||
use crate::index::{OutgoingLink, WorkspaceIndex};
|
||||
use crate::to_lsp_range;
|
||||
|
||||
pub const LINK_SOURCE: &str = "nuwiki.link";
|
||||
|
||||
/// Convert a [`LinkSeverity`] to its LSP counterpart. `Off` returns `None`
|
||||
/// so callers can short-circuit before walking the document.
|
||||
pub fn severity_to_lsp(s: LinkSeverity) -> Option<DiagnosticSeverity> {
|
||||
match s {
|
||||
LinkSeverity::Off => None,
|
||||
LinkSeverity::Hint => Some(DiagnosticSeverity::HINT),
|
||||
LinkSeverity::Warning => Some(DiagnosticSeverity::WARNING),
|
||||
LinkSeverity::Error => Some(DiagnosticSeverity::ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the AST and collect every `WikiLinkNode` in source order.
|
||||
pub fn collect_wiki_links(ast: &DocumentNode) -> Vec<&WikiLinkNode> {
|
||||
let mut out = Vec::new();
|
||||
for block in &ast.children {
|
||||
walk_block(block, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_block<'a>(block: &'a BlockNode, out: &mut Vec<&'a WikiLinkNode>) {
|
||||
match block {
|
||||
BlockNode::Heading(h) => walk_inlines(&h.children, out),
|
||||
BlockNode::Paragraph(p) => walk_inlines(&p.children, out),
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for c in children {
|
||||
walk_block(c, out);
|
||||
}
|
||||
}
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
walk_list_item(item, out);
|
||||
}
|
||||
}
|
||||
BlockNode::DefinitionList(dl) => {
|
||||
for item in &dl.items {
|
||||
if let Some(t) = &item.term {
|
||||
walk_inlines(t, out);
|
||||
}
|
||||
for d in &item.definitions {
|
||||
walk_inlines(d, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Table(TableNode { rows, .. }) => {
|
||||
for row in rows {
|
||||
for cell in &row.cells {
|
||||
walk_inlines(&cell.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_list_item<'a>(item: &'a ListItemNode, out: &mut Vec<&'a WikiLinkNode>) {
|
||||
walk_inlines(&item.children, out);
|
||||
if let Some(sub) = &item.sublist {
|
||||
for sub_it in &sub.items {
|
||||
walk_list_item(sub_it, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_inlines<'a>(inlines: &'a [InlineNode], out: &mut Vec<&'a WikiLinkNode>) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::WikiLink(w) => {
|
||||
out.push(w);
|
||||
if let Some(d) = &w.description {
|
||||
walk_inlines(d, out);
|
||||
}
|
||||
}
|
||||
InlineNode::Bold(b) => walk_inlines(&b.children, out),
|
||||
InlineNode::Italic(i) => walk_inlines(&i.children, out),
|
||||
InlineNode::BoldItalic(bi) => walk_inlines(&bi.children, out),
|
||||
InlineNode::Strikethrough(s) => walk_inlines(&s.children, out),
|
||||
InlineNode::Superscript(s) => walk_inlines(&s.children, out),
|
||||
InlineNode::Subscript(s) => walk_inlines(&s.children, out),
|
||||
InlineNode::Color(c) => walk_inlines(&c.children, out),
|
||||
InlineNode::ExternalLink(e) => {
|
||||
if let Some(d) = &e.description {
|
||||
walk_inlines(d, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classification of why a single wikilink is broken. Returned by
|
||||
/// [`classify_link`] so callers (diagnostics + `nuwiki.workspace.checkLinks`)
|
||||
/// share the same logic.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BrokenLinkKind {
|
||||
/// Target page doesn't exist in the workspace index.
|
||||
MissingPage(String),
|
||||
/// Page exists but the `#anchor` doesn't match any heading or `:tag:`.
|
||||
MissingAnchor { page: String, anchor: String },
|
||||
/// `file:` / `local:` target whose resolved path isn't on disk.
|
||||
MissingFile(PathBuf),
|
||||
}
|
||||
|
||||
impl BrokenLinkKind {
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
BrokenLinkKind::MissingPage(p) => format!("broken wikilink: page '{p}' not in wiki"),
|
||||
BrokenLinkKind::MissingAnchor { page, anchor } => {
|
||||
format!("broken anchor: '#{anchor}' not found on page '{page}'")
|
||||
}
|
||||
BrokenLinkKind::MissingFile(p) => {
|
||||
format!("broken file link: '{}' not found", p.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Short machine-readable kind, used in the `checkLinks` JSON output so
|
||||
/// editors can colour-code or filter.
|
||||
pub fn tag(&self) -> &'static str {
|
||||
match self {
|
||||
BrokenLinkKind::MissingPage(_) => "missing-page",
|
||||
BrokenLinkKind::MissingAnchor { .. } => "missing-anchor",
|
||||
BrokenLinkKind::MissingFile(_) => "missing-file",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect one wikilink and return `Some(_)` when it's broken. The `uri`
|
||||
/// is the source document's URI — required for `file:` / `local:`
|
||||
/// resolution. `source_page` is the source's name in the index (used by
|
||||
/// `AnchorOnly` links to look up the current page's headings/tags).
|
||||
pub fn classify_link(
|
||||
link: &WikiLinkNode,
|
||||
uri: Option<&Url>,
|
||||
index: &WorkspaceIndex,
|
||||
source_page: &str,
|
||||
) -> Option<BrokenLinkKind> {
|
||||
match link.target.kind {
|
||||
LinkKind::Wiki => {
|
||||
let path = link.target.path.as_deref()?;
|
||||
match index.page_for_wiki_target(path, source_page, link.target.is_absolute) {
|
||||
None => Some(BrokenLinkKind::MissingPage(path.to_string())),
|
||||
Some(page) => {
|
||||
if let Some(anchor) = &link.target.anchor {
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: path.to_string(),
|
||||
anchor: anchor.clone(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LinkKind::AnchorOnly => {
|
||||
let anchor = link.target.anchor.as_deref()?;
|
||||
let page = index.page_by_name(source_page)?;
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: source_page.to_string(),
|
||||
anchor: anchor.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
LinkKind::File | LinkKind::Local => {
|
||||
let path = link.target.path.as_deref()?;
|
||||
let resolved = resolve_file_path(uri, index, path, link.target.is_absolute)?;
|
||||
if resolved.exists() {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingFile(resolved))
|
||||
}
|
||||
}
|
||||
LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_resolves_on(page: &crate::index::IndexedPage, anchor: &str) -> bool {
|
||||
page.find_heading_by_anchor(anchor).is_some() || page.tags.iter().any(|t| t.name == anchor)
|
||||
}
|
||||
|
||||
/// Same classification as [`classify_link`] but driven from the cached
|
||||
/// [`OutgoingLink`] data so workspace-wide checks don't have to re-parse
|
||||
/// every page. The on-disk `File`/`Local` check is performed when the
|
||||
/// source URI is available.
|
||||
pub fn classify_outgoing(
|
||||
link: &OutgoingLink,
|
||||
source_uri: &Url,
|
||||
index: &WorkspaceIndex,
|
||||
source_page: &str,
|
||||
) -> Option<BrokenLinkKind> {
|
||||
match link.kind {
|
||||
LinkKind::Wiki => {
|
||||
let path = link.target_page.as_deref()?;
|
||||
match index.page_for_wiki_target(path, source_page, link.is_absolute) {
|
||||
None => Some(BrokenLinkKind::MissingPage(path.to_string())),
|
||||
Some(page) => {
|
||||
if let Some(anchor) = &link.anchor {
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: path.to_string(),
|
||||
anchor: anchor.clone(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LinkKind::AnchorOnly => {
|
||||
let anchor = link.anchor.as_deref()?;
|
||||
let page = index.page_by_name(source_page)?;
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: source_page.to_string(),
|
||||
anchor: anchor.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
LinkKind::File | LinkKind::Local => {
|
||||
let path = link.target_page.as_deref()?;
|
||||
let resolved = resolve_file_path(Some(source_uri), index, path, link.is_absolute)?;
|
||||
if resolved.exists() {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingFile(resolved))
|
||||
}
|
||||
}
|
||||
LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten heading inlines down to a plain string. Used by `commands::ops`
|
||||
/// to match heading text against the configured TOC/Links section name, and
|
||||
/// (via `nuwiki_core::ast::inline_text`) the same text the HTML renderer uses
|
||||
/// for heading `id`s — so anchors validated here match anchors emitted there.
|
||||
pub fn heading_text(inlines: &[InlineNode]) -> String {
|
||||
nuwiki_core::ast::inline_text(inlines)
|
||||
}
|
||||
|
||||
/// Resolve a `file:` / `local:` link target to an absolute path.
|
||||
///
|
||||
/// - Absolute (parsed `//path` or `is_absolute`): used as-is.
|
||||
/// - Otherwise: joined with the source file's parent directory.
|
||||
/// - If we don't have a URI to anchor relative paths against, falls back
|
||||
/// to the wiki root from the index.
|
||||
fn resolve_file_path(
|
||||
uri: Option<&Url>,
|
||||
index: &WorkspaceIndex,
|
||||
path: &str,
|
||||
is_absolute: bool,
|
||||
) -> Option<PathBuf> {
|
||||
let candidate = PathBuf::from(path);
|
||||
if is_absolute || candidate.is_absolute() {
|
||||
return Some(candidate);
|
||||
}
|
||||
if let Some(uri) = uri {
|
||||
if let Ok(base) = uri.to_file_path() {
|
||||
if let Some(parent) = base.parent() {
|
||||
return Some(parent.join(&candidate));
|
||||
}
|
||||
}
|
||||
}
|
||||
index.root.as_ref().map(|r| r.join(&candidate))
|
||||
}
|
||||
|
||||
/// `nuwiki.link` diagnostics for a single document.
|
||||
pub fn link_health(
|
||||
ast: &DocumentNode,
|
||||
text: &str,
|
||||
uri: Option<&Url>,
|
||||
index: &WorkspaceIndex,
|
||||
source_page: &str,
|
||||
severity: LinkSeverity,
|
||||
utf8: bool,
|
||||
) -> Vec<Diagnostic> {
|
||||
let Some(sev) = severity_to_lsp(severity) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for link in collect_wiki_links(ast) {
|
||||
if let Some(kind) = classify_link(link, uri, index, source_page) {
|
||||
out.push(Diagnostic {
|
||||
range: to_lsp_range(&link.span, text, utf8),
|
||||
severity: Some(sev),
|
||||
source: Some(LINK_SOURCE.into()),
|
||||
message: kind.message(),
|
||||
code: Some(tower_lsp::lsp_types::NumberOrString::String(
|
||||
kind.tag().to_string(),
|
||||
)),
|
||||
..Diagnostic::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Diary subsystem — pure helpers used by the
|
||||
//! `nuwiki.diary.*` `executeCommand` handlers.
|
||||
//!
|
||||
//! All path/URI math lives here so the command dispatcher stays a thin
|
||||
//! wrapper. The diary navigation model is intentionally lightweight:
|
||||
//! entries are identified by file stem (`YYYY-MM-DD`) living under
|
||||
//! `<wiki_root>/<diary_rel_path>/`. The `WorkspaceIndex` tags each
|
||||
//! `IndexedPage` with a `diary_date` so prev/next/list operations stay
|
||||
//! O(n) over the indexed pages.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
use nuwiki_core::date::{DiaryDate, DiaryPeriod};
|
||||
|
||||
use crate::config::WikiConfig;
|
||||
use crate::index::WorkspaceIndex;
|
||||
|
||||
/// Resolve a `DiaryDate` to its `file://` URI under the given wiki.
|
||||
pub fn uri_for_date(cfg: &WikiConfig, date: &DiaryDate) -> Option<Url> {
|
||||
Url::from_file_path(cfg.diary_path_for(date)).ok()
|
||||
}
|
||||
|
||||
/// Resolve a `DiaryPeriod` to its `file://` URI under the given wiki.
|
||||
/// Picks the right stem for the period's flavour (day / week / month / year).
|
||||
pub fn uri_for_period(cfg: &WikiConfig, period: &DiaryPeriod) -> Option<Url> {
|
||||
Url::from_file_path(cfg.diary_path_for_period(period)).ok()
|
||||
}
|
||||
|
||||
/// `file://` URI of the diary index page.
|
||||
pub fn index_uri(cfg: &WikiConfig) -> Option<Url> {
|
||||
Url::from_file_path(cfg.diary_index_path()).ok()
|
||||
}
|
||||
|
||||
/// All diary entries currently indexed for `wiki`, sorted ascending by
|
||||
/// date. The wiki's index must already have its `diary_rel_path` set
|
||||
/// (handled by `Wiki::new`).
|
||||
pub fn list_entries(index: &WorkspaceIndex) -> Vec<DiaryEntry> {
|
||||
let mut out: Vec<DiaryEntry> = index
|
||||
.pages_by_uri
|
||||
.values()
|
||||
.filter_map(|p| {
|
||||
p.diary_date.map(|d| DiaryEntry {
|
||||
date: d,
|
||||
uri: p.uri.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.date.cmp(&b.date));
|
||||
out
|
||||
}
|
||||
|
||||
/// Entries for a given (year, month). `month = None` returns the whole
|
||||
/// year; `year = None` matches every year.
|
||||
pub fn list_entries_filtered(
|
||||
index: &WorkspaceIndex,
|
||||
year: Option<i32>,
|
||||
month: Option<u8>,
|
||||
) -> Vec<DiaryEntry> {
|
||||
list_entries(index)
|
||||
.into_iter()
|
||||
.filter(|e| year.is_none_or(|y| e.date.year == y))
|
||||
.filter(|e| month.is_none_or(|m| e.date.month == m))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The diary entry chronologically after `from`. `from` doesn't need to
|
||||
/// be indexed itself — we look for the smallest indexed date strictly
|
||||
/// greater than `from`.
|
||||
pub fn next_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option<DiaryEntry> {
|
||||
list_entries(index).into_iter().find(|e| e.date > *from)
|
||||
}
|
||||
|
||||
/// The diary entry chronologically before `from`.
|
||||
pub fn prev_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option<DiaryEntry> {
|
||||
list_entries(index)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|e| e.date < *from)
|
||||
}
|
||||
|
||||
/// All indexed diary periods of a *specific* flavour (daily / weekly /
|
||||
/// monthly / yearly), sorted ascending. Used by next/prev navigation so
|
||||
/// `<C-Down>` on a weekly page jumps to the next weekly entry, not the
|
||||
/// next daily one.
|
||||
pub fn list_periods_of_kind(index: &WorkspaceIndex, kind: PeriodKind) -> Vec<PeriodEntry> {
|
||||
let mut out: Vec<PeriodEntry> = index
|
||||
.pages_by_uri
|
||||
.values()
|
||||
.filter_map(|p| {
|
||||
let period = p.diary_period?;
|
||||
if PeriodKind::of(&period) != kind {
|
||||
return None;
|
||||
}
|
||||
Some(PeriodEntry {
|
||||
period,
|
||||
uri: p.uri.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|e| period_first_day(&e.period));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn next_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
|
||||
let kind = PeriodKind::of(from);
|
||||
let pivot = period_first_day(from);
|
||||
list_periods_of_kind(index, kind)
|
||||
.into_iter()
|
||||
.find(|e| period_first_day(&e.period) > pivot)
|
||||
}
|
||||
|
||||
pub fn prev_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
|
||||
let kind = PeriodKind::of(from);
|
||||
let pivot = period_first_day(from);
|
||||
list_periods_of_kind(index, kind)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|e| period_first_day(&e.period) < pivot)
|
||||
}
|
||||
|
||||
/// Cluster 4: `(period, uri)` pair for non-daily diary navigation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeriodEntry {
|
||||
pub period: DiaryPeriod,
|
||||
pub uri: Url,
|
||||
}
|
||||
|
||||
impl serde::Serialize for PeriodEntry {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
// For back-compat with daily-only clients, emit `date` AND
|
||||
// `period` — the former is just the first-day string of the
|
||||
// period, the latter is the canonical stem.
|
||||
let mut s = serializer.serialize_struct("PeriodEntry", 3)?;
|
||||
s.serialize_field("date", &self.period.first_day().format())?;
|
||||
s.serialize_field("period", &self.period.format())?;
|
||||
s.serialize_field("uri", &self.uri)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which flavour of `DiaryPeriod` we're looking at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PeriodKind {
|
||||
Day,
|
||||
Week,
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
|
||||
impl PeriodKind {
|
||||
pub fn of(p: &DiaryPeriod) -> Self {
|
||||
match p {
|
||||
DiaryPeriod::Day(_) => Self::Day,
|
||||
DiaryPeriod::Week { .. } => Self::Week,
|
||||
DiaryPeriod::Month { .. } => Self::Month,
|
||||
DiaryPeriod::Year { .. } => Self::Year,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn period_first_day(p: &DiaryPeriod) -> DiaryDate {
|
||||
p.first_day()
|
||||
}
|
||||
|
||||
/// Ordering of entries in the diary index page.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiarySort {
|
||||
/// Newest first (vimwiki default).
|
||||
Desc,
|
||||
/// Oldest first.
|
||||
Asc,
|
||||
}
|
||||
|
||||
impl DiarySort {
|
||||
/// Parse a wiki's `diary_sort` field. Anything other than `asc`
|
||||
/// (case-insensitive) is treated as the default `desc`.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
if s.trim().eq_ignore_ascii_case("asc") {
|
||||
Self::Asc
|
||||
} else {
|
||||
Self::Desc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a heading line at `level` (1 = `= … =`). Clamped to vimwiki's
|
||||
/// 1..=6 range so a stray config value can't emit a 200-equals heading.
|
||||
fn heading(level: u8, text: &str) -> String {
|
||||
let n = level.clamp(1, 6) as usize;
|
||||
let eq = "=".repeat(n);
|
||||
format!("{eq} {text} {eq}")
|
||||
}
|
||||
|
||||
/// Generate the body of the diary index page — a flat list of
|
||||
/// `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month
|
||||
/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`.
|
||||
///
|
||||
/// `caption_level` sets the level of the top caption; the year and month
|
||||
/// subheadings nest one and two levels below it. A `caption_level < 0`
|
||||
/// clamps to `0` (nuwiki builds the tree from dates, so upstream's `-1`
|
||||
/// "no per-page captions" mode doesn't apply). `months` supplies the
|
||||
/// month-number → display name table; an empty slice (or a short list)
|
||||
/// falls back to the English `month_name` for any missing slot. `sort`
|
||||
/// controls whether the flat list runs newest- or oldest-first. `margin`
|
||||
/// is the number of leading spaces before each entry bullet (vimwiki's
|
||||
/// `list_margin`).
|
||||
pub fn build_index_body(
|
||||
entries: &[DiaryEntry],
|
||||
diary_rel_path: &str,
|
||||
index_heading: &str,
|
||||
sort: DiarySort,
|
||||
caption_level: i8,
|
||||
months: &[String],
|
||||
margin: usize,
|
||||
) -> String {
|
||||
let base_level = caption_level.max(0) as u8;
|
||||
let pad = " ".repeat(margin);
|
||||
let mut out = String::new();
|
||||
out.push_str(&heading(base_level, index_heading));
|
||||
out.push('\n');
|
||||
|
||||
if entries.is_empty() {
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<&DiaryEntry> = entries.iter().collect();
|
||||
sorted.sort_by(|a, b| match sort {
|
||||
DiarySort::Desc => b.date.cmp(&a.date),
|
||||
DiarySort::Asc => a.date.cmp(&b.date),
|
||||
});
|
||||
|
||||
let year_level = base_level.saturating_add(1);
|
||||
let month_level = base_level.saturating_add(2);
|
||||
let mut current_year: Option<i32> = None;
|
||||
let mut current_month: Option<u8> = None;
|
||||
for e in sorted {
|
||||
if current_year != Some(e.date.year) {
|
||||
out.push('\n');
|
||||
out.push_str(&heading(year_level, &e.date.year.to_string()));
|
||||
out.push('\n');
|
||||
current_year = Some(e.date.year);
|
||||
current_month = None;
|
||||
}
|
||||
if current_month != Some(e.date.month) {
|
||||
let label = months
|
||||
.get((e.date.month.saturating_sub(1)) as usize)
|
||||
.map(String::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| month_name(e.date.month));
|
||||
out.push_str(&heading(month_level, label));
|
||||
out.push('\n');
|
||||
current_month = Some(e.date.month);
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"{pad}- [[{}/{}]]\n",
|
||||
diary_rel_path.trim_end_matches('/'),
|
||||
e.date.format()
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the diary-index body for the given wiki's currently-indexed
|
||||
/// entries. Convenience wrapper around [`build_index_body`] that honours
|
||||
/// the wiki's `diary_header`, `diary_sort`, `diary_caption_level`, and
|
||||
/// `diary_months`.
|
||||
pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String {
|
||||
build_index_body(
|
||||
&list_entries(index),
|
||||
&cfg.diary_rel_path,
|
||||
&cfg.diary_header,
|
||||
DiarySort::parse(&cfg.diary_sort),
|
||||
cfg.diary_caption_level,
|
||||
&cfg.diary_months,
|
||||
cfg.list_margin.max(0) as usize,
|
||||
)
|
||||
}
|
||||
|
||||
/// Cheap "is this path under the diary subdir of this wiki" check —
|
||||
/// surface-level, no parse needed.
|
||||
pub fn is_in_diary(cfg: &WikiConfig, path: &Path) -> bool {
|
||||
path.starts_with(cfg.diary_dir())
|
||||
}
|
||||
|
||||
/// One diary entry, returned by listing/navigation queries. The custom
|
||||
/// `Serialize` emits `{ "date": "YYYY-MM-DD", "uri": "file://..." }` so
|
||||
/// the client doesn't have to know about [`DiaryDate`]'s internal layout.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiaryEntry {
|
||||
pub date: DiaryDate,
|
||||
pub uri: Url,
|
||||
}
|
||||
|
||||
impl serde::Serialize for DiaryEntry {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut s = serializer.serialize_struct("DiaryEntry", 2)?;
|
||||
s.serialize_field("date", &self.date.format())?;
|
||||
s.serialize_field("uri", &self.uri)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
fn month_name(m: u8) -> &'static str {
|
||||
match m {
|
||||
1 => "January",
|
||||
2 => "February",
|
||||
3 => "March",
|
||||
4 => "April",
|
||||
5 => "May",
|
||||
6 => "June",
|
||||
7 => "July",
|
||||
8 => "August",
|
||||
9 => "September",
|
||||
10 => "October",
|
||||
11 => "November",
|
||||
12 => "December",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Helpers for building `WorkspaceEdit`s.
|
||||
//!
|
||||
//! Rename, list/table edits, and link/TOC generation all return
|
||||
//! `WorkspaceEdit`. The builder centralises the byte-offset → LSP-position
|
||||
//! conversion (UTF-8 or UTF-16, matching whatever was negotiated in
|
||||
//! `initialize`) so each call site doesn't have to redo it.
|
||||
//!
|
||||
//! The builder emits `documentChanges` (with `Operation` for file ops)
|
||||
//! whenever any file op is queued, and falls back to the simpler
|
||||
//! `changes` field for pure text-edit batches — the latter is what older
|
||||
//! LSP 3.15- clients understand.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tower_lsp::lsp_types::{
|
||||
CreateFile, DeleteFile, DocumentChangeOperation, DocumentChanges, OneOf,
|
||||
OptionalVersionedTextDocumentIdentifier, Position, Range, RenameFile, ResourceOp,
|
||||
TextDocumentEdit, TextEdit, Url, WorkspaceEdit,
|
||||
};
|
||||
|
||||
use nuwiki_core::ast::Span;
|
||||
|
||||
use crate::semantic_tokens::LineIndex;
|
||||
|
||||
/// Replace the source covered by `span` with `replacement`. `text` and
|
||||
/// `utf8` are needed to translate the byte-offset span into the LSP
|
||||
/// `Range` the client expects.
|
||||
pub fn text_edit_replace(span: Span, replacement: String, text: &str, utf8: bool) -> TextEdit {
|
||||
TextEdit {
|
||||
range: span_to_range(span, text, utf8),
|
||||
new_text: replacement,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `content` at `pos`. The caller has already converted to LSP
|
||||
/// coordinates — no further conversion is performed here.
|
||||
pub fn text_edit_insert(pos: Position, content: String) -> TextEdit {
|
||||
TextEdit {
|
||||
range: Range {
|
||||
start: pos,
|
||||
end: pos,
|
||||
},
|
||||
new_text: content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the source covered by `span`.
|
||||
pub fn text_edit_delete(span: Span, text: &str, utf8: bool) -> TextEdit {
|
||||
text_edit_replace(span, String::new(), text, utf8)
|
||||
}
|
||||
|
||||
pub fn op_rename(old: Url, new: Url) -> DocumentChangeOperation {
|
||||
DocumentChangeOperation::Op(ResourceOp::Rename(RenameFile {
|
||||
old_uri: old,
|
||||
new_uri: new,
|
||||
options: None,
|
||||
annotation_id: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn op_delete(uri: Url) -> DocumentChangeOperation {
|
||||
// lsp-types 0.94.x ships `DeleteFile` without `annotation_id`; later
|
||||
// versions added it. Stick with the 0.94 shape until we move tower-lsp.
|
||||
DocumentChangeOperation::Op(ResourceOp::Delete(DeleteFile { uri, options: None }))
|
||||
}
|
||||
|
||||
pub fn op_create(uri: Url) -> DocumentChangeOperation {
|
||||
DocumentChangeOperation::Op(ResourceOp::Create(CreateFile {
|
||||
uri,
|
||||
options: None,
|
||||
annotation_id: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WorkspaceEditBuilder {
|
||||
changes: HashMap<Url, Vec<TextEdit>>,
|
||||
ops: Vec<DocumentChangeOperation>,
|
||||
}
|
||||
|
||||
impl WorkspaceEditBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn edit(&mut self, uri: Url, edit: TextEdit) -> &mut Self {
|
||||
self.changes.entry(uri).or_default().push(edit);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self {
|
||||
self.ops.push(op);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changes.is_empty() && self.ops.is_empty()
|
||||
}
|
||||
|
||||
pub fn build(self) -> WorkspaceEdit {
|
||||
// documentChanges is required whenever there are file ops (LSP
|
||||
// doesn't represent renames/deletes/creates in the flat `changes`
|
||||
// map). For pure text edits the older `changes` form keeps
|
||||
// compatibility with LSP 3.15-clients.
|
||||
if self.ops.is_empty() {
|
||||
return WorkspaceEdit {
|
||||
changes: Some(self.changes),
|
||||
document_changes: None,
|
||||
change_annotations: None,
|
||||
};
|
||||
}
|
||||
|
||||
let mut document_changes: Vec<DocumentChangeOperation> = self.ops;
|
||||
// Edits land after file ops so the file exists when the text edit
|
||||
// applies (e.g. create + initial content).
|
||||
for (uri, edits) in self.changes {
|
||||
document_changes.push(DocumentChangeOperation::Edit(TextDocumentEdit {
|
||||
text_document: OptionalVersionedTextDocumentIdentifier { uri, version: None },
|
||||
edits: edits.into_iter().map(OneOf::Left).collect(),
|
||||
}));
|
||||
}
|
||||
WorkspaceEdit {
|
||||
changes: None,
|
||||
document_changes: Some(DocumentChanges::Operations(document_changes)),
|
||||
change_annotations: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn span_to_range(span: Span, text: &str, utf8: bool) -> Range {
|
||||
let idx = LineIndex::new(text);
|
||||
Range {
|
||||
start: Position {
|
||||
line: span.start.line,
|
||||
character: char_at(span.start.line, span.start.column, text, &idx, utf8),
|
||||
},
|
||||
end: Position {
|
||||
line: span.end.line,
|
||||
character: char_at(span.end.line, span.end.column, text, &idx, utf8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn char_at(line: u32, byte_col: u32, text: &str, idx: &LineIndex, utf8: bool) -> u32 {
|
||||
if utf8 {
|
||||
return byte_col;
|
||||
}
|
||||
let line_str = idx.line_str(line, text);
|
||||
let upto = (byte_col as usize).min(line_str.len());
|
||||
line_str[..upto].chars().map(char::len_utf16).sum::<usize>() as u32
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! HTML export — pure helpers shared by the `nuwiki.export.*`
|
||||
//! command handlers. Side-effecting work (reading the template from
|
||||
//! disk, writing the rendered HTML, copying CSS) lives in `commands.rs`
|
||||
//! since the dispatcher is the one with a `&Backend` and async context.
|
||||
//!
|
||||
//! Design split:
|
||||
//! - [`output_path_for`] / [`output_url_for`] — name → on-disk path.
|
||||
//! - [`template_for`] — locate the right template file on disk.
|
||||
//! - [`fallback_template`] — minimal stand-in when nothing is found.
|
||||
//! - [`format_date`] — strftime subset (`%Y`/`%m`/`%d`).
|
||||
//! - [`build_toc_html`] — TOC rendered into the `{{toc}}` variable.
|
||||
//! - [`compute_root_path`] — relative path from a page's output to
|
||||
//! `html_path` for stylesheet hrefs in nested pages.
|
||||
//! - [`is_excluded`] — glob match against `exclude_files`.
|
||||
//! - [`render_page_html`] — drives [`HtmlRenderer`] with all of the
|
||||
//! above wired up; returns the final HTML string.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, LinkKind};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
|
||||
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
|
||||
/// [`HtmlConfig::html_filename_parameterization`] for URL-safe slugs
|
||||
/// — when enabled, only the final path segment is slugified (so the
|
||||
/// `diary/` subdir survives).
|
||||
pub fn output_path_for(cfg: &HtmlConfig, name: &str) -> PathBuf {
|
||||
let segments: Vec<String> = name
|
||||
.split('/')
|
||||
.map(|s| {
|
||||
if cfg.html_filename_parameterization {
|
||||
slugify(s)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut p = cfg.html_path.clone();
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
if i + 1 == segments.len() {
|
||||
p.push(format!("{seg}.html"));
|
||||
} else {
|
||||
p.push(seg);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Locate the template body for a document. Resolution order:
|
||||
/// 1. `<template_path>/<doc.metadata.template>.<template_ext>` if set.
|
||||
/// 2. `<template_path>/<template_default>.<template_ext>`.
|
||||
/// 3. [`fallback_template`].
|
||||
///
|
||||
/// The on-disk read is performed by the caller (`commands.rs`) via the
|
||||
/// [`TemplateSource`] returned here. Keeps this fn pure for tests.
|
||||
pub fn template_for(cfg: &HtmlConfig, doc: &DocumentNode) -> TemplateSource {
|
||||
let primary = doc.metadata.template.as_deref().map(|name| {
|
||||
cfg.template_path
|
||||
.join(format!("{name}{}", cfg.template_ext))
|
||||
});
|
||||
let fallback = cfg
|
||||
.template_path
|
||||
.join(format!("{}{}", cfg.template_default, cfg.template_ext));
|
||||
TemplateSource { primary, fallback }
|
||||
}
|
||||
|
||||
/// Two-tier lookup result. Caller tries `primary` first, then `fallback`,
|
||||
/// then falls back to [`fallback_template`] when neither file exists.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TemplateSource {
|
||||
pub primary: Option<PathBuf>,
|
||||
pub fallback: PathBuf,
|
||||
}
|
||||
|
||||
/// Minimal valid HTML page used when no template file is found on disk.
|
||||
/// Includes the same `{{title}}` / `{{content}}` placeholders as a real
|
||||
/// template so the renderer's substitution still works.
|
||||
pub fn fallback_template(css_name: &str) -> String {
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{{{title}}}}</title>
|
||||
<link rel="stylesheet" href="{{{{root_path}}}}{css_name}">
|
||||
</head>
|
||||
<body>
|
||||
{{{{content}}}}
|
||||
</body>
|
||||
</html>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Tiny strftime: supports `%Y` (4-digit year), `%m` (2-digit month),
|
||||
/// `%d` (2-digit day), `%%` (literal percent). Anything else passes
|
||||
/// through. The diary use case never produces time-of-day output, so
|
||||
/// `%H`/`%M`/`%S` are intentionally omitted.
|
||||
pub fn format_date(date: &DiaryDate, fmt: &str) -> String {
|
||||
let mut out = String::with_capacity(fmt.len() + 4);
|
||||
let bytes = fmt.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 1 < bytes.len() {
|
||||
match bytes[i + 1] {
|
||||
b'Y' => out.push_str(&format!("{:04}", date.year)),
|
||||
b'm' => out.push_str(&format!("{:02}", date.month)),
|
||||
b'd' => out.push_str(&format!("{:02}", date.day)),
|
||||
b'%' => out.push('%'),
|
||||
other => {
|
||||
out.push('%');
|
||||
out.push(other as char);
|
||||
}
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the page's headings as a nested `<ul>` for the `{{toc}}`
|
||||
/// template variable. Returns an empty string when the document has
|
||||
/// no headings.
|
||||
pub fn build_toc_html(doc: &DocumentNode) -> String {
|
||||
let headings = collect_headings(doc);
|
||||
if headings.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let min_level = headings.iter().map(|h| h.level).min().unwrap_or(1);
|
||||
let mut out = String::from("<ul class=\"toc\">");
|
||||
let mut depth = 0usize;
|
||||
for h in &headings {
|
||||
let target = h.level.saturating_sub(min_level) as usize;
|
||||
while depth < target {
|
||||
out.push_str("<ul>");
|
||||
depth += 1;
|
||||
}
|
||||
while depth > target {
|
||||
out.push_str("</ul>");
|
||||
depth -= 1;
|
||||
}
|
||||
// Anchor is the raw heading text (vimwiki scheme), HTML-escaped — must
|
||||
// match the `id` the renderer puts on the heading element.
|
||||
let anchor = escape_html(&h.title);
|
||||
out.push_str(&format!("<li><a href=\"#{anchor}\">{anchor}</a></li>"));
|
||||
}
|
||||
while depth > 0 {
|
||||
out.push_str("</ul>");
|
||||
depth -= 1;
|
||||
}
|
||||
out.push_str("</ul>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the relative path back to `html_path` from the directory
|
||||
/// holding the output file for `name`. Used as the `{{root_path}}`
|
||||
/// prefix so a nested page's `<link>`/`<script>` hrefs still find
|
||||
/// the shared CSS at the export root.
|
||||
pub fn compute_root_path(name: &str) -> String {
|
||||
let depth = name.matches('/').count();
|
||||
if depth == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
let mut s = String::with_capacity(depth * 3);
|
||||
for _ in 0..depth {
|
||||
s.push_str("../");
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `name` matches any of the `exclude_files` glob patterns.
|
||||
/// Supported wildcards: `*` (any segment chars except `/`), `**` (any
|
||||
/// segments including `/`), `?` (one char). Anything else is literal.
|
||||
pub fn is_excluded(patterns: &[String], name: &str) -> bool {
|
||||
patterns.iter().any(|p| glob_match(p, name))
|
||||
}
|
||||
|
||||
/// Pure HTML render pass. The caller has already loaded the template
|
||||
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
|
||||
/// plus arbitrary extras passed via `extra_vars`.
|
||||
///
|
||||
/// `wiki_extension` is the wiki's source file extension (e.g. `".wiki"`).
|
||||
/// When set, it is stripped from wiki/interwiki link targets so a link
|
||||
/// written with the extension (`[[todo.wiki]]`) exports to `todo.html`,
|
||||
/// matching how the LSP resolves the same link for in-editor navigation.
|
||||
// Every parameter is a distinct, cohesive render input; bundling them into a
|
||||
// struct would add an abstraction without simplifying the single caller.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_page_html(
|
||||
doc: &DocumentNode,
|
||||
template: Option<String>,
|
||||
name: &str,
|
||||
today: DiaryDate,
|
||||
cfg: &HtmlConfig,
|
||||
wiki_extension: Option<&str>,
|
||||
extra_vars: HashMap<String, String>,
|
||||
) -> std::io::Result<String> {
|
||||
let date_str = match doc.metadata.date.as_deref() {
|
||||
Some(s) => match DiaryDate::parse(s) {
|
||||
Some(d) => format_date(&d, &cfg.template_date_format),
|
||||
None => s.to_string(),
|
||||
},
|
||||
None => format_date(&today, &cfg.template_date_format),
|
||||
};
|
||||
let root_path = compute_root_path(name);
|
||||
let toc = build_toc_html(doc);
|
||||
|
||||
let mut vars: HashMap<String, String> = HashMap::new();
|
||||
vars.insert("date".into(), date_str);
|
||||
vars.insert("root_path".into(), root_path);
|
||||
vars.insert("toc".into(), toc);
|
||||
vars.insert("css".into(), cfg.css_name.clone());
|
||||
// vimwiki names the stylesheet placeholder `%wiki_css%`; alias it so stock
|
||||
// templates resolve. nuwiki's own templates use `{{css}}`.
|
||||
vars.insert("wiki_css".into(), cfg.css_name.clone());
|
||||
for (k, v) in extra_vars {
|
||||
vars.insert(k, v);
|
||||
}
|
||||
|
||||
let mut r = HtmlRenderer::new();
|
||||
if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
|
||||
// Strip the wiki extension from page links before the default resolver
|
||||
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
|
||||
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
|
||||
// file:/local: paths keep their literal extension.
|
||||
let ext = ext.to_string();
|
||||
r = r.with_link_resolver(move |target| {
|
||||
let mut t = target.clone();
|
||||
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
|
||||
if let Some(p) = t.path.take() {
|
||||
t.path = Some(crate::index::strip_wiki_extension(&p, Some(&ext)).to_string());
|
||||
}
|
||||
}
|
||||
nuwiki_core::render::html::default_link_resolver(&t)
|
||||
});
|
||||
}
|
||||
if let Some(t) = template {
|
||||
r = r.with_template(t);
|
||||
}
|
||||
r = r.with_vars(vars);
|
||||
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,
|
||||
cfg.html_header_numbering_sym.clone(),
|
||||
);
|
||||
}
|
||||
r = r.with_toc_header(&cfg.toc_header);
|
||||
if !cfg.valid_html_tags.is_empty() {
|
||||
r = r.with_valid_html_tags(cfg.valid_html_tags.clone());
|
||||
}
|
||||
r = r.with_emoji(cfg.emoji_enable);
|
||||
r = r.with_newline_handling(cfg.text_ignore_newline, cfg.list_ignore_newline);
|
||||
r.render_to_string(doc)
|
||||
}
|
||||
|
||||
// ===== Internals =====
|
||||
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_alphanumeric() {
|
||||
for c in ch.to_lowercase() {
|
||||
out.push(c);
|
||||
}
|
||||
prev_dash = false;
|
||||
} else if !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
while out.ends_with('-') {
|
||||
out.pop();
|
||||
}
|
||||
if out.is_empty() {
|
||||
"untitled".into()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_headings(doc: &DocumentNode) -> Vec<HeadingProj> {
|
||||
let mut out = Vec::new();
|
||||
for b in &doc.children {
|
||||
if let BlockNode::Heading(h) = b {
|
||||
out.push(HeadingProj {
|
||||
level: h.level,
|
||||
title: nuwiki_core::ast::inline_text(&h.children),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct HeadingProj {
|
||||
level: u8,
|
||||
title: String,
|
||||
}
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
|
||||
/// literal characters. No character classes — those aren't needed for
|
||||
/// "exclude foo/*.tmp" style patterns.
|
||||
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
glob_impl(pattern.as_bytes(), text.as_bytes())
|
||||
}
|
||||
|
||||
fn glob_impl(p: &[u8], t: &[u8]) -> bool {
|
||||
let mut pi = 0;
|
||||
let mut ti = 0;
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
let mut globstar = false;
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && pi + 1 < p.len() && p[pi] == b'*' && p[pi + 1] == b'*' {
|
||||
globstar = true;
|
||||
star = Some((pi, ti));
|
||||
pi += 2;
|
||||
// Allow optional `/` after globstar.
|
||||
if pi < p.len() && p[pi] == b'/' {
|
||||
pi += 1;
|
||||
}
|
||||
continue;
|
||||
} else if pi < p.len() && p[pi] == b'*' {
|
||||
star = Some((pi, ti));
|
||||
pi += 1;
|
||||
globstar = false;
|
||||
continue;
|
||||
} else if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
continue;
|
||||
} else if let Some((s_pi, s_ti)) = star {
|
||||
if !globstar && t[s_ti] == b'/' && t[ti] != b'/' {
|
||||
// single-star can't cross a `/`
|
||||
return false;
|
||||
}
|
||||
pi = if globstar { s_pi + 2 } else { s_pi + 1 };
|
||||
ti = s_ti + 1;
|
||||
star = Some((s_pi, ti));
|
||||
continue;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while pi < p.len() && (p[pi] == b'*' || (p[pi] == b'/' && globstar)) {
|
||||
pi += 1;
|
||||
}
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
/// `<html_path>/<css_name>` — the absolute path of the CSS file the
|
||||
/// renderer references via `{{root_path}}{{css}}`.
|
||||
pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
|
||||
cfg.html_path.join(&cfg.css_name)
|
||||
}
|
||||
|
||||
/// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
|
||||
/// file exists yet at [`css_path`]. Deliberately tiny — users are
|
||||
/// expected to replace it with their own.
|
||||
pub const DEFAULT_CSS: &str =
|
||||
"body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
|
||||
h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
|
||||
pre{background:#f4f4f4;padding:.5em;overflow:auto}\
|
||||
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";
|
||||
@@ -0,0 +1,88 @@
|
||||
//! `textDocument/foldingRange` provider.
|
||||
//!
|
||||
//! Strategy: one fold per heading (line → line before the next heading
|
||||
//! of same-or-higher level) plus one fold per top-level list block.
|
||||
//! Folding is deliberately conservative — we never produce nested folds
|
||||
//! beyond what the AST already groups, since vimwiki users mostly want
|
||||
//! "collapse this section" behaviour rather than fine-grained code-fold
|
||||
//! semantics.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode};
|
||||
use tower_lsp::lsp_types::{FoldingRange, FoldingRangeKind};
|
||||
|
||||
/// Compute folding ranges for the whole document. `total_lines` is the
|
||||
/// number of lines in the source (`text.lines().count() as u32`) — used
|
||||
/// to extend the last heading's fold to end-of-document.
|
||||
pub fn folding_ranges(ast: &DocumentNode, total_lines: u32) -> Vec<FoldingRange> {
|
||||
let mut out = Vec::new();
|
||||
let last_line = total_lines.saturating_sub(1);
|
||||
|
||||
// Collect heading spans + levels in source order. We walk the
|
||||
// top-level children only — nested headings inside blockquotes are
|
||||
// rare and folding them adds complexity for little gain.
|
||||
let mut headings: Vec<(u32, u8)> = Vec::new();
|
||||
for block in &ast.children {
|
||||
if let BlockNode::Heading(h) = block {
|
||||
headings.push((h.span.start.line, h.level));
|
||||
}
|
||||
}
|
||||
// For each heading, end-line is one before the next heading whose
|
||||
// level is <= current level (or last_line otherwise).
|
||||
for (i, &(line, level)) in headings.iter().enumerate() {
|
||||
let end = headings[i + 1..]
|
||||
.iter()
|
||||
.find(|(_, l)| *l <= level)
|
||||
.map(|(next_line, _)| next_line.saturating_sub(1))
|
||||
.unwrap_or(last_line);
|
||||
if end > line {
|
||||
out.push(FoldingRange {
|
||||
start_line: line,
|
||||
end_line: end,
|
||||
start_character: None,
|
||||
end_character: None,
|
||||
kind: Some(FoldingRangeKind::Region),
|
||||
collapsed_text: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fold each top-level list. Sublists fold implicitly because their
|
||||
// parent item's source span already covers them.
|
||||
for block in &ast.children {
|
||||
if let BlockNode::List(l) = block {
|
||||
let start = l.span.start.line;
|
||||
let end = l.span.end.line;
|
||||
if end > start {
|
||||
out.push(FoldingRange {
|
||||
start_line: start,
|
||||
end_line: end,
|
||||
start_character: None,
|
||||
end_character: None,
|
||||
kind: Some(FoldingRangeKind::Region),
|
||||
collapsed_text: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Count lines in `text` (always ≥ 1 for non-empty input).
|
||||
pub fn line_count(text: &str) -> u32 {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
let mut n = text.bytes().filter(|b| *b == b'\n').count() as u32;
|
||||
if !text.ends_with('\n') {
|
||||
n += 1;
|
||||
} else {
|
||||
// trailing newline still bounds a virtual line; LSP positions
|
||||
// index 0-based, so the file ends at line `n` (the empty one
|
||||
// after the final newline). We treat "lines" as the count
|
||||
// *including* that trailing empty line for fold-end clamping.
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
//! Workspace index — the in-memory model of every `.wiki` page nuwiki
|
||||
//! has seen, used to power nav features.
|
||||
//!
|
||||
//! The initial scan runs as a background tokio task so
|
||||
//! the server stays responsive on startup. Per-document updates land here
|
||||
//! synchronously via `upsert` whenever the LSP backend re-parses on
|
||||
//! `didOpen` / `didChange`.
|
||||
//!
|
||||
//! The index keeps only the projections nav needs (headings + outgoing
|
||||
//! links) — the full AST already lives in the backend's `documents`
|
||||
//! `DashMap`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span,
|
||||
TableNode, TagScope,
|
||||
};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexedPage {
|
||||
pub uri: Url,
|
||||
/// Page name relative to the workspace root, without the `.wiki`
|
||||
/// extension. Falls back to the URL's last path segment if no root.
|
||||
pub name: String,
|
||||
pub title: Option<String>,
|
||||
pub headings: Vec<HeadingInfo>,
|
||||
pub outgoing: Vec<OutgoingLink>,
|
||||
/// Every `TagNode` on the page. `tags_by_name` on the
|
||||
/// containing `WorkspaceIndex` is the reverse map.
|
||||
pub tags: Vec<TagInfo>,
|
||||
/// `Some(date)` when this page is a *daily* diary entry —
|
||||
/// stem parses as `YYYY-MM-DD`. Equivalent to
|
||||
/// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`.
|
||||
/// Kept as its own field for back-compat with prev/next/list callers
|
||||
/// that pre-date the multi-frequency upgrade.
|
||||
pub diary_date: Option<DiaryDate>,
|
||||
/// Cluster 4 (vimwiki parity): `Some(period)` for any diary entry
|
||||
/// (daily, weekly, monthly, yearly). The stem must match the
|
||||
/// canonical format for one of the four frequencies.
|
||||
pub diary_period: Option<nuwiki_core::date::DiaryPeriod>,
|
||||
}
|
||||
|
||||
/// One tag occurrence on a page. `name` is the bare tag string (no
|
||||
/// surrounding colons). Multiple tags on the same source line each get
|
||||
/// their own `TagInfo` so anchor lookup can return a precise location.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagInfo {
|
||||
pub name: String,
|
||||
pub scope: TagScope,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeadingInfo {
|
||||
pub title: String,
|
||||
pub level: u8,
|
||||
pub anchor: String,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
impl IndexedPage {
|
||||
/// Find a heading whose anchor matches `request`. The on-disk wikilink
|
||||
/// syntax lets users write either the slug (`[[Page#some-heading]]`) or
|
||||
/// the raw heading text (`[[Page#Some Heading]]`). `slugify` is
|
||||
/// idempotent on valid slug input, so applying it to the request
|
||||
/// before comparison handles both forms in a single pass.
|
||||
pub fn find_heading_by_anchor(&self, request: &str) -> Option<&HeadingInfo> {
|
||||
let needle = slugify(request);
|
||||
self.headings.iter().find(|h| h.anchor == needle)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutgoingLink {
|
||||
/// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds
|
||||
/// that don't address another page in the workspace (`AnchorOnly`,
|
||||
/// `Raw`, `File`, `Local`, `Diary`, …).
|
||||
pub target_page: Option<String>,
|
||||
pub anchor: Option<String>,
|
||||
pub kind: LinkKind,
|
||||
pub span: Span,
|
||||
/// Mirrors `LinkTarget::is_absolute` so `classify_outgoing` and the
|
||||
/// `links` query can distinguish `[[Page]]` (source-relative) from
|
||||
/// `[[/Page]]` (root-relative) without re-parsing.
|
||||
pub is_absolute: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Backlink {
|
||||
pub source_uri: Url,
|
||||
pub source_span: Span,
|
||||
pub target_anchor: Option<String>,
|
||||
}
|
||||
|
||||
/// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page
|
||||
/// `[[Page#tag]]` resolution and `nuwiki.tags.search`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagOccurrence {
|
||||
pub uri: Url,
|
||||
pub span: Span,
|
||||
pub scope: TagScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WorkspaceIndex {
|
||||
pub root: Option<PathBuf>,
|
||||
/// Subdir relative to `root` that holds diary entries.
|
||||
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
|
||||
/// classify each indexed URI without a back-reference to the config.
|
||||
pub diary_rel_path: Option<String>,
|
||||
/// File extension for this wiki's pages (e.g. `".wiki"` or `"wiki"`).
|
||||
/// Used to normalise link targets that include the extension so that
|
||||
/// `[[page.wiki]]` resolves the same way as `[[page]]`.
|
||||
pub file_extension: Option<String>,
|
||||
pub pages_by_uri: HashMap<Url, IndexedPage>,
|
||||
pub pages_by_name: HashMap<String, Url>,
|
||||
pub backlinks: HashMap<String, Vec<Backlink>>,
|
||||
/// Tag name → every occurrence across the workspace. Used by
|
||||
/// 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 {
|
||||
pub fn new(root: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
root,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_diary_rel_path(mut self, rel: Option<String>) -> Self {
|
||||
self.diary_rel_path = rel;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_file_extension(mut self, ext: Option<String>) -> Self {
|
||||
self.file_extension = ext;
|
||||
self
|
||||
}
|
||||
|
||||
/// Strip the wiki's configured file extension from a link target path.
|
||||
/// Handles both `"page.wiki"` → `"page"` and `"subdir/page.wiki"` →
|
||||
/// `"subdir/page"`. Returns `path` unchanged when the extension doesn't
|
||||
/// match or no extension is configured.
|
||||
pub fn strip_link_extension<'a>(&self, path: &'a str) -> &'a str {
|
||||
strip_wiki_extension(path, self.file_extension.as_deref())
|
||||
}
|
||||
|
||||
/// Insert or update an indexed page. Removes any prior indexing for
|
||||
/// the same URI first (handles renames and re-parses without leaking
|
||||
/// stale backlinks).
|
||||
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
|
||||
self.remove(&uri);
|
||||
let name = page_name_from_uri(&uri, self.root.as_deref());
|
||||
let diary_period =
|
||||
diary_period_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref());
|
||||
let diary_date = diary_period.and_then(|p| match p {
|
||||
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
|
||||
_ => None,
|
||||
});
|
||||
let mut page = IndexedPage {
|
||||
uri: uri.clone(),
|
||||
name: name.clone(),
|
||||
title: ast.metadata.title.clone(),
|
||||
headings: Vec::new(),
|
||||
outgoing: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
diary_date,
|
||||
diary_period,
|
||||
};
|
||||
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.clone())
|
||||
.or_default()
|
||||
.push(Backlink {
|
||||
source_uri: uri.clone(),
|
||||
source_span: link.span,
|
||||
target_anchor: link.anchor.clone(),
|
||||
});
|
||||
contrib.backlink_keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain the reverse tag map alongside the per-page list.
|
||||
for tag in &page.tags {
|
||||
self.tags_by_name
|
||||
.entry(tag.name.clone())
|
||||
.or_default()
|
||||
.push(TagOccurrence {
|
||||
uri: uri.clone(),
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, uri: &Url) {
|
||||
if let Some(page) = self.pages_by_uri.remove(uri) {
|
||||
self.pages_by_name.remove(&page.name);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All occurrences of a tag across the workspace. Empty slice when the
|
||||
/// tag isn't indexed.
|
||||
pub fn tags_for(&self, name: &str) -> &[TagOccurrence] {
|
||||
self.tags_by_name
|
||||
.get(name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Resolve a `LinkTarget` to a page URI in this workspace.
|
||||
/// `source_page` is the name of the page the link originates from —
|
||||
/// needed for `AnchorOnly` resolution and source-relative `Wiki` links.
|
||||
pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> {
|
||||
match target.kind {
|
||||
LinkKind::Wiki => target
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| self.resolve_wiki_path(p, source_page, target.is_absolute)),
|
||||
LinkKind::AnchorOnly => self.pages_by_name.get(source_page),
|
||||
// Interwiki / Diary / File / Local / Raw aren't resolved
|
||||
// against the workspace index here. The handlers fall back to
|
||||
// best-effort resolution (e.g. building file:// URLs).
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a wiki link target by name. Tries source-relative first
|
||||
/// (vimwiki's default — `[[Page]]` in `posts/index.wiki` opens
|
||||
/// `posts/Page.wiki`) then falls back to root-relative. Skips the
|
||||
/// source-relative attempt for explicitly absolute (`[[/Page]]`) and
|
||||
/// for source pages already at the wiki root (`source_page` with no
|
||||
/// `/`). Strips the configured `.wiki` extension and collapses any
|
||||
/// `.`/`..` segments before lookup.
|
||||
pub fn resolve_wiki_path(
|
||||
&self,
|
||||
path: &str,
|
||||
source_page: &str,
|
||||
is_absolute: bool,
|
||||
) -> Option<&Url> {
|
||||
let normalized = self.strip_link_extension(path);
|
||||
if !is_absolute {
|
||||
if let Some(slash) = source_page.rfind('/') {
|
||||
let candidate = collapse_dots(&format!("{}/{}", &source_page[..slash], normalized));
|
||||
if let Some(uri) = self.pages_by_name.get(&candidate) {
|
||||
return Some(uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
let root_candidate = collapse_dots(normalized);
|
||||
self.pages_by_name.get(&root_candidate)
|
||||
}
|
||||
|
||||
pub fn page(&self, uri: &Url) -> Option<&IndexedPage> {
|
||||
self.pages_by_uri.get(uri)
|
||||
}
|
||||
|
||||
pub fn page_by_name(&self, name: &str) -> Option<&IndexedPage> {
|
||||
self.pages_by_name
|
||||
.get(name)
|
||||
.and_then(|u| self.pages_by_uri.get(u))
|
||||
}
|
||||
|
||||
/// Resolve a wiki link target to the page it points at. Source-relative
|
||||
/// first, then root-relative — mirrors [`Self::resolve_wiki_path`] but
|
||||
/// returns the `IndexedPage` so anchor checks have everything they need.
|
||||
pub fn page_for_wiki_target(
|
||||
&self,
|
||||
path: &str,
|
||||
source_page: &str,
|
||||
is_absolute: bool,
|
||||
) -> Option<&IndexedPage> {
|
||||
self.resolve_wiki_path(path, source_page, is_absolute)
|
||||
.and_then(|u| self.pages_by_uri.get(u))
|
||||
}
|
||||
|
||||
pub fn page_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.pages_by_name.keys().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn backlinks_for(&self, page_name: &str) -> &[Backlink] {
|
||||
self.backlinks
|
||||
.get(page_name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of
|
||||
/// the four supported frequencies. Recognises:
|
||||
///
|
||||
/// `YYYY-MM-DD` → Day
|
||||
/// `YYYY-Www` → Week (ISO)
|
||||
/// `YYYY-MM` → Month
|
||||
/// `YYYY` → Year
|
||||
///
|
||||
/// 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>,
|
||||
diary_rel_path: Option<&str>,
|
||||
) -> Option<nuwiki_core::date::DiaryPeriod> {
|
||||
let path = uri.to_file_path().ok()?;
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let parsed = nuwiki_core::date::DiaryPeriod::parse(stem)?;
|
||||
let Some(root) = root else {
|
||||
return Some(parsed);
|
||||
};
|
||||
let parent = path.parent()?;
|
||||
let expected = root.join(diary_rel_path.unwrap_or("diary"));
|
||||
if parent.starts_with(&expected) {
|
||||
Some(parsed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a page name from a `file://` URL. With a workspace root the name
|
||||
/// is the URL's path relative to root, sans `.wiki`. Without a root it's
|
||||
/// just the filename stem.
|
||||
pub fn page_name_from_uri(uri: &Url, root: Option<&Path>) -> String {
|
||||
if let (Some(root), Ok(p)) = (root, uri.to_file_path()) {
|
||||
if let Ok(rel) = p.strip_prefix(root) {
|
||||
let stripped = rel.with_extension("");
|
||||
return stripped
|
||||
.to_string_lossy()
|
||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
||||
}
|
||||
}
|
||||
// Fallback: last path segment, strip .wiki if present.
|
||||
if let Ok(p) = uri.to_file_path() {
|
||||
return p
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
uri.path_segments()
|
||||
.and_then(|mut s| s.next_back())
|
||||
.map(|s| {
|
||||
s.strip_suffix(".wiki")
|
||||
.unwrap_or(s)
|
||||
.trim_start_matches('/')
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Normalise heading text into a URL-safe anchor slug.
|
||||
pub fn slugify(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_alphanumeric() {
|
||||
for c in ch.to_lowercase() {
|
||||
out.push(c);
|
||||
}
|
||||
prev_dash = false;
|
||||
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
// other punctuation: drop
|
||||
}
|
||||
while out.ends_with('-') {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ===== Indexing helpers =====
|
||||
|
||||
fn index_blocks(blocks: &[BlockNode], page: &mut IndexedPage) {
|
||||
for block in blocks {
|
||||
index_block(block, page);
|
||||
}
|
||||
}
|
||||
|
||||
fn index_block(block: &BlockNode, page: &mut IndexedPage) {
|
||||
match block {
|
||||
BlockNode::Heading(h) => {
|
||||
let title = inline_to_text(&h.children);
|
||||
page.headings.push(HeadingInfo {
|
||||
title: title.clone(),
|
||||
level: h.level,
|
||||
anchor: slugify(&title),
|
||||
span: h.span,
|
||||
});
|
||||
index_inlines(&h.children, page);
|
||||
}
|
||||
BlockNode::Paragraph(p) => index_inlines(&p.children, page),
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for c in children {
|
||||
index_block(c, page);
|
||||
}
|
||||
}
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
index_inlines(&item.children, page);
|
||||
if let Some(sub) = &item.sublist {
|
||||
index_block(&BlockNode::List(sub.clone()), page);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::DefinitionList(dl) => {
|
||||
for item in &dl.items {
|
||||
if let Some(term) = &item.term {
|
||||
index_inlines(term, page);
|
||||
}
|
||||
for def in &item.definitions {
|
||||
index_inlines(def, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Table(TableNode { rows, .. }) => {
|
||||
for row in rows {
|
||||
for cell in &row.cells {
|
||||
index_inlines(&cell.children, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Tag(t) => {
|
||||
for name in &t.tags {
|
||||
page.tags.push(TagInfo {
|
||||
name: name.clone(),
|
||||
scope: t.scope,
|
||||
span: t.span,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn index_inlines(inlines: &[InlineNode], page: &mut IndexedPage) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::WikiLink(w) => {
|
||||
page.outgoing.push(OutgoingLink {
|
||||
target_page: w.target.path.clone(),
|
||||
anchor: w.target.anchor.clone(),
|
||||
kind: w.target.kind,
|
||||
span: w.span,
|
||||
is_absolute: w.target.is_absolute,
|
||||
});
|
||||
if let Some(desc) = &w.description {
|
||||
index_inlines(desc, page);
|
||||
}
|
||||
}
|
||||
InlineNode::ExternalLink(e) => {
|
||||
if let Some(desc) = &e.description {
|
||||
index_inlines(desc, page);
|
||||
}
|
||||
}
|
||||
InlineNode::Bold(b) => index_inlines(&b.children, page),
|
||||
InlineNode::Italic(i) => index_inlines(&i.children, page),
|
||||
InlineNode::BoldItalic(bi) => index_inlines(&bi.children, page),
|
||||
InlineNode::Strikethrough(s) => index_inlines(&s.children, page),
|
||||
InlineNode::Superscript(s) => index_inlines(&s.children, page),
|
||||
InlineNode::Subscript(s) => index_inlines(&s.children, page),
|
||||
InlineNode::Color(c) => index_inlines(&c.children, page),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_to_text(inlines: &[InlineNode]) -> String {
|
||||
let mut out = String::new();
|
||||
inline_to_text_into(inlines, &mut out);
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::Text(t) => out.push_str(&t.content),
|
||||
InlineNode::Bold(b) => inline_to_text_into(&b.children, out),
|
||||
InlineNode::Italic(i) => inline_to_text_into(&i.children, out),
|
||||
InlineNode::BoldItalic(bi) => inline_to_text_into(&bi.children, out),
|
||||
InlineNode::Strikethrough(s) => inline_to_text_into(&s.children, out),
|
||||
InlineNode::Code(c) => out.push_str(&c.content),
|
||||
InlineNode::Superscript(s) => inline_to_text_into(&s.children, out),
|
||||
InlineNode::Subscript(s) => inline_to_text_into(&s.children, out),
|
||||
InlineNode::MathInline(m) => out.push_str(&m.content),
|
||||
InlineNode::Color(c) => inline_to_text_into(&c.children, out),
|
||||
InlineNode::WikiLink(w) => {
|
||||
if let Some(d) = &w.description {
|
||||
inline_to_text_into(d, out);
|
||||
} else if let Some(p) = &w.target.path {
|
||||
out.push_str(p);
|
||||
}
|
||||
}
|
||||
InlineNode::ExternalLink(e) => match &e.description {
|
||||
Some(d) => inline_to_text_into(d, out),
|
||||
None => out.push_str(&e.url),
|
||||
},
|
||||
InlineNode::Keyword(_) => {}
|
||||
InlineNode::Transclusion(t) => out.push_str(t.alt.as_deref().unwrap_or(&t.url)),
|
||||
InlineNode::RawUrl(r) => out.push_str(&r.url),
|
||||
InlineNode::SoftBreak(_) => out.push(' '),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Filesystem walking =====
|
||||
|
||||
/// Canonicalise a wikilink target into the page name the workspace index
|
||||
/// would key it by. Mirrors [`WorkspaceIndex::resolve_wiki_path`]'s preference
|
||||
/// (source-relative when not explicitly absolute, root-relative otherwise) but
|
||||
/// without requiring the target to already be indexed — useful for backlinks
|
||||
/// where the target may be added to the index after the source.
|
||||
///
|
||||
/// Collapses `.` and `..` segments so `posts/../foo` → `foo`. `..` at the
|
||||
/// root of the wiki is treated as a no-op (the wiki has no parent in this
|
||||
/// model — interwiki crossings use the dedicated `[[wiki:Page]]` syntax).
|
||||
pub fn canonical_link_name(
|
||||
path: &str,
|
||||
source_page: &str,
|
||||
is_absolute: bool,
|
||||
file_extension: Option<&str>,
|
||||
) -> String {
|
||||
let normalized = strip_wiki_extension(path, file_extension);
|
||||
let joined = if !is_absolute {
|
||||
if let Some(slash) = source_page.rfind('/') {
|
||||
format!("{}/{}", &source_page[..slash], normalized)
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
} else {
|
||||
normalized.to_string()
|
||||
};
|
||||
collapse_dots(&joined)
|
||||
}
|
||||
|
||||
/// Collapse `.` and `..` segments in a `/`-separated path string. Doesn't
|
||||
/// touch URI escapes — these are workspace-relative page names, not URLs.
|
||||
pub fn collapse_dots(s: &str) -> String {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for seg in s.split('/') {
|
||||
match seg {
|
||||
"" | "." => continue,
|
||||
".." => {
|
||||
parts.pop();
|
||||
}
|
||||
_ => parts.push(seg),
|
||||
}
|
||||
}
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
/// Strip a wiki file extension from a link target path without allocating.
|
||||
///
|
||||
/// `file_extension` may be `".wiki"` (with leading dot) or `"wiki"` (without).
|
||||
/// Returns `path` unchanged when no extension is configured, the extension is
|
||||
/// empty, or the path doesn't end with `.{ext}`.
|
||||
pub fn strip_wiki_extension<'a>(path: &'a str, file_extension: Option<&str>) -> &'a str {
|
||||
let ext = match file_extension {
|
||||
Some(e) => e.trim_start_matches('.'),
|
||||
None => return path,
|
||||
};
|
||||
if ext.is_empty() {
|
||||
return path;
|
||||
}
|
||||
let dot_ext_len = ext.len() + 1; // +1 for the '.'
|
||||
if path.len() > dot_ext_len
|
||||
&& path.ends_with(ext)
|
||||
&& path.as_bytes()[path.len() - dot_ext_len] == b'.'
|
||||
{
|
||||
&path[..path.len() - dot_ext_len]
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively collect every `.wiki` file under `root`, skipping dotfiles
|
||||
/// and dot-directories.
|
||||
pub fn walk_wiki_files(root: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name() else {
|
||||
continue;
|
||||
};
|
||||
if name.to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wiki") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
//! Helpers shared by the navigation handlers (definition,
|
||||
//! references, hover, completion).
|
||||
//!
|
||||
//! Two responsibilities:
|
||||
//!
|
||||
//! 1. **Position conversion** — clients send LSP `Position`s in either
|
||||
//! UTF-8 or UTF-16 encoding; nuwiki AST spans are byte offsets.
|
||||
//! `lsp_to_byte_pos` translates the LSP position into the same shape
|
||||
//! so it can be compared against AST spans.
|
||||
//! 2. **Position lookup** — given a byte position, find the most specific
|
||||
//! `InlineNode` whose span covers it. Used to identify the link under
|
||||
//! the cursor.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, InlineNode, ListItemNode, Span};
|
||||
use tower_lsp::lsp_types::Position as LspPosition;
|
||||
|
||||
use crate::semantic_tokens::LineIndex;
|
||||
|
||||
/// Translate an LSP `Position` (whose `character` is a UTF-16 code-unit
|
||||
/// offset by default, or a UTF-8 byte offset when negotiated) into our
|
||||
/// internal `(line, byte_col)` shape.
|
||||
pub fn lsp_to_byte_pos(p: LspPosition, text: &str, utf8: bool) -> (u32, u32) {
|
||||
if utf8 {
|
||||
return (p.line, p.character);
|
||||
}
|
||||
let idx = LineIndex::new(text);
|
||||
let line_str = idx.line_str(p.line, text);
|
||||
let byte_col = utf16_to_byte_col(line_str, p.character);
|
||||
(p.line, byte_col)
|
||||
}
|
||||
|
||||
fn utf16_to_byte_col(line: &str, target_utf16: u32) -> u32 {
|
||||
let mut byte = 0u32;
|
||||
let mut units = 0u32;
|
||||
for ch in line.chars() {
|
||||
if units >= target_utf16 {
|
||||
break;
|
||||
}
|
||||
units += ch.len_utf16() as u32;
|
||||
byte += ch.len_utf8() as u32;
|
||||
}
|
||||
byte
|
||||
}
|
||||
|
||||
/// Find the most specific inline node whose span covers the given byte
|
||||
/// position. Walks block structure first, then descends into inline
|
||||
/// containers (bold, italic, …) until no child claims the position.
|
||||
pub fn find_inline_at(doc: &DocumentNode, line: u32, byte_col: u32) -> Option<&InlineNode> {
|
||||
for block in &doc.children {
|
||||
if let Some(node) = find_in_block(block, line, byte_col) {
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&InlineNode> {
|
||||
match block {
|
||||
BlockNode::Heading(h) => find_in_inlines(&h.children, line, col),
|
||||
BlockNode::Paragraph(p) => find_in_inlines(&p.children, line, col),
|
||||
BlockNode::Blockquote(b) => b.children.iter().find_map(|c| find_in_block(c, line, col)),
|
||||
BlockNode::List(l) => l
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| find_in_list_item(it, line, col)),
|
||||
BlockNode::DefinitionList(dl) => dl.items.iter().find_map(|it| {
|
||||
it.term
|
||||
.as_ref()
|
||||
.and_then(|t| find_in_inlines(t, line, col))
|
||||
.or_else(|| {
|
||||
it.definitions
|
||||
.iter()
|
||||
.find_map(|d| find_in_inlines(d, line, col))
|
||||
})
|
||||
}),
|
||||
BlockNode::Table(t) => t.rows.iter().find_map(|r| {
|
||||
r.cells
|
||||
.iter()
|
||||
.find_map(|c| find_in_inlines(&c.children, line, col))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_in_list_item(item: &ListItemNode, line: u32, col: u32) -> Option<&InlineNode> {
|
||||
find_in_inlines(&item.children, line, col).or_else(|| {
|
||||
item.sublist.as_ref().and_then(|sub| {
|
||||
sub.items
|
||||
.iter()
|
||||
.find_map(|i| find_in_list_item(i, line, col))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> {
|
||||
for n in inlines {
|
||||
if !span_contains(n.span(), line, col) {
|
||||
continue;
|
||||
}
|
||||
// Descend into inline containers so we return the *innermost* hit.
|
||||
let deeper = match n {
|
||||
InlineNode::Bold(b) => find_in_inlines(&b.children, line, col),
|
||||
InlineNode::Italic(i) => find_in_inlines(&i.children, line, col),
|
||||
InlineNode::BoldItalic(bi) => find_in_inlines(&bi.children, line, col),
|
||||
InlineNode::Strikethrough(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Subscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Color(c) => find_in_inlines(&c.children, line, col),
|
||||
// Links are an atomic unit for navigation: a cursor anywhere in
|
||||
// `[[target|description]]` (or `[desc](url)`) should resolve the
|
||||
// link itself. Don't descend into the description — returning its
|
||||
// inner Text node leaves goto-definition/references/hover with
|
||||
// nothing to act on ("No locations found" when on the title).
|
||||
_ => None,
|
||||
};
|
||||
return Some(deeper.unwrap_or(n));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn span_contains(s: Span, line: u32, col: u32) -> bool {
|
||||
let start = (s.start.line, s.start.column);
|
||||
let end = (s.end.line, s.end.column);
|
||||
let p = (line, col);
|
||||
p >= start && p < end
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! `textDocument/rename` — server-side rename with cross-document link
|
||||
//! rewriting.
|
||||
//!
|
||||
//! Implementation:
|
||||
//! 1. Resolve the rename target — cursor on a wikilink renames the linked
|
||||
//! page; otherwise rename the current file.
|
||||
//! 2. Build the new URI from the wiki root + the user-supplied path.
|
||||
//! 3. For every page in the wiki's index with an outgoing link to the
|
||||
//! old page name, read its source via the closed-doc loader
|
||||
//! so cached spans are validated against current text, then emit a
|
||||
//! `TextEdit` that swaps the `[[old]]` for `[[new]]` while preserving
|
||||
//! anchors and descriptions.
|
||||
//! 4. Add the `RenameFile` op last so the builder emits it before content
|
||||
//! edits in `documentChanges` ordering.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tower_lsp::lsp_types::{Url, WorkspaceEdit};
|
||||
|
||||
use crate::edits::{op_rename, text_edit_replace, WorkspaceEditBuilder};
|
||||
use crate::index::page_name_from_uri;
|
||||
use crate::Backend;
|
||||
|
||||
/// Compute the `WorkspaceEdit` for renaming `target_uri` to `new_name`.
|
||||
///
|
||||
/// Returns `None` when:
|
||||
/// - the URI doesn't belong to any registered wiki,
|
||||
/// - the new URI couldn't be built from the wiki root + new_name,
|
||||
/// - the new URI is the same as the old one.
|
||||
pub(crate) async fn compute_rename(
|
||||
backend: &Backend,
|
||||
target_uri: Url,
|
||||
new_name: String,
|
||||
utf8: bool,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
let wiki = backend.wiki_for_uri(&target_uri)?;
|
||||
let old_name = page_name_from_uri(&target_uri, Some(&wiki.config.root));
|
||||
// Spaces in the link *target* (and the file on disk) are replaced with
|
||||
// the wiki's `links_space_char`; descriptions keep their original text.
|
||||
let new_name = apply_links_space_char(&new_name, &wiki.config.links_space_char);
|
||||
let new_uri = build_new_uri(&wiki.config.root, &new_name, &wiki.config.file_extension)?;
|
||||
if new_uri == target_uri {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut builder = WorkspaceEditBuilder::new();
|
||||
builder.file_op(op_rename(target_uri.clone(), new_uri));
|
||||
|
||||
// Snapshot the backlinks so the index read lock is released before we
|
||||
// await any disk I/O.
|
||||
let backlinks = {
|
||||
let idx = wiki.index.read().ok()?;
|
||||
idx.backlinks_for(&old_name).to_vec()
|
||||
};
|
||||
|
||||
for bl in backlinks {
|
||||
let Ok(snapshot) = backend.read_or_open(&bl.source_uri).await else {
|
||||
continue;
|
||||
};
|
||||
let start = bl.source_span.start.offset.min(snapshot.text.len());
|
||||
let end = bl.source_span.end.offset.min(snapshot.text.len());
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let original = &snapshot.text[start..end];
|
||||
if let Some(new_link) = rewrite_wikilink_target(original, &new_name) {
|
||||
let edit = text_edit_replace(bl.source_span, new_link, &snapshot.text, utf8);
|
||||
builder.edit(bl.source_uri.clone(), edit);
|
||||
}
|
||||
}
|
||||
|
||||
if builder.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace spaces in a link path with the configured `links_space_char`.
|
||||
/// The default `" "` is an identity transform (spaces kept verbatim).
|
||||
pub fn apply_links_space_char(name: &str, space_char: &str) -> String {
|
||||
if space_char == " " {
|
||||
name.to_string()
|
||||
} else {
|
||||
name.replace(' ', space_char)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `<root>/<new_name>(<ext>)` as a `file://` URL.
|
||||
pub fn build_new_uri(root: &Path, new_name: &str, ext: &str) -> Option<Url> {
|
||||
let mut path = root.to_path_buf();
|
||||
for part in new_name.split(['/', '\\']) {
|
||||
if !part.is_empty() {
|
||||
path.push(part);
|
||||
}
|
||||
}
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let with_ext = if path_str.ends_with(ext) {
|
||||
path_str
|
||||
} else {
|
||||
format!("{path_str}{ext}")
|
||||
};
|
||||
Url::from_file_path(with_ext).ok()
|
||||
}
|
||||
|
||||
/// Rewrite a `[[...]]` source segment so the path portion becomes
|
||||
/// `new_path`, keeping any `#anchor` and `|description` parts intact.
|
||||
///
|
||||
/// Returns `None` if `segment` doesn't look like a wikilink (starts with
|
||||
/// `[[`, ends with `]]`).
|
||||
pub fn rewrite_wikilink_target(segment: &str, new_path: &str) -> Option<String> {
|
||||
if !segment.starts_with("[[") || !segment.ends_with("]]") || segment.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let inner = &segment[2..segment.len() - 2];
|
||||
let (path_part, description) = match inner.find('|') {
|
||||
Some(i) => (&inner[..i], Some(&inner[i + 1..])),
|
||||
None => (inner, None),
|
||||
};
|
||||
let anchor = path_part.find('#').map(|i| &path_part[i + 1..]);
|
||||
|
||||
let mut out = String::with_capacity(segment.len());
|
||||
out.push_str("[[");
|
||||
out.push_str(new_path);
|
||||
if let Some(a) = anchor {
|
||||
out.push('#');
|
||||
out.push_str(a);
|
||||
}
|
||||
if let Some(d) = description {
|
||||
out.push('|');
|
||||
out.push_str(d);
|
||||
}
|
||||
out.push_str("]]");
|
||||
Some(out)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Semantic-token emission for the vimwiki AST.
|
||||
//!
|
||||
//! Token-type scheme: custom `vimwiki*` types plus
|
||||
//! `level1`..`level6` + `centered` modifiers.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//!
|
||||
//! 1. Walk the AST, emitting one or more `RawToken`s per AST node. Block
|
||||
//! structure that "wraps" text (paragraph, list item, table cell)
|
||||
//! recurses into inlines instead of emitting a wrapping token, so we
|
||||
//! end up with a non-overlapping tile of inline tokens. Block nodes
|
||||
//! that are themselves visually distinct (heading, code block, math
|
||||
//! block, comment) emit one token across their whole span.
|
||||
//! 2. Multi-line spans are split into one token per line — the LSP wire
|
||||
//! format can't express a single token that crosses a newline.
|
||||
//! 3. Tokens are sorted by `(line, byte_col)` and encoded as the LSP
|
||||
//! delta-quintuple stream `(deltaLine, deltaStart, length, type, mods)`.
|
||||
//! When the client doesn't support UTF-8 encoding, byte offsets are
|
||||
//! converted to UTF-16 code-unit counts on the fly.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, HeadingNode, InlineNode, ListItemNode, ListNode, Span,
|
||||
TableNode,
|
||||
};
|
||||
use tower_lsp::lsp_types::{Range, SemanticTokenModifier, SemanticTokenType, SemanticTokensLegend};
|
||||
|
||||
// ===== Legend =====
|
||||
|
||||
pub const TOKEN_HEADING: u32 = 0;
|
||||
pub const TOKEN_BOLD: u32 = 1;
|
||||
pub const TOKEN_ITALIC: u32 = 2;
|
||||
pub const TOKEN_BOLD_ITALIC: u32 = 3;
|
||||
pub const TOKEN_STRIKETHROUGH: u32 = 4;
|
||||
pub const TOKEN_SUPERSCRIPT: u32 = 5;
|
||||
pub const TOKEN_SUBSCRIPT: u32 = 6;
|
||||
pub const TOKEN_CODE: u32 = 7;
|
||||
pub const TOKEN_CODE_BLOCK: u32 = 8;
|
||||
pub const TOKEN_MATH: u32 = 9;
|
||||
pub const TOKEN_MATH_BLOCK: u32 = 10;
|
||||
pub const TOKEN_KEYWORD: u32 = 11;
|
||||
pub const TOKEN_COLOR: u32 = 12;
|
||||
pub const TOKEN_WIKILINK: u32 = 13;
|
||||
pub const TOKEN_EXTERNAL_LINK: u32 = 14;
|
||||
pub const TOKEN_TRANSCLUSION: u32 = 15;
|
||||
pub const TOKEN_URL: u32 = 16;
|
||||
pub const TOKEN_COMMENT: u32 = 17;
|
||||
pub const TOKEN_DEFINITION_TERM: u32 = 18;
|
||||
pub const TOKEN_TAG: u32 = 19;
|
||||
|
||||
pub const TOKEN_TYPE_NAMES: &[&str] = &[
|
||||
"vimwikiHeading",
|
||||
"vimwikiBold",
|
||||
"vimwikiItalic",
|
||||
"vimwikiBoldItalic",
|
||||
"vimwikiStrikethrough",
|
||||
"vimwikiSuperscript",
|
||||
"vimwikiSubscript",
|
||||
"vimwikiCode",
|
||||
"vimwikiCodeBlock",
|
||||
"vimwikiMath",
|
||||
"vimwikiMathBlock",
|
||||
"vimwikiKeyword",
|
||||
"vimwikiColor",
|
||||
"vimwikiWikilink",
|
||||
"vimwikiExternalLink",
|
||||
"vimwikiTransclusion",
|
||||
"vimwikiUrl",
|
||||
"vimwikiComment",
|
||||
"vimwikiDefinitionTerm",
|
||||
"vimwikiTag",
|
||||
];
|
||||
|
||||
pub const MOD_LEVEL1: u32 = 1 << 0;
|
||||
pub const MOD_LEVEL2: u32 = 1 << 1;
|
||||
pub const MOD_LEVEL3: u32 = 1 << 2;
|
||||
pub const MOD_LEVEL4: u32 = 1 << 3;
|
||||
pub const MOD_LEVEL5: u32 = 1 << 4;
|
||||
pub const MOD_LEVEL6: u32 = 1 << 5;
|
||||
pub const MOD_CENTERED: u32 = 1 << 6;
|
||||
|
||||
pub const TOKEN_MODIFIER_NAMES: &[&str] = &[
|
||||
"level1", "level2", "level3", "level4", "level5", "level6", "centered",
|
||||
];
|
||||
|
||||
pub fn legend() -> SemanticTokensLegend {
|
||||
SemanticTokensLegend {
|
||||
token_types: TOKEN_TYPE_NAMES
|
||||
.iter()
|
||||
.map(|s| SemanticTokenType::new(s))
|
||||
.collect(),
|
||||
token_modifiers: TOKEN_MODIFIER_NAMES
|
||||
.iter()
|
||||
.map(|s| SemanticTokenModifier::new(s))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Raw tokens =====
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RawToken {
|
||||
pub line: u32,
|
||||
pub byte_col: u32,
|
||||
pub byte_len: u32,
|
||||
pub kind: u32,
|
||||
pub mods: u32,
|
||||
}
|
||||
|
||||
/// Index of byte offsets at the start of each line. Built once per request
|
||||
/// so multi-line span splitting and UTF-16 conversion stay linear in
|
||||
/// document size.
|
||||
pub struct LineIndex {
|
||||
pub starts: Vec<usize>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
impl LineIndex {
|
||||
pub fn new(text: &str) -> Self {
|
||||
let mut starts = vec![0];
|
||||
for (i, b) in text.bytes().enumerate() {
|
||||
if b == b'\n' {
|
||||
starts.push(i + 1);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
starts,
|
||||
total: text.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn line_byte_range(&self, line: u32) -> (usize, usize) {
|
||||
let start = self
|
||||
.starts
|
||||
.get(line as usize)
|
||||
.copied()
|
||||
.unwrap_or(self.total);
|
||||
let end = self
|
||||
.starts
|
||||
.get(line as usize + 1)
|
||||
.map(|&s| s.saturating_sub(1))
|
||||
.unwrap_or(self.total);
|
||||
(start, end)
|
||||
}
|
||||
|
||||
pub fn line_str<'a>(&self, line: u32, text: &'a str) -> &'a str {
|
||||
let (s, e) = self.line_byte_range(line);
|
||||
&text[s..e]
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Collection =====
|
||||
|
||||
pub fn collect_tokens(doc: &DocumentNode, text: &str) -> Vec<RawToken> {
|
||||
let idx = LineIndex::new(text);
|
||||
let mut out = Vec::new();
|
||||
for block in &doc.children {
|
||||
emit_block(block, text, &idx, &mut out);
|
||||
}
|
||||
out.sort_by_key(|t| (t.line, t.byte_col));
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_block(block: &BlockNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
match block {
|
||||
BlockNode::Heading(h) => emit_heading(h, text, idx, out),
|
||||
BlockNode::Paragraph(p) => emit_inlines(&p.children, text, idx, out),
|
||||
BlockNode::HorizontalRule(_) => {}
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for child in children {
|
||||
emit_block(child, text, idx, out);
|
||||
}
|
||||
}
|
||||
BlockNode::Preformatted(p) => split_span(p.span, text, idx, TOKEN_CODE_BLOCK, 0, out),
|
||||
BlockNode::MathBlock(m) => split_span(m.span, text, idx, TOKEN_MATH_BLOCK, 0, out),
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
emit_list_item(item, text, idx, out);
|
||||
}
|
||||
}
|
||||
BlockNode::DefinitionList(dl) => {
|
||||
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(first.span().start, last.span().end);
|
||||
split_span(s, text, idx, TOKEN_DEFINITION_TERM, 0, out);
|
||||
}
|
||||
}
|
||||
for def in &item.definitions {
|
||||
emit_inlines(def, text, idx, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Table(TableNode { rows, .. }) => {
|
||||
for row in rows {
|
||||
for cell in &row.cells {
|
||||
emit_inlines(&cell.children, text, idx, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Comment(c) => split_span(c.span, text, idx, TOKEN_COMMENT, 0, out),
|
||||
BlockNode::Tag(t) => split_span(t.span, text, idx, TOKEN_TAG, 0, out),
|
||||
BlockNode::Error(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_heading(h: &HeadingNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
let mut mods = 0u32;
|
||||
mods |= match h.level {
|
||||
1 => MOD_LEVEL1,
|
||||
2 => MOD_LEVEL2,
|
||||
3 => MOD_LEVEL3,
|
||||
4 => MOD_LEVEL4,
|
||||
5 => MOD_LEVEL5,
|
||||
_ => MOD_LEVEL6,
|
||||
};
|
||||
if h.centered {
|
||||
mods |= MOD_CENTERED;
|
||||
}
|
||||
split_span(h.span, text, idx, TOKEN_HEADING, mods, out);
|
||||
}
|
||||
|
||||
fn emit_list_item(item: &ListItemNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
emit_inlines(&item.children, text, idx, out);
|
||||
if let Some(sublist) = &item.sublist {
|
||||
for child in &sublist.items {
|
||||
emit_list_item(child, text, idx, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_inlines(inlines: &[InlineNode], text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
for n in inlines {
|
||||
let (kind, span) = match n {
|
||||
InlineNode::Text(_) | InlineNode::SoftBreak(_) => continue,
|
||||
InlineNode::Bold(b) => (TOKEN_BOLD, b.span),
|
||||
InlineNode::Italic(i) => (TOKEN_ITALIC, i.span),
|
||||
InlineNode::BoldItalic(bi) => (TOKEN_BOLD_ITALIC, bi.span),
|
||||
InlineNode::Strikethrough(s) => (TOKEN_STRIKETHROUGH, s.span),
|
||||
InlineNode::Code(c) => (TOKEN_CODE, c.span),
|
||||
InlineNode::Superscript(s) => (TOKEN_SUPERSCRIPT, s.span),
|
||||
InlineNode::Subscript(s) => (TOKEN_SUBSCRIPT, s.span),
|
||||
InlineNode::MathInline(m) => (TOKEN_MATH, m.span),
|
||||
InlineNode::Keyword(k) => (TOKEN_KEYWORD, k.span),
|
||||
InlineNode::Color(c) => (TOKEN_COLOR, c.span),
|
||||
InlineNode::WikiLink(w) => (TOKEN_WIKILINK, w.span),
|
||||
InlineNode::ExternalLink(e) => (TOKEN_EXTERNAL_LINK, e.span),
|
||||
InlineNode::Transclusion(t) => (TOKEN_TRANSCLUSION, t.span),
|
||||
InlineNode::RawUrl(u) => (TOKEN_URL, u.span),
|
||||
};
|
||||
split_span(span, text, idx, kind, 0, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn split_span(
|
||||
span: Span,
|
||||
text: &str,
|
||||
idx: &LineIndex,
|
||||
kind: u32,
|
||||
mods: u32,
|
||||
out: &mut Vec<RawToken>,
|
||||
) {
|
||||
if span.start.line == span.end.line {
|
||||
let len = span.end.column.saturating_sub(span.start.column);
|
||||
if len > 0 {
|
||||
out.push(RawToken {
|
||||
line: span.start.line,
|
||||
byte_col: span.start.column,
|
||||
byte_len: len,
|
||||
kind,
|
||||
mods,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
for line in span.start.line..=span.end.line {
|
||||
let line_str = idx.line_str(line, text);
|
||||
let line_len = line_str.len() as u32;
|
||||
let start_col = if line == span.start.line {
|
||||
span.start.column
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end_col = if line == span.end.line {
|
||||
span.end.column.min(line_len)
|
||||
} else {
|
||||
line_len
|
||||
};
|
||||
if end_col > start_col {
|
||||
out.push(RawToken {
|
||||
line,
|
||||
byte_col: start_col,
|
||||
byte_len: end_col - start_col,
|
||||
kind,
|
||||
mods,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Encoding =====
|
||||
|
||||
/// LSP wire-format encoder. Produces a flat `Vec<u32>` of
|
||||
/// `(deltaLine, deltaStart, length, type, modifiers)` quintuples.
|
||||
///
|
||||
/// `utf8 = true` means the client opted into UTF-8 encoding, so byte offsets
|
||||
/// pass through directly. Otherwise positions and lengths are converted to
|
||||
/// UTF-16 code units per the LSP default.
|
||||
pub fn encode(raws: &[RawToken], text: &str, idx: &LineIndex, utf8: bool) -> Vec<u32> {
|
||||
let mut data = Vec::with_capacity(raws.len() * 5);
|
||||
let mut prev_line = 0u32;
|
||||
let mut prev_col = 0u32;
|
||||
for tok in raws {
|
||||
let (col, len) = if utf8 {
|
||||
(tok.byte_col, tok.byte_len)
|
||||
} else {
|
||||
let line_str = idx.line_str(tok.line, text);
|
||||
let start = utf16_count(line_str, tok.byte_col as usize);
|
||||
let end = utf16_count(line_str, (tok.byte_col + tok.byte_len) as usize);
|
||||
(start, end - start)
|
||||
};
|
||||
let delta_line = tok.line - prev_line;
|
||||
let delta_col = if delta_line == 0 { col - prev_col } else { col };
|
||||
data.extend_from_slice(&[delta_line, delta_col, len, tok.kind, tok.mods]);
|
||||
prev_line = tok.line;
|
||||
prev_col = col;
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn utf16_count(s: &str, byte_offset: usize) -> u32 {
|
||||
let upto = byte_offset.min(s.len());
|
||||
s[..upto].chars().map(char::len_utf16).sum::<usize>() as u32
|
||||
}
|
||||
|
||||
/// Build the encoded data stream for either the full document or a range
|
||||
/// request.
|
||||
pub fn build_data(doc: &DocumentNode, text: &str, utf8: bool, range: Option<Range>) -> Vec<u32> {
|
||||
let idx = LineIndex::new(text);
|
||||
let mut tokens = collect_tokens(doc, text);
|
||||
if let Some(r) = range {
|
||||
tokens.retain(|t| token_overlaps_range(t, &r, text, &idx, utf8));
|
||||
}
|
||||
encode(&tokens, text, &idx, utf8)
|
||||
}
|
||||
|
||||
fn token_overlaps_range(
|
||||
tok: &RawToken,
|
||||
range: &Range,
|
||||
text: &str,
|
||||
idx: &LineIndex,
|
||||
utf8: bool,
|
||||
) -> bool {
|
||||
let (start_char, end_char) = if utf8 {
|
||||
(tok.byte_col, tok.byte_col + tok.byte_len)
|
||||
} else {
|
||||
let line_str = idx.line_str(tok.line, text);
|
||||
let s = utf16_count(line_str, tok.byte_col as usize);
|
||||
let e = utf16_count(line_str, (tok.byte_col + tok.byte_len) as usize);
|
||||
(s, e)
|
||||
};
|
||||
let tok_start = (tok.line, start_char);
|
||||
let tok_end = (tok.line, end_char);
|
||||
let r_start = (range.start.line, range.start.character);
|
||||
let r_end = (range.end.line, range.end.character);
|
||||
tok_end > r_start && tok_start < r_end
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! `Wiki` aggregate — per-wiki state, even when there's only one.
|
||||
//!
|
||||
//! The single `Arc<RwLock<WorkspaceIndex>>` on `Backend` is lifted into a
|
||||
//! `Wiki` aggregate so multi-wiki support only changes config shape, not
|
||||
//! data flow. Handlers always operate on a `Wiki`; in practice the `wikis`
|
||||
//! `Vec` has one entry until multi-wiki support lands.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
use crate::config::WikiConfig;
|
||||
use crate::index::WorkspaceIndex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
|
||||
pub struct WikiId(pub u32);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Wiki {
|
||||
pub id: WikiId,
|
||||
// `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>>,
|
||||
}
|
||||
|
||||
impl Wiki {
|
||||
pub fn new(id: WikiId, config: WikiConfig) -> Self {
|
||||
let index = WorkspaceIndex::new(Some(config.root.clone()))
|
||||
.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: Arc::new(config),
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `uri` resolves to a file under this wiki's root.
|
||||
pub fn contains(&self, uri: &Url) -> bool {
|
||||
let Ok(path) = uri.to_file_path() else {
|
||||
return false;
|
||||
};
|
||||
path.starts_with(&self.config.root)
|
||||
}
|
||||
|
||||
/// Length of the wiki's root path (component count) — used to break
|
||||
/// ties in nested-root layouts.
|
||||
pub fn root_depth(&self) -> usize {
|
||||
self.config.root.components().count()
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.config.root
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the wiki whose root is the longest prefix of `uri`. Returns the
|
||||
/// wiki id, not a guard, so callers can release the read lock before
|
||||
/// awaiting.
|
||||
pub fn resolve_uri_to_wiki(wikis: &[Wiki], uri: &Url) -> Option<WikiId> {
|
||||
wikis
|
||||
.iter()
|
||||
.filter(|w| w.contains(uri))
|
||||
.max_by_key(|w| w.root_depth())
|
||||
.map(|w| w.id)
|
||||
}
|
||||
|
||||
/// Build the initial `Vec<Wiki>` from a list of `WikiConfig`s. Always
|
||||
/// assigns ids by position so `WikiId(0)` is the default wiki.
|
||||
pub fn build_wikis(configs: &[WikiConfig]) -> Vec<Wiki> {
|
||||
configs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cfg)| Wiki::new(WikiId(i as u32), cfg.clone()))
|
||||
.collect()
|
||||
}
|
||||
Reference in New Issue
Block a user