Compare commits
7 Commits
d21237cfdc
...
v0.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 9884f7b13c | |||
| e073533c62 | |||
| 2dfc2d9de2 | |||
| 18f1ddfe7f | |||
| 7da13ffc5c | |||
| 31f41315b2 | |||
| caaafd3cf3 |
@@ -133,8 +133,15 @@ jobs:
|
|||||||
# crti.o, and friends needed at link time.
|
# crti.o, and friends needed at link time.
|
||||||
apt: "gcc-aarch64-linux-gnu libc6-dev-arm64-cross"
|
apt: "gcc-aarch64-linux-gnu libc6-dev-arm64-cross"
|
||||||
rustflags: "-D warnings"
|
rustflags: "-D warnings"
|
||||||
|
# No musl-tools / musl-gcc here on purpose. Rust ships a
|
||||||
|
# self-contained musl libc.a plus the crt objects for this
|
||||||
|
# target, and the default link mode is static-pie. Routing the
|
||||||
|
# link through Ubuntu's musl-gcc wrapper instead produces a
|
||||||
|
# *dynamic* binary needing /lib/ld-musl-x86_64.so.1, which
|
||||||
|
# exists on approximately no machine outside Alpine — the
|
||||||
|
# opposite of what a musl build is for.
|
||||||
- target: x86_64-unknown-linux-musl
|
- target: x86_64-unknown-linux-musl
|
||||||
apt: "musl-tools"
|
apt: ""
|
||||||
rustflags: "-D warnings"
|
rustflags: "-D warnings"
|
||||||
- target: aarch64-unknown-linux-musl
|
- target: aarch64-unknown-linux-musl
|
||||||
apt: ""
|
apt: ""
|
||||||
@@ -168,13 +175,29 @@ jobs:
|
|||||||
- name: Build nuwiki-ls
|
- name: Build nuwiki-ls
|
||||||
env:
|
env:
|
||||||
# Per-target linker overrides. Cargo ignores the entries that
|
# Per-target linker overrides. Cargo ignores the entries that
|
||||||
# don't match the current target, so setting all of them here
|
# don't match the current target, so setting them here keeps
|
||||||
# keeps the matrix declarative.
|
# the matrix declarative. Only add an override for a target
|
||||||
|
# whose default linker genuinely can't do the job.
|
||||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
|
|
||||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||||
run: cargo build --release --target ${{ matrix.target }} -p nuwiki-ls
|
run: cargo build --release --target ${{ matrix.target }} -p nuwiki-ls
|
||||||
|
|
||||||
|
# A musl build only earns its keep if it is actually static. A
|
||||||
|
# dynamic one silently fails at exec time with "no such file or
|
||||||
|
# directory" on every non-Alpine host, so fail the release here
|
||||||
|
# rather than shipping it.
|
||||||
|
- name: Verify musl binary is statically linked
|
||||||
|
if: contains(matrix.target, 'musl')
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bin="target/${{ matrix.target }}/release/nuwiki-ls"
|
||||||
|
if readelf -l "$bin" | grep -q INTERP; then
|
||||||
|
echo "::error::$bin is dynamically linked but should be static:"
|
||||||
|
readelf -l "$bin" | grep -A2 INTERP
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: $bin has no INTERP segment (statically linked)."
|
||||||
|
|
||||||
- name: Package archive
|
- name: Package archive
|
||||||
id: package
|
id: package
|
||||||
env:
|
env:
|
||||||
@@ -183,7 +206,9 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
archive="nuwiki-ls-${VERSION}-${{ matrix.target }}.tar.gz"
|
archive="nuwiki-ls-${VERSION}-${{ matrix.target }}.tar.gz"
|
||||||
tar -czf "$archive" -C "target/${{ matrix.target }}/release" nuwiki-ls
|
tar -czf "$archive" -C "target/${{ matrix.target }}/release" nuwiki-ls
|
||||||
# Use a stable name without version so /releases/latest/download/nuwiki-ls-{target}.tar.gz always resolves.
|
# Use a stable name without version so the download URL always
|
||||||
|
# resolves. Note the Gitea shape is /releases/download/latest/<name>,
|
||||||
|
# not GitHub's /releases/latest/download/<name> — the latter 404s.
|
||||||
stable="nuwiki-ls-${{ matrix.target }}.tar.gz"
|
stable="nuwiki-ls-${{ matrix.target }}.tar.gz"
|
||||||
mv "$archive" "$stable"
|
mv "$archive" "$stable"
|
||||||
echo "archive=$stable" >> "$GITHUB_OUTPUT"
|
echo "archive=$stable" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
Generated
+3
-3
@@ -376,11 +376,11 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nuwiki-core"
|
name = "nuwiki-core"
|
||||||
version = "0.4.1"
|
version = "0.5.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nuwiki-ls"
|
name = "nuwiki-ls"
|
||||||
version = "0.4.1"
|
version = "0.5.2"
|
||||||
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.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dashmap 6.1.0",
|
"dashmap 6.1.0",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.1"
|
version = "0.5.2"
|
||||||
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>"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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.2" }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
|
||||||
|
|||||||
@@ -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.2" }
|
||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user