Skip to content

Commit 6237384

Browse files
committed
fix(md018): recognize tags that start with a digit
Obsidian's rule is that a tag must contain at least one non-numerical character, wherever it sits: #1984 is not a tag, #y1984 and #3d_printing are. The tag pattern instead required the character right after # to be a non-digit, so every digit-leading tag was read as a malformed heading. Under the Obsidian flavor, where tags default to on, this was not just a spurious warning: rumdl fmt rewrote #3d_printing into a heading, and MD025 then demoted the duplicate H1, so a tag became "## 3d_printing". A leading digit run is now allowed when a letter, combining mark, emoji, underscore, hyphen or slash follows it. Punctuation deliberately does not count, so #37. and #42, stay issue references rather than becoming tags. The change only adds exemptions, so nothing exempt before is now flagged. Two tests asserted the old behavior, listing #1tag and #2023-project as invalid tags, and the sibling loop over valid tags discarded its result without asserting. Both are corrected. Closes #799
1 parent 8b89584 commit 6237384

4 files changed

Lines changed: 135 additions & 25 deletions

File tree

docs/md018.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ This rule correctly handles:
107107

108108
When `tags = true`, this rule skips `#word` patterns that look like tags (e.g., `#todo`, `#project/active`) instead of treating them as malformed headings.
109109

110-
Tags are recognized when they start with `#` followed by a non-digit, non-space character. Multi-hash patterns like `##tag` are always treated as malformed headings, and `#123` (starting with a digit) is not a valid tag.
110+
Tags are recognized following [Obsidian's tag rules](https://obsidian.md/help/tags): a tag must contain at least one non-numerical character, so `#1984` is not a tag but `#y1984` and `#3d_printing` are. Multi-hash patterns like `##tag` are always treated as malformed headings.
111+
112+
A leading run of digits is allowed as long as a letter, an underscore, a hyphen, a forward slash or an emoji follows it. Digits followed by other punctuation stay flagged, because `#37.` and `#42,` are issue references rather than tags.
111113

112114
When `tags` is not explicitly set, it defaults to `true` for Obsidian flavor and `false` otherwise. This means Obsidian users get tag support automatically, while users of other flavors can opt in:
113115

@@ -127,16 +129,18 @@ tags = true
127129

128130
#project/active nested tag
129131

132+
#3d_printing tag starting with a digit
133+
130134
##Introduction
131135
```
132136

133137
<!-- rumdl-enable MD018 MD022 MD025 -->
134138

135139
With `tags = true`:
136140

137-
- `#todo` and `#project/active` are **not flagged** (recognized as tags)
141+
- `#todo`, `#project/active` and `#3d_printing` are **not flagged** (recognized as tags)
138142
- `##Introduction` is **flagged** (multi-hash, clearly a malformed heading)
139-
- `#123` is **flagged** (tags cannot start with digits)
143+
- `#1984` is **flagged** (all-numeric, so not a valid tag)
140144

141145
## Automatic fixes
142146

src/rules/md018_no_missing_space_atx.rs

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ static UNICODE_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(UN
2323
const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
2424
static MAGICLINK_REF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(MAGICLINK_REF_PATTERN_STR).unwrap());
2525

26-
// Tag pattern: #tagname, #project/active, #my-tag_2023, etc.
27-
// Tags start with # followed by a non-digit, non-space character,
28-
// then any combination of word characters, hyphens, underscores, and slashes.
29-
// Tags cannot start with a number.
30-
const TAG_PATTERN_STR: &str = r"^#[^\d\s#][^\s#]*(?:\s|$)";
26+
// Tag pattern: #tagname, #project/active, #my-tag_2023, #3d_printing, etc.
27+
// A tag must contain at least one non-numerical character, wherever it sits:
28+
// `#1984` is not a tag, `#y1984` and `#3d_printing` are. A leading run of digits
29+
// is therefore allowed as long as a non-numerical tag character follows it.
30+
// That character may be a letter, a combining mark, an emoji or one of the three
31+
// punctuation characters Obsidian lists (`_`, `-`, `/`), but not punctuation in
32+
// general: `#37.` and `#42,` are issue references, not tags.
33+
const TAG_PATTERN_STR: &str = r"^#(?:[^\d\s#]|\d+[\p{L}\p{M}\p{So}_/-])[^\s#]*(?:\s|$)";
3134
static TAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(TAG_PATTERN_STR).unwrap());
3235

3336
#[derive(Clone)]
@@ -58,7 +61,7 @@ impl MD018NoMissingSpaceAtx {
5861
MAGICLINK_REF_PATTERN.is_match(line.trim_start())
5962
}
6063

61-
/// Check if a line is a tag (e.g., #tagname, #project/active)
64+
/// Check if a line is a tag (e.g., #tagname, #project/active, #3d_printing)
6265
fn is_tag(line: &str) -> bool {
6366
TAG_PATTERN.is_match(line.trim_start())
6467
}
@@ -1425,12 +1428,13 @@ More content.
14251428

14261429
#[test]
14271430
fn test_obsidian_tag_numeric_still_flagged() {
1428-
// Tags cannot start with a number in Obsidian, so #123 should still be flagged
1431+
// An Obsidian tag needs at least one non-numerical character, so an
1432+
// all-numeric #123 is not a tag and should still be flagged
14291433
let rule = MD018NoMissingSpaceAtx::new();
14301434

14311435
assert!(
14321436
rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1433-
"#123 should be flagged in Obsidian flavor (tags cannot start with digit)"
1437+
"#123 should be flagged in Obsidian flavor (no non-numerical character)"
14341438
);
14351439
assert!(
14361440
rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
@@ -1541,7 +1545,7 @@ More content.
15411545

15421546
// Valid Obsidian tags - should be SKIPPED
15431547
let valid_tags = [
1544-
"#a", // Minimum valid tag (but note: may be skipped due to length < 2)
1548+
"#a", // Minimum valid tag (also under the content length < 2 rule)
15451549
"#tag", // Simple tag
15461550
"#Tag", // Capitalized tag
15471551
"#TAG", // Uppercase tag
@@ -1554,20 +1558,20 @@ More content.
15541558
];
15551559

15561560
for tag in valid_tags {
1557-
// Note: #a and #a1 might be skipped due to content length < 2 rule
1558-
let result = rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian);
1559-
// We don't assert is_none because some might be skipped by other rules
1560-
// Just verify the pattern doesn't cause errors
1561-
let _ = result;
1561+
assert!(
1562+
rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1563+
"{tag:?} should be skipped in Obsidian flavor (valid tag)"
1564+
);
15621565
}
15631566

1564-
// Invalid tags (start with digit) - should be FLAGGED
1565-
let invalid_tags = ["#1tag", "#123", "#2023-project"];
1567+
// Not tags - should be FLAGGED. Every one of these is all-numeric up to
1568+
// the first character Obsidian would not accept in a tag.
1569+
let invalid_tags = ["#123", "#1984", "#37.", "#42,"];
15661570

15671571
for tag in invalid_tags {
15681572
assert!(
15691573
rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1570-
"{tag:?} should be flagged in Obsidian flavor (starts with digit)"
1574+
"{tag:?} should be flagged in Obsidian flavor (no non-numerical character)"
15711575
);
15721576
}
15731577
}
@@ -1714,4 +1718,104 @@ More content.
17141718
"#TODO should be skipped in Obsidian flavor"
17151719
);
17161720
}
1721+
1722+
#[test]
1723+
fn test_obsidian_tag_may_start_with_a_digit() {
1724+
// Obsidian requires at least one non-numerical character anywhere in the
1725+
// tag, not a non-numerical FIRST character.
1726+
let rule = MD018NoMissingSpaceAtx::new();
1727+
1728+
let tags = [
1729+
"#3d_printing", // the reported case
1730+
"#1tag", // digit then letters
1731+
"#2023-project", // digit run then a hyphen
1732+
"#100DaysOfCode", // digits then mixed case
1733+
"#1on1", // digits on both sides of letters
1734+
"#5S", // single digit, single letter
1735+
"#3/4", // digit run then a nested-tag separator
1736+
"#1_2", // digit run then an underscore
1737+
"#3🔥", // digit run then an emoji
1738+
"#3\u{FE0F}\u{20E3}", // keycap: digit, variation selector, enclosing mark
1739+
];
1740+
1741+
for tag in tags {
1742+
assert!(
1743+
rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1744+
"{tag:?} contains a non-numerical character and should be skipped as a tag"
1745+
);
1746+
}
1747+
}
1748+
1749+
#[test]
1750+
fn test_numeric_references_are_not_tags() {
1751+
// The counterpart of the test above: allowing a leading digit run must
1752+
// not turn an issue reference or a numeric heading into a tag. Without
1753+
// these, widening the pattern to any non-digit would pass unnoticed.
1754+
let rule = MD018NoMissingSpaceAtx::new();
1755+
1756+
let not_tags = [
1757+
"#1984", // all-numeric, Obsidian's own example
1758+
"#123", // all-numeric
1759+
"#10", // issue reference
1760+
"#37.", // issue reference ending a sentence
1761+
"#42,", // issue reference in a list
1762+
"#42)", // issue reference in parentheses
1763+
"#404 Not Found", // numeric heading
1764+
"#10 discusses the issue", // issue reference opening a line
1765+
];
1766+
1767+
for line in not_tags {
1768+
assert!(
1769+
rule.check_atx_heading_line(line, MarkdownFlavor::Obsidian).is_some(),
1770+
"{line:?} has no non-numerical tag character and should stay flagged"
1771+
);
1772+
}
1773+
}
1774+
1775+
#[test]
1776+
fn test_digit_leading_tag_survives_check_and_fix() {
1777+
// End-to-end through the two paths a user actually hits: the Obsidian
1778+
// flavor default, and `tags = true` on Standard flavor as reported.
1779+
let obsidian = MD018NoMissingSpaceAtx::new();
1780+
let standard = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1781+
magiclink: false,
1782+
tags: Some(true),
1783+
});
1784+
1785+
// ##Heading is the positive control: it must still be flagged and fixed,
1786+
// otherwise an untouched tag proves nothing about the fix having run.
1787+
let content = "# Header\n\n#3d_printing\n\n##Heading\n";
1788+
1789+
for (rule, flavor, label) in [
1790+
(&obsidian, MarkdownFlavor::Obsidian, "obsidian flavor"),
1791+
(&standard, MarkdownFlavor::Standard, "tags = true"),
1792+
] {
1793+
let ctx = LintContext::new(content, flavor, None);
1794+
let flagged: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1795+
assert_eq!(
1796+
flagged,
1797+
vec![5],
1798+
"{label}: only ##Heading should be flagged, got {flagged:?}"
1799+
);
1800+
1801+
let fixed = rule.fix(&ctx).unwrap();
1802+
assert_eq!(
1803+
fixed, "# Header\n\n#3d_printing\n\n## Heading\n",
1804+
"{label}: fix must leave the tag alone and still fix the heading"
1805+
);
1806+
}
1807+
}
1808+
1809+
#[test]
1810+
fn test_digit_leading_tag_is_still_flagged_without_tags_mode() {
1811+
// Standard flavor without the option keeps treating it as a malformed
1812+
// heading, so the fix does not leak tag awareness into other flavors.
1813+
let rule = MD018NoMissingSpaceAtx::new();
1814+
1815+
assert!(
1816+
rule.check_atx_heading_line("#3d_printing", MarkdownFlavor::Standard)
1817+
.is_some(),
1818+
"#3d_printing should be flagged in Standard flavor (tags disabled)"
1819+
);
1820+
}
17171821
}

src/rules/md018_no_missing_space_atx/md018_config.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ pub struct MD018Config {
1313
pub magiclink: bool,
1414

1515
/// Recognize `#word` patterns as tags instead of malformed headings.
16-
/// When true, single-hash patterns like `#tag`, `#project/active` are
17-
/// skipped. When null/unset, defaults to true for Obsidian flavor
18-
/// and false otherwise.
16+
/// When true, single-hash patterns like `#tag`, `#project/active` and
17+
/// `#3d_printing` are skipped. Following Obsidian, a tag must contain at
18+
/// least one non-numerical character, so `#1984` is still flagged.
19+
/// When null/unset, defaults to true for Obsidian flavor and false
20+
/// otherwise.
1921
#[serde(default)]
2022
pub tags: Option<bool>,
2123
}

tests/cli/cli_flavor_test.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -545,8 +545,8 @@ tags = true
545545

546546
let fixed_content = fs::read_to_string(&md_path).expect("Should read fixed file");
547547

548-
// Both #todo and #Summary match the tag pattern (# + non-digit non-space),
549-
// so neither should be modified
548+
// Both #todo and #Summary are valid tags (they contain a non-numerical
549+
// character), so neither should be modified
550550
assert!(
551551
fixed_content.contains("#todo"),
552552
"#todo should be preserved with tags=true. Fixed content: {fixed_content}"

0 commit comments

Comments
 (0)