Skip to content
Open
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
16 changes: 8 additions & 8 deletions src/model/writing-session-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,26 @@ const WORD_COUNT_REGEX = (() => {
.source;
return new RegExp(
[
"(?:[0-9]+(?:(?:,|\\.)[0-9]+)*|[\\-" + spaceDelimitedChars + "])+",
"(?:[0-9]+(?:(?:,|\\.)[0-9]+)*|[\\-'" + spaceDelimitedChars + "])+",
nonSpaceDelimitedWords,
].join("|"),
"g"
);
})();

function countWords(
export function countWords(
text: string,
removeMarkdown = true,
removeComments = true
): number {
let textToCount = text;

if (removeComments) {
textToCount = textToCount
.replace(/<!--.*?-->/gs, "") // HTML comments
.replace(/%%.*?%%/gs, ""); // Markdown comments
}

if (removeMarkdown) {
textToCount = textToCount
.replace(/`\$?=[^`]+`/g, "") // inline dataview
Expand All @@ -52,12 +58,6 @@ function countWords(
.replace(/\*|_|\[\[|\]\]|\||==|~~|---|#|> |`/g, ""); // Markdown Syntax
}

if (removeComments) {
textToCount = textToCount
.replace(/<!--.*?-->/gs, "") // HTML comments
.replace(/%%.*?%%/gs, ""); // Markdown comments
}

return (textToCount.match(WORD_COUNT_REGEX) || []).length;
}

Expand Down
18 changes: 18 additions & 0 deletions test/__mocks__/obsidian.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const debounce = <T extends (...args: unknown[]) => unknown>(fn: T, _wait: number, _immediate?: boolean): T => {
return fn;
};

export abstract class TAbstractFile {
path: string = "";
vault: unknown = null;
}

export class TFile extends TAbstractFile {
stat = { ctime: 0, mtime: 0, size: 0 };
basename = "";
extension = "";
name = "";
parent = null;
}

export class Vault {}
130 changes: 130 additions & 0 deletions test/model/writing-session-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { countWords } from "src/model/writing-session-tracker";

describe("countWords", () => {
it("counts basic space-delimited words", () => {
expect(countWords("Hello world")).toBe(2);
expect(countWords("one two three four five")).toBe(5);
expect(countWords("a")).toBe(1);
});

it("handles multiple spaces", () => {
expect(countWords("Hello world")).toBe(2);
expect(countWords(" leading")).toBe(1);
expect(countWords("trailing ")).toBe(1);
});

it("handles newlines", () => {
expect(countWords("Hello\nworld")).toBe(2);
expect(countWords("line1\nline2\nline3")).toBe(3);
});

it("handles empty string", () => {
expect(countWords("")).toBe(0);
});

it("counts numbers as words", () => {
expect(countWords("12345")).toBe(1);
expect(countWords("hello 123 world")).toBe(3);
});

it("counts comma-separated numbers as one word", () => {
expect(countWords("1,000")).toBe(1);
expect(countWords("1,000,000")).toBe(1);
});

it("counts decimal numbers as one word", () => {
expect(countWords("1,000.50")).toBe(1);
expect(countWords("3.14")).toBe(1);
});

it("strips bold markers", () => {
expect(countWords("**bold**")).toBe(1);
expect(countWords("**bold** and **more**")).toBe(3);
});

it("strips italic markers", () => {
expect(countWords("*italic*")).toBe(1);
expect(countWords("_italic_")).toBe(1);
});

it("strips wiki links", () => {
expect(countWords("[[link]]")).toBe(1);
expect(countWords("[[File|Alias]]")).toBe(1);
});

it("strips external links", () => {
expect(countWords("[text](url)")).toBe(1);
});

it("strips heading markers", () => {
expect(countWords("# Header")).toBe(1);
expect(countWords("## Subheader")).toBe(1);
});

it("removes YAML frontmatter", () => {
expect(countWords("---\ntitle: x\n---\n\nBody text")).toBe(2);
});

it("strips HTML comments", () => {
expect(countWords("Hello <!-- comment --> world")).toBe(2);
});

it("strips markdown comments", () => {
expect(countWords("Hello %% comment %% world")).toBe(2);
});

it("counts hyphenated words as one word", () => {
expect(countWords("well-known")).toBe(1);
expect(countWords("up-to-date")).toBe(1);
});

it("counts CJK characters individually", () => {
expect(countWords("日本語")).toBe(3);
expect(countWords("東京")).toBe(2);
});

it("handles markdown code inlines", () => {
// NB: backticks are stripped by the markdown regex, so `code`
// becomes `code` and counts as a word. Existing behavior.
expect(countWords("text `code` text")).toBe(3);
});

it("strips inline dataview", () => {
expect(countWords("text `=this.field` text")).toBe(2);
});

it("handles real-world paragraph text", () => {
const text = "The quick brown fox jumps over the lazy dog.";
expect(countWords(text)).toBe(9);
});

describe("flag combinations with mixed content", () => {
const mixed = "Hello <!-- html --> **bold** %% md %% world";

it("strips comments and markdown when both flags active", () => {
expect(countWords(mixed, true, true), "comments removed first, then ** stripped — clean word count").toBe(3);
});

it("strips only markdown, preserves %% comments", () => {
expect(countWords(mixed, true, false), "md strips ** and > but %% is left intact so it acts as word-delimiter for md").toBe(6);
});

it("strips only comments, preserves markdown syntax", () => {
expect(countWords(mixed, false, true), "* is not in the word character class, so **bold** becomes bold").toBe(3);
});

it("preserves all syntax when processing disabled", () => {
expect(countWords(mixed, false, false), "raw text — all HTML, markdown, and %% tokens counted as-is").toBe(7);
});
});

it("counts contractions with apostrophes as a single word", () => {
expect.soft(countWords("Can't"), '"Can\'t" should be 1 word').toBe(1);
expect.soft(countWords("don't"), '"don\'t" should be 1 word').toBe(1);
expect.soft(countWords("it's"), '"it\'s" should be 1 word').toBe(1);
expect.soft(countWords("won't"), '"won\'t" should be 1 word').toBe(1);
expect.soft(countWords("o'clock"), '"o\'clock" should be 1 word').toBe(1);
expect.soft(countWords("won't don't can't"), 'phrase with 3 contractions should be 3 words').toBe(3);
});
});
6 changes: 6 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { defineConfig } from "vitest/config";
import path from "path";

export default defineConfig({
resolve: {
alias: {
obsidian: path.resolve(__dirname, "test/__mocks__/obsidian.ts"),
},
},
test: {
coverage: {
reporter: ["text", "html"],
Expand Down
Loading