Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ While the major version is 0, minor version bumps may contain breaking changes.

### Fixed

- `W_INDEX_ORPHAN` no longer fires for files the root index does reference. A
link destination ended at the last `)` on the line, so an entry annotated
with a parenthetical — `- [Alpha](tasks/alpha.md) (superseded)` — recorded a
path matching no file, and only the first link on a line was read at all, so
two links on one line reported both files as orphans. Destinations now end at
the parenthesis that closes their own link, parentheses inside a path are
balanced, angle-bracketed and titled destinations are understood, and every
link on a line is collected (#60).

- `.lashignore` is reachable from where users hit it. The `W_INDEX_ORPHAN`
warning names it in the message text, `lash lint --help` and `lash --help`
describe file discovery, and the README, user guide and error-code reference
Expand Down
95 changes: 95 additions & 0 deletions crates/lash-cli/tests/lint_output_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1673,3 +1673,98 @@ fn test_mut000491_494_exact_severity_counts_warning_file() {
"hints must be 0 (mut-000494); summary={summary}"
);
}

// ---------------------------------------------------------------------------
// W_INDEX_ORPHAN: index entries annotated with parentheses (GitHub issue #60)
//
// The index link parser used to end a destination at the last `)` on the line,
// so `- [Alpha](tasks/alpha.md) (note)` recorded a path no file matched and the
// linked file was reported as an orphan. Two links on one line broke both.
// ---------------------------------------------------------------------------

/// Run `lash --json lint <dir>` and return the codes of every diagnostic.
fn lint_json_codes(path: &std::path::Path) -> Vec<String> {
let output = lash()
.arg("--json")
.arg("lint")
.arg(path)
.output()
.expect("lash must run");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!("stdout was not valid JSON: {e}\nstdout={stdout}");
});
json["diagnostics"]
.as_array()
.map(|diags| {
diags
.iter()
.filter_map(|d| d["code"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}

#[test]
fn test_annotated_index_links_are_not_orphans() {
let td = TempDir::new().unwrap();
fs::create_dir(td.path().join("tasks")).unwrap();

write_md(
&td,
"lash.index.md",
"# Demo Project\n\
\n\
@id: index\n\
\n\
## Epic Task Files\n\
\n\
- [Alpha](tasks/alpha.md) (trailing parenthetical)\n\
- [Beta](tasks/beta.md) — trailing em dash\n\
- [Gamma](tasks/gamma.md) plain trailing words\n\
- [Delta](tasks/delta.md) mentions (a parenthetical) mid-sentence\n\
- [Epsilon](tasks/epsilon.md)(immediately adjacent)\n\
- [Zeta](tasks/zeta.md) and [Eta](tasks/eta.md) on one line\n",
);

for name in ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"] {
write_md(
&td,
&format!("tasks/{name}.md"),
&format!("# {name}\n\n@id: {name}\n@created: 2024-01-15\n\n## Tasks\n\n- [ ] A task\n"),
);
}

let codes = lint_json_codes(td.path());
assert!(
!codes.iter().any(|code| code == "W_INDEX_ORPHAN"),
"every task file is linked from the index; got diagnostics: {codes:?}"
);
}

#[test]
fn test_unreferenced_file_is_still_an_orphan() {
let td = TempDir::new().unwrap();
fs::create_dir(td.path().join("tasks")).unwrap();

write_md(
&td,
"lash.index.md",
"# Demo Project\n\n@id: index\n\n## Epic Task Files\n\n\
- [Alpha](tasks/alpha.md) (trailing parenthetical)\n",
);
for name in ["alpha", "stray"] {
write_md(
&td,
&format!("tasks/{name}.md"),
&format!("# {name}\n\n@id: {name}\n@created: 2024-01-15\n\n## Tasks\n\n- [ ] A task\n"),
);
}

let codes = lint_json_codes(td.path());
assert_eq!(
codes.iter().filter(|c| *c == "W_INDEX_ORPHAN").count(),
1,
"only tasks/stray.md is unreferenced; got diagnostics: {codes:?}"
);
}
132 changes: 120 additions & 12 deletions crates/lash-core/src/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,83 @@ use std::path::Path;
/// ```
#[must_use]
pub fn extract_link_path(text: &str) -> Option<String> {
// Try to match [link text](path) pattern
let open_bracket = text.find('[')?;
let close_bracket = text.find("](")?;
extract_link_paths(text).into_iter().next()
}

/// Extract every Markdown link destination on a line, in order
///
/// Unlike [`extract_link_path`], this collects all links rather than just the
/// first, and each destination ends at the parenthesis that closes *its* link
/// rather than at the last parenthesis on the line. Parentheses nested inside a
/// destination are balanced; angle-bracketed destinations (`[a](<b c.md>)`) are
/// unwrapped.
///
/// # Examples
///
/// ```
/// use lash_core::display::extract_link_paths;
///
/// assert_eq!(
/// extract_link_paths("[A](a.md) and [B](b.md) (see also)"),
/// vec!["a.md".to_string(), "b.md".to_string()]
/// );
/// assert_eq!(extract_link_paths("Plain text"), Vec::<String>::new());
/// ```
#[must_use]
pub fn extract_link_paths(text: &str) -> Vec<String> {
let mut paths = Vec::new();
let mut cursor = 0;

if open_bracket >= close_bracket {
return None;
while let Some(offset) = text[cursor..].find("](") {
let close_bracket = cursor + offset;
let dest_start = close_bracket + 2; // Skip "]("

// A destination only counts as a link if some `[` opens it.
if !text[cursor..close_bracket].contains('[') {
cursor = dest_start;
continue;
}

let Some(dest_end) = find_dest_end(text, dest_start) else {
break;
};

let dest = text[dest_start..dest_end].trim();
let dest = dest
.strip_prefix('<')
.and_then(|inner| inner.strip_suffix('>'))
.unwrap_or(dest);
if !dest.is_empty() {
paths.push(dest.to_string());
}

cursor = dest_end + 1; // Skip the closing ')'
}

let path_start = close_bracket + 2; // Skip "]("
let close_paren = text[path_start..].find(')')?;
let path = &text[path_start..path_start + close_paren];
paths
}

/// Find the parenthesis that closes a link destination starting at `start`
///
/// Parentheses inside the destination are balanced, so `[a](f(1).md)` ends at
/// the final `)` rather than the one after `1`.
fn find_dest_end(text: &str, start: usize) -> Option<usize> {
let mut depth = 0usize;

if path.is_empty() {
None
} else {
Some(path.to_string())
for (offset, ch) in text[start..].char_indices() {
match ch {
'(' => depth += 1,
')' => {
if depth == 0 {
return Some(start + offset);
}
depth -= 1;
}
_ => {}
}
}

None
}

/// Extract link text from Markdown links
Expand Down Expand Up @@ -225,6 +285,54 @@ mod tests {
assert_eq!(extract_link_text("`path/file.md`"), "`path/file.md`");
}

#[test]
fn test_extract_link_paths() {
// Every link on the line is collected, in order
assert_eq!(
extract_link_paths("[A](a.md) and [B](b.md)"),
vec!["a.md".to_string(), "b.md".to_string()]
);

// A later ')' does not extend the destination (GitHub issue #60)
assert_eq!(
extract_link_paths("[A](a.md) (see also)"),
vec!["a.md".to_string()]
);

// Parentheses inside the destination are balanced
assert_eq!(
extract_link_paths("[Copy](a(1).md)"),
vec!["a(1).md".to_string()]
);

// Angle brackets are unwrapped
assert_eq!(
extract_link_paths("[A](<my file.md>)"),
vec!["my file.md".to_string()]
);

// Empty destinations and non-links are skipped
assert!(extract_link_paths("[A]()").is_empty());
assert!(extract_link_paths("Plain text").is_empty());
assert!(extract_link_paths("not a link](a.md)").is_empty());

// Unterminated destination
assert!(extract_link_paths("[A](a.md").is_empty());
}

#[test]
fn test_extract_link_path_first_link_only() {
assert_eq!(
extract_link_path("[A](a.md) and [B](b.md)"),
Some("a.md".to_string())
);
assert_eq!(
extract_link_path("[A](a.md) (see also)"),
Some("a.md".to_string())
);
assert_eq!(extract_link_path("Plain text"), None);
}

#[test]
fn test_format_index_annotations() {
// Full annotation: strip @id and convert @labels to hashtags
Expand Down
Loading
Loading