Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a543559
create fixture for whitespace wikilinks
JayJayArr Aug 13, 2025
6311db0
implement directory walking for base-url
JayJayArr Aug 28, 2025
bf27508
implement indexing and lookup
JayJayArr Aug 31, 2025
0f1228c
switch to Hashmap to resolve file names to pathes
JayJayArr Sep 2, 2025
9762e07
feat: resolve Filenames through wikilink checker
JayJayArr Sep 17, 2025
cc4630d
fix: exclude fragments
JayJayArr Sep 30, 2025
91fa1ed
Apply suggestions from code review
JayJayArr Oct 3, 2025
da50cb8
tie --include-wikilinks to --base-url
JayJayArr Oct 3, 2025
62a69c1
update return values for Wikilink checker
JayJayArr Oct 3, 2025
f4b0600
refactor: wikilink cleanup
JayJayArr Oct 13, 2025
070b7b2
feat: WikilinkChecker as optional
JayJayArr Oct 13, 2025
c95c765
Apply suggestions from code review
JayJayArr Nov 16, 2025
166b870
refactor: WikilinkResolver in own module
JayJayArr Nov 20, 2025
fcb8716
Apply suggestion from @mre
mre Nov 25, 2025
27b2787
Apply suggestion from @mre
mre Nov 25, 2025
b74cb5e
Apply suggestion from @mre
mre Nov 25, 2025
93ea466
Apply suggestions from code review
JayJayArr Nov 25, 2025
b67a579
WikilinkResolver base non-optional
JayJayArr Nov 26, 2025
3a42e9f
refactor: move WikiLink cleaning to WikiLink Module
JayJayArr Dec 6, 2025
3b390f3
fix type
JayJayArr Dec 6, 2025
8f9c107
Error handling for invalid base
thomas-zahner Dec 17, 2025
d3bac8d
Apply suggestions from @thomas-zahner
JayJayArr Dec 22, 2025
335beb7
Improve Error Handling for WikilinkNotFound
JayJayArr Dec 22, 2025
dfeab26
Check for Unsupported Characters in Wikilinks
JayJayArr Dec 22, 2025
2afa74b
Remove Unsupported Character Check
JayJayArr Dec 23, 2025
9824dbe
Test for WikiLinkNotFound
JayJayArr Dec 23, 2025
0c3dcb5
Simplify WikilinkIndex
thomas-zahner Dec 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Available as a command-line utility, a library and a [GitHub Action](https://git

<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->

## Table of Contents

- [Development](#development)
Expand Down Expand Up @@ -573,7 +574,7 @@ Options:
Find links in verbatim sections like `pre`- and `code` blocks

--include-wikilinks
Check WikiLinks in Markdown files
Check WikiLinks in Markdown files, this requires specifying --base-url

--index-files <INDEX_FILES>
When checking locally, resolves directory links to a separate index file.
Expand Down
1 change: 1 addition & 0 deletions fixtures/wiki/Dash-Usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Header
5 changes: 5 additions & 0 deletions fixtures/wiki/Non-existent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Links to non-existing Files

[[Does not exist]]
[[Doesn't exist.md]]
[[Does_not_exist]]
1 change: 1 addition & 0 deletions fixtures/wiki/Space Usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Header
1 change: 1 addition & 0 deletions fixtures/wiki/Underscore_Usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Header
1 change: 1 addition & 0 deletions fixtures/wiki/Usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Header
8 changes: 8 additions & 0 deletions fixtures/wiki/obsidian-style-plus-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[[#LocalHeader]]

# LocalHeader

[[Usage#Header|HeaderRenaming]]
[[Space Usage#Header|HeaderRenaming]]
[[Space Usage DifferentDirectory#Header|HeaderRenaming]]
[[DifferentDirectory#Header|HeaderRenaming]]
4 changes: 4 additions & 0 deletions fixtures/wiki/obsidian-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[[Usage]]
[[Space Usage]]
[[Space Usage DifferentDirectory]]
[[DifferentDirectory]]
1 change: 1 addition & 0 deletions fixtures/wiki/subdirectory/Different-Directory-Dash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Header
1 change: 1 addition & 0 deletions fixtures/wiki/subdirectory/DifferentDirectory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Header
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Header
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Header
19 changes: 19 additions & 0 deletions fixtures/wiki/wikilink-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[[#LocalHeader]]

[[Usage]]
[[Space Usage]]
[[Dash Usage]]
[[Underscore Usage]]
[[DifferentDirectory]]
[[Different Directory Dash]]
[[Different Directory Underscore]]

[[Usage#Header|HeaderRenaming]]
[[Space Usage#Header|HeaderRenaming]]
[[Dash Usage#Header|HeaderRenaming]]
[[Underscore Usage#Header|HeaderRenaming]]
[[DifferentDirectory#Header|HeaderRenaming]]
[[Different Directory Dash#Header|HeaderRenaming]]
[[Different Directory Underscore#Header|HeaderRenaming]]

# LocalHeader
1 change: 1 addition & 0 deletions lychee-bin/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub(crate) fn create(cfg: &Config, cookie_jar: Option<&Arc<CookieStoreMutex>>) -
.include_fragments(cfg.include_fragments)
.fallback_extensions(cfg.fallback_extensions.clone())
.index_files(cfg.index_files.clone())
.include_wikilinks(cfg.include_wikilinks)
.rate_limit_config(RateLimitConfig::from_options(
cfg.host_concurrency,
cfg.host_request_interval,
Expand Down
3 changes: 2 additions & 1 deletion lychee-bin/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,8 @@ and existing cookies will be updated."
pub(crate) cookie_jar: Option<PathBuf>,

#[allow(clippy::doc_markdown)]
/// Check WikiLinks in Markdown files
/// Check WikiLinks in Markdown files, this requires specifying --base-url
#[clap(requires = "base_url")]
#[arg(long)]
#[serde(default)]
pub(crate) include_wikilinks: bool,
Expand Down
70 changes: 70 additions & 0 deletions lychee-bin/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2586,6 +2586,8 @@ The config file should contain every possible key for documentation purposes."
cargo_bin_cmd!()
.arg("--dump")
.arg("--include-wikilinks")
.arg("--base-url")
.arg(fixtures_path!())
.arg(test_path)
.assert()
.success()
Expand Down Expand Up @@ -3046,6 +3048,74 @@ The config file should contain every possible key for documentation purposes."
.stdout(contains("https://example.org")); // Should extract the link as plaintext
}

#[test]
fn test_wikilink_fixture_obsidian_style() {
let input = fixtures_path!().join("wiki/obsidian-style.md");

// testing without fragments should not yield failures
cargo_bin_cmd!()
.arg(&input)
.arg("--include-wikilinks")
.arg("--fallback-extensions")
.arg("md")
.arg("--base-url")
.arg(fixtures_path!())
.assert()
.success()
.stdout(contains("4 OK"));
}

#[test]
fn test_wikilink_fixture_wikilink_non_existent() {
let input = fixtures_path!().join("wiki/Non-existent.md");

cargo_bin_cmd!()
.arg(&input)
.arg("--include-wikilinks")
.arg("--fallback-extensions")
.arg("md")
.arg("--base-url")
.arg(fixtures_path!())
.assert()
.failure()
.stdout(contains("3 Errors"));
}

#[test]
fn test_wikilink_fixture_with_fragments_obsidian_style_fixtures_excluded() {
let input = fixtures_path!().join("wiki/obsidian-style-plus-headers.md");

// fragments should resolve all headers
cargo_bin_cmd!()
.arg(&input)
.arg("--include-wikilinks")
.arg("--fallback-extensions")
.arg("md")
.arg("--base-url")
.arg(fixtures_path!())
.assert()
.success()
.stdout(contains("4 OK"));
}

#[test]
fn test_wikilink_fixture_with_fragments_obsidian_style() {
let input = fixtures_path!().join("wiki/obsidian-style-plus-headers.md");

// fragments should resolve all headers
cargo_bin_cmd!()
.arg(&input)
.arg("--include-wikilinks")
.arg("--include-fragments")
.arg("--fallback-extensions")
.arg("md")
.arg("--base-url")
.arg(fixtures_path!())
.assert()
.success()
.stdout(contains("4 OK"));
}

/// An input which matches nothing should print a warning and continue.
#[test]
fn test_input_matching_nothing_warns() -> Result<()> {
Expand Down
1 change: 1 addition & 0 deletions lychee-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ tokio = { version = "1.48.0", features = ["full"] }
toml = "0.9.10"
typed-builder = "0.23.2"
url = { version = "2.5.7", features = ["serde"] }
walkdir = "2.5.0"

[dependencies.par-stream]
version = "0.10.2"
Expand Down
76 changes: 54 additions & 22 deletions lychee-lib/src/checker/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ use log::warn;
use std::borrow::Cow;
use std::path::{Path, PathBuf};

use crate::checker::wikilink::resolver::WikilinkResolver;
use crate::{
Base, ErrorKind, Status, Uri,
Base, ErrorKind, Result, Status, Uri,
utils::fragment_checker::{FragmentChecker, FragmentInput},
};

Expand Down Expand Up @@ -34,6 +35,8 @@ pub(crate) struct FileChecker {
include_fragments: bool,
/// Utility for performing fragment checks in HTML files.
fragment_checker: FragmentChecker,
/// Utility for optionally resolving Wikilinks.
wikilink_resolver: Option<WikilinkResolver>,
}

impl FileChecker {
Expand All @@ -45,19 +48,35 @@ impl FileChecker {
/// * `fallback_extensions` - List of extensions to try if the original file is not found.
/// * `index_files` - Optional list of index file names to search for if the path is a directory.
/// * `include_fragments` - Whether to check for fragment existence in HTML files.
/// * `include_wikilinks` - Whether to check the existence of Wikilinks found in Markdown files .
///
/// # Errors
///
/// Fails if an invalid `base` is provided when including wikilinks.
pub(crate) fn new(
base: Option<Base>,
fallback_extensions: Vec<String>,
index_files: Option<Vec<String>>,
include_fragments: bool,
) -> Self {
Self {
include_wikilinks: bool,
) -> Result<Self> {
let wikilink_resolver = if include_wikilinks {
Some(WikilinkResolver::new(
base.as_ref(),
fallback_extensions.clone(),
)?)
} else {
None
};

Ok(Self {
base,
fallback_extensions,
index_files,
include_fragments,
fragment_checker: FragmentChecker::new(),
}
wikilink_resolver,
})
}

/// Checks the given file URI for existence and validity.
Expand Down Expand Up @@ -127,16 +146,20 @@ impl FileChecker {
/// Returns `Ok` with the resolved path if it is valid, otherwise returns
/// `Err` with an appropriate error. The returned path, if any, is guaranteed
/// to exist and may be a file or a directory.
fn resolve_local_path<'a>(
&self,
path: &'a Path,
uri: &Uri,
) -> Result<Cow<'a, Path>, ErrorKind> {
fn resolve_local_path<'a>(&self, path: &'a Path, uri: &Uri) -> Result<Cow<'a, Path>> {
let path = match path.metadata() {
// for non-existing paths, attempt fallback extensions
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
self.apply_fallback_extensions(path, uri).map(Cow::Owned)
}
// if fallback extensions don't help, try wikilinks
Err(e) if e.kind() == std::io::ErrorKind::NotFound => self
.apply_fallback_extensions(path, uri)
.or_else(|_| {
if let Some(resolver) = &self.wikilink_resolver {
resolver.resolve(path, uri)
} else {
Err(ErrorKind::InvalidFilePath(uri.clone()))
}
})
.map(Cow::Owned),

// other IO errors are unexpected and should fail the check
Err(e) => Err(ErrorKind::ReadFileInput(e, path.to_path_buf())),
Expand Down Expand Up @@ -181,7 +204,7 @@ impl FileChecker {
///
/// Returns `Ok(PathBuf)` with the resolved file path, or `Err` if no valid file is found.
/// If `Ok` is returned, the contained `PathBuf` is guaranteed to exist and be a file.
fn apply_fallback_extensions(&self, path: &Path, uri: &Uri) -> Result<PathBuf, ErrorKind> {
fn apply_fallback_extensions(&self, path: &Path, uri: &Uri) -> Result<PathBuf> {
// If it's already a file, use it directly
if path.is_file() {
return Ok(path.to_path_buf());
Expand Down Expand Up @@ -221,7 +244,7 @@ impl FileChecker {
/// is guaranteed to exist. In most cases, the returned path will be a file path.
///
/// If index files are disabled, simply returns `Ok(dir_path)`.
fn apply_index_files(&self, dir_path: &Path) -> Result<PathBuf, ErrorKind> {
fn apply_index_files(&self, dir_path: &Path) -> Result<PathBuf> {
// this implements the "disabled" case by treating a directory as its
// own index file.
let index_names_to_try = match &self.index_files {
Expand Down Expand Up @@ -372,7 +395,7 @@ mod tests {
#[tokio::test]
async fn test_default() {
// default behaviour accepts dir links as long as the directory exists.
let checker = FileChecker::new(None, vec![], None, true);
let checker = FileChecker::new(None, vec![], None, true, false).unwrap();

assert_filecheck!(&checker, "filechecker/index_dir", Status::Ok(_));

Expand Down Expand Up @@ -430,7 +453,9 @@ mod tests {
vec![],
Some(vec!["index.html".to_owned(), "index.md".to_owned()]),
true,
);
false,
)
.unwrap();

assert_resolves!(
&checker,
Expand Down Expand Up @@ -468,7 +493,9 @@ mod tests {
vec!["html".to_owned()],
Some(vec!["index".to_owned()]),
false,
);
false,
)
.unwrap();

// this test case has a subdir 'same_name' and a file 'same_name.html'.
// this shows that the index file resolving is applied in this case and
Expand All @@ -492,7 +519,8 @@ mod tests {
#[tokio::test]
async fn test_empty_index_list_corner() {
// empty index_files list will reject all directory links
let checker_no_indexes = FileChecker::new(None, vec![], Some(vec![]), false);
let checker_no_indexes =
FileChecker::new(None, vec![], Some(vec![]), false, false).unwrap();
assert_resolves!(
&checker_no_indexes,
"filechecker/index_dir",
Expand All @@ -516,7 +544,8 @@ mod tests {
"..".to_owned(),
"/".to_owned(),
];
let checker_dir_indexes = FileChecker::new(None, vec![], Some(dir_names), false);
let checker_dir_indexes =
FileChecker::new(None, vec![], Some(dir_names), false, false).unwrap();
assert_resolves!(
&checker_dir_indexes,
"filechecker/index_dir",
Expand All @@ -537,7 +566,9 @@ mod tests {
vec![],
Some(vec!["../index_dir/index.html".to_owned()]),
true,
);
false,
)
.unwrap();
assert_resolves!(
&checker_dotdot,
"filechecker/empty_dir#fragment",
Expand All @@ -550,7 +581,8 @@ mod tests {
.to_str()
.expect("expected utf-8 fixtures path")
.to_owned();
let checker_absolute = FileChecker::new(None, vec![], Some(vec![absolute_html]), true);
let checker_absolute =
FileChecker::new(None, vec![], Some(vec![absolute_html]), true, false).unwrap();
assert_resolves!(
&checker_absolute,
"filechecker/empty_dir#fragment",
Expand All @@ -560,7 +592,7 @@ mod tests {

#[tokio::test]
async fn test_fallback_extensions_on_directories() {
let checker = FileChecker::new(None, vec!["html".to_owned()], None, true);
let checker = FileChecker::new(None, vec!["html".to_owned()], None, true, false).unwrap();

// fallback extensions should be applied when directory links are resolved
// to directories (i.e., the default index_files behavior or if `.`
Expand Down
1 change: 1 addition & 0 deletions lychee-lib/src/checker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
pub(crate) mod file;
pub(crate) mod mail;
pub(crate) mod website;
pub(crate) mod wikilink;
Loading