5 Commits

Author SHA1 Message Date
gffranco 2dfc2d9de2 chore(release): 0.5.1
CI / cargo fmt --check (push) Successful in 39s
Release / bump + tag (push) Successful in 45s
CI / cargo clippy (push) Successful in 1m9s
CI / cargo test (push) Successful in 1m19s
Release / build x86_64-unknown-linux-gnu (push) Successful in 1m43s
Release / build aarch64-unknown-linux-musl (push) Successful in 1m48s
Release / build x86_64-unknown-linux-musl (push) Successful in 1m57s
Release / build aarch64-unknown-linux-gnu (push) Successful in 2m0s
Release / gitea release (push) Successful in 53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzhq43v1qpkZczN65dqXa3
2026-07-02 12:30:57 +00:00
gffranco 18f1ddfe7f fix(lexer): don't split table cells on | inside links/code
The table-row lexer collected every `|` as a cell separator, so a pipe
inside a `[[url|title]]` wikilink (or `{{…}}` transclusion / `` `…` ``
inline code) was mistaken for a column boundary — mangling the link and
adding a phantom column in the AST and HTML export.

Replace the naive scan in `try_lex_table_row` with `cell_bar_positions`,
which skips pipes inside those constructs (matching the regions
`lex_inline` tokenises as a unit). Unclosed constructs fall back to
literal openers so a malformed cell can't swallow the rest of the row,
and the scan only slices at ASCII openers to stay UTF-8 safe.

Backslash-escaped `\|` is intentionally left unhandled: the inline lexer
has no escape handling, so honouring it here would leave a stray
backslash in the output. Documented as a matched follow-up.

Adds lexer + HTML-export regression tests.

Closes #1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzhq43v1qpkZczN65dqXa3
2026-07-02 12:30:15 +00:00
gitea-actions 7da13ffc5c chore(release): 0.5.0 2026-06-24 16:21:41 +00:00
gffranco 31f41315b2 docs(readme): clarify this is the server; the plugin lives at gffranco/nuwiki
CI / cargo fmt --check (push) Successful in 52s
CI / cargo clippy (push) Successful in 58s
CI / cargo test (push) Successful in 1m10s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:52:04 +00:00
gffranco caaafd3cf3 sync: bring Rust crates in line with nuwiki main (v0.4.2)
CI / cargo fmt --check (push) Successful in 31s
CI / cargo clippy (push) Successful in 34s
CI / cargo test (push) Successful in 49s
Release / bump + tag (push) Successful in 52s
Release / build aarch64-unknown-linux-gnu (push) Successful in 1m45s
Release / build x86_64-unknown-linux-gnu (push) Successful in 2m3s
Release / build aarch64-unknown-linux-musl (push) Successful in 2m6s
Release / build x86_64-unknown-linux-musl (push) Successful in 2m8s
Release / gitea release (push) Successful in 30s
2026-06-24 09:15:09 +00:00
8 changed files with 169 additions and 20 deletions
Generated
+3 -3
View File
@@ -376,11 +376,11 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]] [[package]]
name = "nuwiki-core" name = "nuwiki-core"
version = "0.4.1" version = "0.5.1"
[[package]] [[package]]
name = "nuwiki-ls" name = "nuwiki-ls"
version = "0.4.1" version = "0.5.1"
dependencies = [ dependencies = [
"nuwiki-lsp", "nuwiki-lsp",
"tokio", "tokio",
@@ -388,7 +388,7 @@ dependencies = [
[[package]] [[package]]
name = "nuwiki-lsp" name = "nuwiki-lsp"
version = "0.4.1" version = "0.5.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dashmap 6.1.0", "dashmap 6.1.0",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.4.1" version = "0.5.1"
edition = "2021" edition = "2021"
rust-version = "1.83" rust-version = "1.83"
authors = ["Gabriel Fróes Franco <gffranco@gmail.com>"] authors = ["Gabriel Fróes Franco <gffranco@gmail.com>"]
+9 -1
View File
@@ -1,6 +1,14 @@
# nuwiki-rs # nuwiki-rs
Rust crates powering [nuwiki](https://code.gfran.co/gffranco/nuwiki): the vimwiki-compatible language server and editor plugin for Vim/Neovim. The Rust language server (`nuwiki-ls`) behind [nuwiki](https://code.gfran.co/gffranco/nuwiki), the vimwiki-compatible Vim/Neovim plugin.
> [!NOTE]
> **This repo is the server only.** If you just want to *use* nuwiki in
> Vim/Neovim, install the plugin from
> **[gffranco/nuwiki](https://code.gfran.co/gffranco/nuwiki)** — it downloads
> a prebuilt `nuwiki-ls` binary from this repo's releases automatically, so
> you don't need this repo or a Rust toolchain. Work here only to develop the
> parser / LSP / HTML renderer.
## Crates ## Crates
+52 -13
View File
@@ -526,10 +526,9 @@ impl<'src> LexState<'src> {
if level == 0 { if level == 0 {
return false; return false;
} }
// Must be followed by at least one space. // vimwiki accepts both spaced (`== H ==`) and spaceless (`==H==`)
if leading_ws + level >= bytes.len() || bytes[leading_ws + level] != b' ' { // headings, so we don't require a space after the opening `=`s. The
return false; // title is still trimmed of one optional space per side below.
}
// Trailing `=`s of the same level. // Trailing `=`s of the same level.
let trimmed_end = line.trim_end_matches([' ', '\t']); let trimmed_end = line.trim_end_matches([' ', '\t']);
@@ -618,15 +617,10 @@ impl<'src> LexState<'src> {
end -= 1; end -= 1;
} }
// Collect cell boundaries. // Collect cell boundaries. A `|` inside a `[[…]]` wiklink, a `{{…}}`
let mut sep_positions = Vec::new(); // transclusion, or `` `…` `` inline code is literal cell content, not
let mut k = i; // a separator, so those regions are skipped (see `cell_bar_positions`).
while k < end { let sep_positions = cell_bar_positions(line, i, end);
if bytes[k] == b'|' {
sep_positions.push(k);
}
k += 1;
}
if sep_positions.len() < 2 { if sep_positions.len() < 2 {
return false; return false;
@@ -1069,6 +1063,51 @@ impl<'src> LexState<'src> {
/// bracketed description like `[[page|Task [B-1]]]` keeps the `[B-1]` and closes /// bracketed description like `[[page|Task [B-1]]]` keeps the `[B-1]` and closes
/// at the final `]]`. A stray single `]` (no matching `[`) is treated as literal /// at the final `]]`. A stray single `]` (no matching `[`) is treated as literal
/// body text. Returns `None` if no closing `]]` is found. /// body text. Returns `None` if no closing `]]` is found.
/// Byte offsets of the `|` characters in `line[i..end]` that act as
/// table-cell separators. Pipes inside a `[[…]]` wikilink, a `{{…}}`
/// transclusion, or `` `…` `` inline code are literal cell content — they
/// mirror the regions [`VimwikiLexer::lex_inline`] tokenises as a single unit
/// — so they are not reported as separators. An *unclosed* construct is
/// treated as a literal opener (it does not start a skip region), so a
/// malformed cell can never swallow the rest of the row.
///
/// Note: a backslash-escaped `\|` is intentionally *not* honoured here,
/// because the inline lexer has no escape handling and would render the
/// backslash literally; supporting it would need a matching change there.
fn cell_bar_positions(line: &str, i: usize, end: usize) -> Vec<usize> {
let bytes = line.as_bytes();
let mut positions = Vec::new();
let mut k = i;
while k < end {
// These openers are ASCII, so `bytes[k]` never matches a UTF-8
// continuation byte and every slice below starts on a char boundary.
match bytes[k] {
b'[' if bytes.get(k + 1) == Some(&b'[') => {
if let Some(close) = find_wikilink_close(&line[k + 2..end]) {
k += close + 4; // `[[` + inner + `]]`
continue;
}
}
b'{' if bytes.get(k + 1) == Some(&b'{') => {
if let Some(close) = line[k + 2..end].find("}}") {
k += close + 4; // `{{` + inner + `}}`
continue;
}
}
b'`' => {
if let Some(close) = line[k + 1..end].find('`') {
k += close + 2; // opening + inner + closing backtick
continue;
}
}
b'|' => positions.push(k),
_ => {}
}
k += 1;
}
positions
}
fn find_wikilink_close(s: &str) -> Option<usize> { fn find_wikilink_close(s: &str) -> Option<usize> {
let b = s.as_bytes(); let b = s.as_bytes();
let mut depth: u32 = 0; let mut depth: u32 = 0;
+34
View File
@@ -77,6 +77,27 @@ fn centered_heading_gets_centered_class() {
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>")); assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
} }
#[test]
fn spaceless_heading_renders_as_heading() {
// `==Heading==` (no surrounding spaces) is a heading, like vimwiki — not a
// literal `<p>==Heading==</p>`.
let out = render("==Plain==\n");
assert!(
out.contains(
"<div id=\"Plain\"><h2 id=\"Plain\" class=\"header\">\
<a href=\"#Plain\">Plain</a></h2></div>"
),
"got: {out}"
);
// The spaceless + keyword combo from the report: a header, not a badge.
let out2 = render("==TODO==\n");
assert!(
out2.contains("<h2 id=\"TODO\" class=\"header\">"),
"got: {out2}"
);
assert!(!out2.contains("<p>"), "not a paragraph: {out2}");
}
#[test] #[test]
fn heading_keyword_renders_as_plain_text_not_badge() { fn heading_keyword_renders_as_plain_text_not_badge() {
// `== TODO ==` is a section title, not an inline keyword: it must render as // `== TODO ==` is a section title, not an inline keyword: it must render as
@@ -224,6 +245,19 @@ fn table_with_header_separator() {
assert!(out.contains("<td> 1 </td>")); assert!(out.contains("<td> 1 </td>"));
} }
#[test]
fn table_cell_with_wikilink_pipe_exports_one_cell() {
// Regression: the `|` in `[[url|title]]` must stay inside the link, so the
// row keeps two columns and the link renders as a single anchor. Before the
// fix the cell split into `[[https://example.com` + `My Site]]`, adding a
// phantom <td> and breaking the link.
let out = render("| Name | Link |\n|---|---|\n| foo | [[https://example.com|My Site]] |\n");
assert!(
out.contains("<td> foo </td><td> <a href=\"https://example.com\">My Site</a> </td>"),
"got: {out}"
);
}
#[test] #[test]
fn single_comment_becomes_html_comment() { fn single_comment_becomes_html_comment() {
let out = render("%% hidden\n"); let out = render("%% hidden\n");
+68
View File
@@ -85,6 +85,32 @@ fn centered_heading_is_marked_centered() {
assert!(matches!(lexed[2], HeadingClose)); assert!(matches!(lexed[2], HeadingClose));
} }
#[test]
fn spaceless_heading_is_a_heading() {
// vimwiki accepts `==Heading==` with no spaces around the text.
let lexed = lex("==Plain==\n");
assert_eq!(
lexed,
vec![
HeadingOpen {
level: 2,
centered: false
},
text("Plain"),
HeadingClose,
Newline,
]
);
}
#[test]
fn marker_only_or_unbalanced_lines_are_not_headings() {
// No title between markers, and trailing `=` not at line end → plain text.
assert!(!matches!(lex("======\n")[0], HeadingOpen { .. }));
assert!(!matches!(lex("==> arrow\n")[0], HeadingOpen { .. }));
assert!(!matches!(lex("==a==b\n")[0], HeadingOpen { .. }));
}
#[test] #[test]
fn heading_with_inline_bold() { fn heading_with_inline_bold() {
let lexed = lex("== Hello *world* ==\n"); let lexed = lex("== Hello *world* ==\n");
@@ -428,6 +454,48 @@ fn table_col_and_row_span_cells() {
); );
} }
#[test]
fn table_cell_with_wikilink_pipe_is_not_split() {
// The `|` inside `[[url|title]]` is part of the link, not a cell
// boundary: the row must lex to exactly two cells (three TableSeps).
assert_eq!(
lex("| foo | [[https://example.com|My Site]] |\n"),
vec![
TableSep,
text(" foo "),
TableSep,
text(" "),
WikiLinkOpen,
text("https://example.com"),
WikiLinkSep,
text("My Site"),
WikiLinkClose,
text(" "),
TableSep,
Newline,
]
);
}
#[test]
fn table_cell_with_transclusion_and_code_pipes_are_not_split() {
// `|` inside `{{…}}` and inside `` `…` `` are both literal.
let lexed = lex("| {{a|b}} | `x|y` |\n");
let seps = lexed.iter().filter(|t| matches!(t, TableSep)).count();
assert_eq!(seps, 3, "expected 2 cells: {lexed:?}");
}
#[test]
fn table_cell_with_unclosed_wikilink_falls_back_to_pipe_split() {
// A malformed, unclosed `[[` must not swallow the rest of the row —
// the pipes still delimit cells.
let seps = lex("| [[oops | tail |\n")
.iter()
.filter(|t| matches!(t, TableSep))
.count();
assert_eq!(seps, 3);
}
// ===== Definition list ===== // ===== Definition list =====
#[test] #[test]
+1 -1
View File
@@ -17,5 +17,5 @@ name = "nuwiki-ls"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.4.1" } nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.5.1" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
+1 -1
View File
@@ -13,7 +13,7 @@ homepage.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
nuwiki-core = { path = "../nuwiki-core", version = "0.4.1" } nuwiki-core = { path = "../nuwiki-core", version = "0.5.1" }
tower-lsp = "0.20" tower-lsp = "0.20"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] }
dashmap = "6" dashmap = "6"