feat: Add syntax highlighting for completion and creation dates, refine parser,tests

This commit is contained in:
Riosk It 2025-06-25 22:21:54 +09:00
parent cf5557e135
commit 0fa34ea3fe
9 changed files with 2284 additions and 208 deletions

68
no-todo.txt Normal file
View file

@ -0,0 +1,68 @@
Empty priority () should not parse
Mixed case priority (a) should not parse as priority
Lowercase x in middle of task x should not be completion
X Uppercase X at start should not be completion
x(A) No space after x should not parse correctly
x2025-01-16 No space after x before date
(A)No space after priority should still work
2025-01-15No space after date might not parse
+projectNoSpace@contextNoSpace tags without spaces
Key without value due: should not parse
Value without key :2025-12-31 should not parse
(-1) Negative priority should not parse as priority
Task with invalid date 2025-13-32
Task with partial date 2025-01
due:tomorrow Invalid date format
due:2025/12/31 Different date separator
( A ) Priority with spaces inside parentheses
++invalid double plus project
@@invalid double at context
+val@id project with @ in name
@val+id context with + in name
+pro ject with space should be two tokens
@con text with space should be two tokens
+ project with space after plus
@ context with space after at
Task with +project+project2 no space between
Task with @context@context2 no space between
Task with due:2025-12-31due:2025-12-30 duplicate keys
Task with +@ mixed symbols
Task with @+ reversed symbols
Task with ++double +plus
Task with @@double @at
Task with ++ @@ multiple symbol prefixes
(A)(B) Double priority task
x x Double completion marker
2025-01-15 2025-01-15 Duplicate creation date
Task with : colon but no key:value
Task with key: no value after colon
Task with :value no key before colon
Task with + bare plus
Task with @ bare at
Task with ending plus +
Task with ending at @
Task with \n newline escape
Task with \t tab escape
Task with \r carriage return
Task with null\0character
Task with unicode escape \u0041 in text
Task with +project\n@context broken by newline
Multiline attempt line1\nline2 +project
Long priority (URGENT) 2025-01-15 Task +project @context
x (A) 2025-01-16 Priority completed with date +project
x (A) 2025-01-16 Priority completed with date @context
x (A) 2025-01-16 Priority completed with date due:2025-12-31
x (A) 2025-01-16 2025-01-15 Priority completed both dates +project
x (A) 2025-01-16 2025-01-15 Priority completed both dates @context
x (A) 2025-01-16 2025-01-15 Priority completed both dates due:2025-12-31
x (A) 2025-01-16 Priority completed +project @context
x (A) 2025-01-16 Priority completed +project due:2025-12-31
x (A) 2025-01-16 Priority completed @context due:2025-12-31
x (A) 2025-01-16 Priority completed +project @context due:2025-12-31
x (A) 2025-01-16 2025-01-15 Priority completed all +project @context due:2025-12-31
x (A) 2025-01-16 2025-01-15 Completed priority with space after x
+project@ Invalid project with @ symbol
@context+ Invalid context with + symbol
Empty line above and below task
Task with newline\nshould be single line
Task with tab\tcharacters inside

View file

@ -0,0 +1,349 @@
import { tokenizeLine, Token, ParsedLine } from '../parser';
describe('tokenizeLine', () => {
describe('テキストトークン', () => {
it('テキストのみ', () => {
const result = tokenizeLine('これはシンプルなタスクです');
expect(result.tokens).toHaveLength(1);
expect(result.tokens[0]).toEqual({
type: 'text',
value: 'これはシンプルなタスクです',
start: 0,
end: 13
});
});
it('空行', () => {
const result = tokenizeLine('');
expect(result.tokens).toHaveLength(0);
});
it('空白のみ', () => {
const result = tokenizeLine(' ');
expect(result.tokens).toHaveLength(0);
});
});
describe('完了フラグ', () => {
it('完了フラグのみ', () => {
const result = tokenizeLine('x');
expect(result.tokens).toHaveLength(1);
expect(result.tokens[0]).toEqual({
type: 'completion',
value: 'x',
start: 0,
end: 1
});
});
it('完了フラグ付き', () => {
const result = tokenizeLine('x 完了したタスク');
expect(result.tokens).toHaveLength(2);
expect(result.tokens[0]).toEqual({
type: 'completion',
value: 'x',
start: 0,
end: 1
});
expect(result.tokens[1].type).toBe('text');
expect(result.tokens[1].value).toBe('完了したタスク');
expect(result.tokens[1].start).toBe(2);
});
it('インデント付き', () => {
const result = tokenizeLine(' x 完了したタスク');
expect(result.tokens).toHaveLength(2);
expect(result.tokens[0]).toEqual({
type: 'completion',
value: 'x',
start: 2,
end: 3
});
});
});
describe('優先度', () => {
it('アルファベット', () => {
const result = tokenizeLine('(A) 高優先度タスク');
expect(result.tokens).toHaveLength(2);
expect(result.tokens[0]).toEqual({
type: 'priority',
value: '(A)',
start: 0,
end: 3
});
expect(result.tokens[1].type).toBe('text');
expect(result.tokens[1].value).toBe('高優先度タスク');
expect(result.tokens[1].start).toBe(4);
});
it('数字', () => {
const result = tokenizeLine('(1) 数字優先度タスク');
expect(result.tokens).toHaveLength(2);
expect(result.tokens[0]).toEqual({
type: 'priority',
value: '(1)',
start: 0,
end: 3
});
});
it('混合', () => {
const result = tokenizeLine('(A1) 混合優先度タスク');
expect(result.tokens).toHaveLength(2);
expect(result.tokens[0]).toEqual({
type: 'priority',
value: '(A1)',
start: 0,
end: 4
});
});
it('完了フラグ付き', () => {
const result = tokenizeLine('x (A) 完了した高優先度タスク');
expect(result.tokens).toHaveLength(3);
expect(result.tokens[0].type).toBe('completion');
expect(result.tokens[1]).toEqual({
type: 'priority',
value: '(A)',
start: 2,
end: 5
});
expect(result.tokens[2].type).toBe('text');
});
});
describe('日付', () => {
it('完了日', () => {
const result = tokenizeLine('x 2024-01-15 完了したタスク');
expect(result.tokens).toHaveLength(3);
expect(result.tokens[0].type).toBe('completion');
expect(result.tokens[1]).toEqual({
type: 'completion_date',
value: '2024-01-15',
start: 2,
end: 12
});
});
it('作成日', () => {
const result = tokenizeLine('2024-01-15 作成日付きタスク');
expect(result.tokens).toHaveLength(2);
expect(result.tokens[0]).toEqual({
type: 'creation_date',
value: '2024-01-15',
start: 0,
end: 10
});
});
it('完了日と作成日', () => {
const result = tokenizeLine('x 2024-01-15 2024-01-10 完了タスク');
expect(result.tokens).toHaveLength(4);
expect(result.tokens[0].type).toBe('completion');
expect(result.tokens[1]).toEqual({
type: 'completion_date',
value: '2024-01-15',
start: 2,
end: 12
});
expect(result.tokens[2]).toEqual({
type: 'creation_date',
value: '2024-01-10',
start: 13,
end: 23
});
});
it('優先度付き', () => {
const result = tokenizeLine('(A) 2024-01-15 優先度付きタスク');
expect(result.tokens).toHaveLength(3);
expect(result.tokens[0].type).toBe('priority');
expect(result.tokens[1]).toEqual({
type: 'creation_date',
value: '2024-01-15',
start: 4,
end: 14
});
});
it('完全な構文', () => {
const result = tokenizeLine('x (A) 2024-01-15 2024-01-10 完了タスク');
expect(result.tokens).toHaveLength(5);
expect(result.tokens[0].type).toBe('completion');
expect(result.tokens[1].type).toBe('priority');
expect(result.tokens[2].type).toBe('completion_date');
expect(result.tokens[3].type).toBe('creation_date');
expect(result.tokens[4].type).toBe('text');
});
});
describe('プロジェクトとコンテキスト', () => {
it('プロジェクト', () => {
const result = tokenizeLine('タスク +project1 +project2');
expect(result.tokens).toHaveLength(3);
expect(result.tokens[1].type).toBe('project');
expect(result.tokens[1].value).toBe('+project1');
expect(result.tokens[2].type).toBe('project');
expect(result.tokens[2].value).toBe('+project2');
});
it('コンテキスト', () => {
const result = tokenizeLine('タスク @home @work');
expect(result.tokens).toHaveLength(3);
expect(result.tokens[1].type).toBe('context');
expect(result.tokens[1].value).toBe('@home');
expect(result.tokens[2].type).toBe('context');
expect(result.tokens[2].value).toBe('@work');
});
it('混合', () => {
const result = tokenizeLine('タスク +project @context');
expect(result.tokens).toHaveLength(3);
expect(result.tokens[1].type).toBe('project');
expect(result.tokens[2].type).toBe('context');
});
});
describe('key:value', () => {
it('due:日付', () => {
const result = tokenizeLine('タスク due:2024-01-15');
expect(result.tokens).toHaveLength(2);
expect(result.tokens[1].type).toBe('key_value');
expect(result.tokens[1].value).toBe('due:2024-01-15');
});
it('カスタム', () => {
const result = tokenizeLine('タスク priority:high effort:low');
expect(result.tokens).toHaveLength(3);
expect(result.tokens[1].type).toBe('key_value');
expect(result.tokens[1].value).toBe('priority:high');
expect(result.tokens[2].type).toBe('key_value');
expect(result.tokens[2].value).toBe('effort:low');
});
});
describe('複合構文', () => {
it('未完了タスク', () => {
const result = tokenizeLine('(A) Call Mom +Family +PeaceLake due:2016-02-02');
const tokenTypes = result.tokens.map(t => t.type);
expect(tokenTypes).toContain('priority');
expect(tokenTypes).toContain('text');
expect(tokenTypes).toContain('project');
expect(tokenTypes).toContain('key_value');
const projectTokens = result.tokens.filter(t => t.type === 'project');
expect(projectTokens).toHaveLength(2);
expect(projectTokens[0].value).toBe('+Family');
expect(projectTokens[1].value).toBe('+PeaceLake');
});
it('完了タスク', () => {
const result = tokenizeLine('x 2011-03-02 2011-03-01 Review Tim\'s pull request +TodoTxtTouch @github due:2016-05-30');
const tokenTypes = result.tokens.map(t => t.type);
expect(tokenTypes).toContain('completion');
expect(tokenTypes).toContain('completion_date');
expect(tokenTypes).toContain('creation_date');
expect(tokenTypes).toContain('project');
expect(tokenTypes).toContain('context');
expect(tokenTypes).toContain('key_value');
expect(result.tokens[0].type).toBe('completion');
expect(result.tokens[1].type).toBe('completion_date');
expect(result.tokens[2].type).toBe('creation_date');
});
it('完全な構文', () => {
const result = tokenizeLine('x (A) 2016-05-20 2016-04-30 measure space for +chapelShelving @chapel due:2016-05-30');
expect(result.tokens.length).toBeGreaterThan(8);
const tokenTypes = result.tokens.map(t => t.type);
expect(result.tokens[0].type).toBe('completion');
expect(result.tokens[1].type).toBe('priority');
expect(result.tokens[2].type).toBe('completion_date');
expect(result.tokens[3].type).toBe('creation_date');
expect(tokenTypes).toContain('project');
expect(tokenTypes).toContain('context');
expect(tokenTypes).toContain('key_value');
});
it('日本語タスク', () => {
const result = tokenizeLine('x (A) 2024-01-15 2024-01-10 買い物リストを作成 +家事 @買い物 due:2024-01-20');
expect(result.tokens.length).toBeGreaterThan(7);
expect(result.tokens[0].type).toBe('completion');
expect(result.tokens[1].type).toBe('priority');
expect(result.tokens[2].type).toBe('completion_date');
expect(result.tokens[3].type).toBe('creation_date');
const tokenTypes = result.tokens.map(t => t.type);
expect(tokenTypes).toContain('project');
expect(tokenTypes).toContain('context');
expect(tokenTypes).toContain('key_value');
});
});
describe('エッジケース', () => {
it('無効な優先度', () => {
const result = tokenizeLine('(a) 小文字優先度');
expect(result.tokens[0].type).toBe('text');
});
it('空白なし', () => {
const result = tokenizeLine('タスク+project@context');
expect(result.tokens).toHaveLength(1);
expect(result.tokens[0].type).toBe('text');
});
it('不完全なkey:value', () => {
const result = tokenizeLine('タスク key: value');
expect(result.tokens.some(t => t.type === 'key_value')).toBe(false);
});
it('未完了タスクで2つの日付連続', () => {
const result = tokenizeLine('2011-03-02 2011-03-01 Review Tim\'s pull request +TodoTxtTouch @github due:2016-05-30');
console.log('Debug - Tokens:', result.tokens.map(t => `${t.type}:${t.value}`));
// 最初の日付は creation_date として認識されるべき
expect(result.tokens[0].type).toBe('creation_date');
expect(result.tokens[0].value).toBe('2011-03-02');
// 2番目の日付はtextとして扱われるべき未完了タスクでは完了日は存在しない
expect(result.tokens[1].type).toBe('text');
expect(result.tokens[1].value).toBe('2011-03-01');
});
it('未完了タスクで優先度付き2つの日付', () => {
const result = tokenizeLine('(A) 2011-03-02 2011-03-01 Review Tim\'s pull request +TodoTxtTouch @github due:2016-05-30');
console.log('Debug - Tokens:', result.tokens.map(t => `${t.type}:${t.value}`));
expect(result.tokens[0].type).toBe('priority');
expect(result.tokens[0].value).toBe('(A)');
// 優先度の後の最初の日付は creation_date として認識されるべき
expect(result.tokens[1].type).toBe('creation_date');
expect(result.tokens[1].value).toBe('2011-03-02');
// 2番目の日付はtextとして扱われるべき
expect(result.tokens[2].type).toBe('text');
expect(result.tokens[2].value).toBe('2011-03-01');
});
it('完了タスクで優先度記号が含まれる場合', () => {
const result = tokenizeLine('x (A) 2025-01-02 hoge @hoge');
console.log('Debug - Completed with priority tokens:', result.tokens.map(t => `${t.type}:${t.value}`));
expect(result.tokens[0].type).toBe('completion');
expect(result.tokens[0].value).toBe('x');
// 完了タスクでも優先度はパースされる
expect(result.tokens[1].type).toBe('priority');
expect(result.tokens[1].value).toBe('(A)');
// 最初の日付は completion_date として認識されるべき
expect(result.tokens[2].type).toBe('completion_date');
expect(result.tokens[2].value).toBe('2025-01-02');
});
});
});

View file

@ -1,80 +1,412 @@
import { parsePriorityValue, parseProjectTag, parseContextTag, parseDueDate } from '../parser';
describe('TodoTxtSorter - 実装関数のテスト', () => {
describe('parser functions', () => {
describe('parsePriorityValue', () => {
it('完了タスクは最大値を返す', () => {
expect(parsePriorityValue('x Completed task')).toBe(Number.MAX_SAFE_INTEGER);
expect(parsePriorityValue(' x Completed task with spaces')).toBe(Number.MAX_SAFE_INTEGER);
describe('完了タスク', () => {
it('基本的な完了タスク', () => {
expect(parsePriorityValue('x Simple completed task')).toBe(Number.MAX_SAFE_INTEGER);
expect(parsePriorityValue('x Completed task')).toBe(Number.MAX_SAFE_INTEGER);
});
it('スペース付き完了タスク', () => {
expect(parsePriorityValue(' x Completed task with spaces')).toBe(Number.MAX_SAFE_INTEGER);
expect(parsePriorityValue(' x Completed task with many spaces')).toBe(Number.MAX_SAFE_INTEGER);
expect(parsePriorityValue('\t\tx Completed with tabs')).toBe(Number.MAX_SAFE_INTEGER);
});
it('日付付き完了タスク', () => {
expect(parsePriorityValue('x 2025-01-16 Completed task with completion date')).toBe(Number.MAX_SAFE_INTEGER);
expect(parsePriorityValue('x 2025-01-16 2025-01-15 Completed with both dates')).toBe(Number.MAX_SAFE_INTEGER);
expect(parsePriorityValue('x 2025-01-16 Completed with double space')).toBe(Number.MAX_SAFE_INTEGER);
});
it('優先度付き完了タスク(優先度は無視される)', () => {
expect(parsePriorityValue('x (B) 2025-01-16 2025-01-15 Complete +🚀')).toBe(Number.MAX_SAFE_INTEGER);
});
});
it('優先度なしは最大値-1を返す', () => {
expect(parsePriorityValue('Task without priority')).toBe(Number.MAX_SAFE_INTEGER - 1);
describe('優先度なし', () => {
it('シンプルなタスク', () => {
expect(parsePriorityValue('Simple task without any parameters')).toBe(Number.MAX_SAFE_INTEGER - 1);
expect(parsePriorityValue('Task without priority')).toBe(Number.MAX_SAFE_INTEGER - 1);
});
it('日付付きタスク(優先度なし)', () => {
expect(parsePriorityValue('2025-01-15 Task with creation date only')).toBe(Number.MAX_SAFE_INTEGER - 1);
expect(parsePriorityValue('2025-01-15 Date only no description')).toBe(Number.MAX_SAFE_INTEGER - 1);
});
it('プロジェクト・コンテキスト付きタスク(優先度なし)', () => {
expect(parsePriorityValue('Task with +project')).toBe(Number.MAX_SAFE_INTEGER - 1);
expect(parsePriorityValue('Task with @context')).toBe(Number.MAX_SAFE_INTEGER - 1);
expect(parsePriorityValue('Task with +project @context due:2025-12-31')).toBe(Number.MAX_SAFE_INTEGER - 1);
});
it('説明にカッコを含むが優先度ではないタスク', () => {
expect(parsePriorityValue('Task with (parentheses) in description not priority')).toBe(Number.MAX_SAFE_INTEGER - 1);
expect(parsePriorityValue('Task with [brackets] and {braces} in description')).toBe(Number.MAX_SAFE_INTEGER - 1);
});
});
it('アルファベット優先度の処理', () => {
expect(parsePriorityValue('(A) High priority')).toBe(0);
expect(parsePriorityValue('(B) Medium priority')).toBe(1);
expect(parsePriorityValue('(C) Low priority')).toBe(2);
expect(parsePriorityValue('(Z) Lowest priority')).toBe(25);
describe('アルファベット優先度', () => {
it('基本的なA-Z優先度', () => {
expect(parsePriorityValue('(A) Priority A task')).toBe(0);
expect(parsePriorityValue('(B) Priority B task')).toBe(1);
expect(parsePriorityValue('(C) Priority C task')).toBe(2);
expect(parsePriorityValue('(D) Priority D task')).toBe(3);
expect(parsePriorityValue('(Z) Priority Z task')).toBe(25);
});
it('日付付きアルファベット優先度', () => {
expect(parsePriorityValue('(A) 2025-01-15 Priority A with creation date')).toBe(0);
expect(parsePriorityValue('(B) 2025-01-15 Priority B with creation date')).toBe(1);
});
it('スペースなしアルファベット優先度', () => {
expect(parsePriorityValue('(A)No space after priority works as valid format')).toBe(0);
});
it('余分なスペース付きアルファベット優先度', () => {
expect(parsePriorityValue('(A) 2025-01-15 Double space after priority')).toBe(0);
expect(parsePriorityValue(' (A) 2025-01-15 Task with leading spaces and params ')).toBe(0);
});
});
it('数値優先度の処理', () => {
expect(parsePriorityValue('(1) First task')).toBe(1);
expect(parsePriorityValue('(10) Tenth task')).toBe(10);
expect(parsePriorityValue('(123) Task 123')).toBe(123);
describe('数値優先度', () => {
it('1桁の数値優先度', () => {
expect(parsePriorityValue('(0) Zero priority task')).toBe(0);
expect(parsePriorityValue('(1) Single digit priority task')).toBe(1);
expect(parsePriorityValue('(9) Nine priority task')).toBe(9);
});
it('2桁の数値優先度', () => {
expect(parsePriorityValue('(10) Tenth task')).toBe(10);
expect(parsePriorityValue('(99) Ninety-nine task')).toBe(99);
});
it('3桁の数値優先度', () => {
expect(parsePriorityValue('(123) Numeric priority task')).toBe(123);
expect(parsePriorityValue('(999) Three digit priority task')).toBe(999);
});
it('大きな数値優先度', () => {
expect(parsePriorityValue('(999999) Very large numeric priority')).toBe(999999);
expect(parsePriorityValue('(123456789) Very long numeric priority +project')).toBe(123456789);
});
it('日付付き数値優先度', () => {
expect(parsePriorityValue('(123) 2025-01-15 Numeric priority with creation date')).toBe(123);
expect(parsePriorityValue('(123) 2025-01-13 Numeric priority all params +projectX @work due:2025-10-31')).toBe(123);
});
});
it('混合優先度の処理', () => {
expect(parsePriorityValue('(1a) Mixed priority')).toBe(1);
expect(parsePriorityValue('(10b) Another mixed')).toBe(10);
expect(parsePriorityValue('(A1) Letter first')).toBe(0);
describe('混合優先度', () => {
it('数値+文字の混合優先度', () => {
expect(parsePriorityValue('(1a) Mixed alphanumeric priority task')).toBe(1);
expect(parsePriorityValue('(10b) Another mixed')).toBe(10);
expect(parsePriorityValue('(123a) Large number with letter')).toBe(123);
});
it('文字+数値の混合優先度', () => {
expect(parsePriorityValue('(A1) Letter first mixed priority task')).toBe(0);
expect(parsePriorityValue('(B2) Letter first')).toBe(1);
expect(parsePriorityValue('(Z9) Letter Z with number')).toBe(25);
});
it('複数文字の優先度', () => {
expect(parsePriorityValue('(ABC) Multi-letter priority task')).toBe(0); // 最初の文字 'A' を使用
expect(parsePriorityValue('(A1B2C3) Complex alphanumeric priority @context')).toBe(0);
});
});
describe('エッジケースと無効な形式', () => {
it('優先度のみで説明なし', () => {
expect(parsePriorityValue('(A) Priority only no task description')).toBe(0);
expect(parsePriorityValue('(A)')).toBe(0);
});
it('特殊なタスク開始パターン', () => {
expect(parsePriorityValue('+project Task starting with project tag')).toBe(Number.MAX_SAFE_INTEGER - 1);
expect(parsePriorityValue('@context Task starting with context tag')).toBe(Number.MAX_SAFE_INTEGER - 1);
expect(parsePriorityValue('due:2025-12-31 Task starting with key value')).toBe(Number.MAX_SAFE_INTEGER - 1);
});
it('空行と空白のみの行', () => {
expect(parsePriorityValue('')).toBe(Number.MAX_SAFE_INTEGER - 1);
expect(parsePriorityValue(' ')).toBe(Number.MAX_SAFE_INTEGER - 1);
expect(parsePriorityValue('\t\t')).toBe(Number.MAX_SAFE_INTEGER - 1);
});
});
});
describe('parseProjectTag', () => {
it('プロジェクトタグを抽出', () => {
expect(parseProjectTag('Task +project1')).toBe('project1');
expect(parseProjectTag('Task +Project2 with more text')).toBe('project2');
expect(parseProjectTag('+ProjectAtStart')).toBe('projectatstart');
describe('基本的なプロジェクトタグ', () => {
it('シンプルなプロジェクトタグ', () => {
expect(parseProjectTag('Task with +project')).toBe('project');
expect(parseProjectTag('Task +project1')).toBe('project1');
expect(parseProjectTag('Task +Project2 with more text')).toBe('project2');
});
it('タスク開始位置のプロジェクトタグ', () => {
expect(parseProjectTag('+ProjectAtStart')).toBe('projectatstart');
expect(parseProjectTag('+project Task starting with project tag')).toBe('project');
});
it('複数のプロジェクトタグ(最初のものを返す)', () => {
expect(parseProjectTag('Task +first +second')).toBe('first');
expect(parseProjectTag('Task with multiple +project1 +project2 +project3')).toBe('project1');
expect(parseProjectTag('Complex task with +project1 +project2 @home @work due:2025-12-31')).toBe('project1');
});
it('大文字小文字の正規化', () => {
expect(parseProjectTag('Task with +MultiWordProject')).toBe('multiwordproject');
expect(parseProjectTag('Task with +CamelCaseProject')).toBe('camelcaseproject');
expect(parseProjectTag('Task with +UPPERCASE')).toBe('uppercase');
});
});
it('プロジェクトタグがない場合のデフォルト値', () => {
expect(parseProjectTag('Task without project')).toBe('zzzz');
describe('特殊文字を含むプロジェクトタグ', () => {
it('ハイフン・アンダースコアを含むタグ', () => {
expect(parseProjectTag('Task with +pro-ject_name')).toBe('pro-ject_name');
expect(parseProjectTag('Task with +snake_case_project')).toBe('snake_case_project');
expect(parseProjectTag('Task with +project-name')).toBe('project-name');
});
it('ドットを含むタグ', () => {
expect(parseProjectTag('Task with +project.name')).toBe('project.name');
expect(parseProjectTag('Task with dots in +project.name @context.place')).toBe('project.name');
});
it('数字を含むタグ', () => {
expect(parseProjectTag('Task with +project123')).toBe('project123');
expect(parseProjectTag('+123project Project starting with numbers')).toBe('123project');
});
it('特殊文字の組み合わせ', () => {
expect(parseProjectTag('Task with +project!name')).toBe('project!name');
expect(parseProjectTag('Task with +project/subproject')).toBe('project/subproject');
expect(parseProjectTag('Task with +project|pipe')).toBe('project|pipe');
});
});
it('複数のプロジェクトタグがある場合は最初のものを返す', () => {
expect(parseProjectTag('Task +first +second')).toBe('first');
describe('Unicode・多言語プロジェクトタグ', () => {
it('日本語のプロジェクトタグ', () => {
expect(parseProjectTag('タスク +プロジェクト @コンテキスト')).toBe('プロジェクト');
expect(parseProjectTag('Mixed +日本語project @中文context')).toBe('日本語project');
});
it('絵文字を含むプロジェクトタグ', () => {
expect(parseProjectTag('Task with emoji project +🚀rocket')).toBe('🚀rocket');
expect(parseProjectTag('Task with +🚀 +📱 @🏠')).toBe('🚀');
expect(parseProjectTag('Mixed emoji and text +emoji🎯project')).toBe('emoji🎯project');
});
it('その他の言語', () => {
expect(parseProjectTag('Task with +über')).toBe('über');
expect(parseProjectTag('задача +проект @контекст')).toBe('проект');
expect(parseProjectTag('任务 +项目 @上下文')).toBe('项目');
});
});
describe('プロジェクトタグなし', () => {
it('プロジェクトタグを含まないタスク', () => {
expect(parseProjectTag('Task without project')).toBe('zzzz');
expect(parseProjectTag('Simple task without any parameters')).toBe('zzzz');
expect(parseProjectTag('Task with @context but no project')).toBe('zzzz');
});
it('エスケープされたプロジェクトタグ', () => {
expect(parseProjectTag('Escaped special chars \\+not-project')).toBe('not-project');
});
});
describe('エッジケース', () => {
it('プロジェクトタグのみ', () => {
expect(parseProjectTag('+project Task with only project no description after')).toBe('project');
expect(parseProjectTag('+project')).toBe('project');
});
it('誤解を招く可能性のあるパターン', () => {
expect(parseProjectTag('+project@email.com Might confuse parser')).toBe('project@email.com');
expect(parseProjectTag('Task with quotes "+project"')).toBe('project"'); // 引用符も含めて認識される
});
it('非常に長いプロジェクト名', () => {
expect(parseProjectTag('Task with +ThisIsAVeryLongProjectNameThatMightCauseIssuesWithSomeSystemsThatHaveLengthLimitsOnIdentifiers'))
.toBe('thisisaverylongprojectnamethatmightcauseissueswithsomesystemsthathavelengthlimitsonidentifiers');
});
});
});
describe('parseContextTag', () => {
it('コンテキストタグを抽出', () => {
expect(parseContextTag('Task @home')).toBe('home');
expect(parseContextTag('Task @Work with more text')).toBe('work');
expect(parseContextTag('@ContextAtStart')).toBe('contextatstart');
describe('基本的なコンテキストタグ', () => {
it('シンプルなコンテキストタグ', () => {
expect(parseContextTag('Task with @context')).toBe('context');
expect(parseContextTag('Task @home')).toBe('home');
expect(parseContextTag('Task @Work with more text')).toBe('work');
});
it('タスク開始位置のコンテキストタグ', () => {
expect(parseContextTag('@ContextAtStart')).toBe('contextatstart');
expect(parseContextTag('@context Task starting with context tag')).toBe('context');
});
it('複数のコンテキストタグ(最初のものを返す)', () => {
expect(parseContextTag('Task @first @second')).toBe('first');
expect(parseContextTag('Task with @home @phone multiple contexts')).toBe('home');
expect(parseContextTag('Multiple same contexts @home @home @home')).toBe('home');
});
it('大文字小文字の正規化', () => {
expect(parseContextTag('Task with @MultiWordContext')).toBe('multiwordcontext');
expect(parseContextTag('Task with @PascalCaseContext')).toBe('pascalcasecontext');
expect(parseContextTag('Task with @UPPERCASE')).toBe('uppercase');
});
});
it('コンテキストタグがない場合のデフォルト値', () => {
expect(parseContextTag('Task without context')).toBe('zzzz');
describe('特殊文字を含むコンテキストタグ', () => {
it('ハイフン・アンダースコアを含むタグ', () => {
expect(parseContextTag('Task with @con.text_name')).toBe('con.text_name');
expect(parseContextTag('Task with @kebab-case-context')).toBe('kebab-case-context');
expect(parseContextTag('Task with @context_name')).toBe('context_name');
});
it('数字を含むタグ', () => {
expect(parseContextTag('Task with @context456')).toBe('context456');
expect(parseContextTag('@123context Context starting with numbers')).toBe('123context');
});
it('特殊文字の組み合わせ', () => {
expect(parseContextTag('Task with @context#tag')).toBe('context#tag');
expect(parseContextTag('Task with @context\\subcontext')).toBe('context\\subcontext');
expect(parseContextTag('Task with @context&and')).toBe('context&and');
});
});
it('複数のコンテキストタグがある場合は最初のものを返す', () => {
expect(parseContextTag('Task @first @second')).toBe('first');
describe('Unicode・多言語コンテキストタグ', () => {
it('日本語のコンテキストタグ', () => {
expect(parseContextTag('タスク +プロジェクト @コンテキスト')).toBe('コンテキスト');
expect(parseContextTag('Mixed +日本語project @中文context')).toBe('中文context');
});
it('絵文字を含むコンテキストタグ', () => {
expect(parseContextTag('Task with emoji context @🏠home')).toBe('🏠home');
expect(parseContextTag('Task with @🏠 @💼 @🛒')).toBe('🏠');
expect(parseContextTag('Mixed @context🔥fire')).toBe('context🔥fire');
});
it('その他の言語', () => {
expect(parseContextTag('Task with @café')).toBe('café');
expect(parseContextTag('작업 +프로젝트 @컨텍스트')).toBe('컨텍스트');
expect(parseContextTag('משימה +פרויקט @הקשר')).toBe('הקשר');
});
});
describe('コンテキストタグなし', () => {
it('コンテキストタグを含まないタスク', () => {
expect(parseContextTag('Task without context')).toBe('zzzz');
expect(parseContextTag('Simple task without any parameters')).toBe('zzzz');
expect(parseContextTag('Task with +project but no context')).toBe('zzzz');
});
it('メールアドレスの@は無視', () => {
expect(parseContextTag('Task with email user@example.com might have @ but not context')).toBe('example.com');
});
it('エスケープされたコンテキストタグ', () => {
expect(parseContextTag('Escaped special chars \\@not-context')).toBe('not-context');
});
});
describe('エッジケース', () => {
it('コンテキストタグのみ', () => {
expect(parseContextTag('@context Task with only context no description after')).toBe('context');
expect(parseContextTag('@context')).toBe('context');
});
it('複雑な絵文字', () => {
expect(parseContextTag('Task with @👨💻dev')).toBe('👨💻dev');
expect(parseContextTag('Task with @🏳🌈pride')).toBe('🏳🌈pride');
expect(parseContextTag('Task with @👨doctor')).toBe('👨doctor');
});
it('順序が重要なケース', () => {
expect(parseContextTag('@context+project Wrong order but both present')).toBe('context+project');
expect(parseContextTag('due:2025-12-31 @context Order matters')).toBe('context');
});
});
});
describe('parseDueDate', () => {
it('期日を抽出', () => {
expect(parseDueDate('Task due:2023-12-31')).toBe('2023-12-31');
expect(parseDueDate('due:2024-01-01 Task with date')).toBe('2024-01-01');
describe('基本的な期日', () => {
it('標準的なdue:日付形式', () => {
expect(parseDueDate('Task due:2023-12-31')).toBe('2023-12-31');
expect(parseDueDate('Task with key:value pair due:2025-12-31')).toBe('2025-12-31');
expect(parseDueDate('due:2024-01-01 Task with date')).toBe('2024-01-01');
});
it('タスク開始位置の期日', () => {
expect(parseDueDate('due:2025-12-31 Task starting with key value')).toBe('2025-12-31');
});
it('複数のキー値ペア', () => {
expect(parseDueDate('Task with multiple pairs due:2025-12-31 rec:1w')).toBe('2025-12-31');
expect(parseDueDate('Task with many key:value pairs a:1 b:2 due:2025-12-31 c:3')).toBe('2025-12-31');
});
});
it('期日なしの場合のデフォルト値(未来)', () => {
expect(parseDueDate('Task without due date')).toBe('9999-99-99');
describe('時刻付き期日', () => {
it('ISO形式の時刻付き期日日付部分のみ抽出', () => {
expect(parseDueDate('Task with time due:2025-12-31T14:30:00')).toBe('2025-12-31');
});
});
it('期日なしの場合のデフォルト値(過去)', () => {
expect(parseDueDate('Task without due date', false)).toBe('0000-00-00');
describe('期日なし', () => {
it('期日なし(デフォルト:未来)', () => {
expect(parseDueDate('Task without due date')).toBe('9999-99-99');
expect(parseDueDate('Simple task without any parameters')).toBe('9999-99-99');
expect(parseDueDate('Task with +project @context but no due')).toBe('9999-99-99');
});
it('期日なし(過去を指定)', () => {
expect(parseDueDate('Task without due date', false)).toBe('0000-00-00');
expect(parseDueDate('Simple task', false)).toBe('0000-00-00');
});
});
describe('他のキー値ペア', () => {
it('recurrenceキー', () => {
expect(parseDueDate('rec:+1w Recurrence with plus sign')).toBe('9999-99-99');
expect(parseDueDate('Task with due:2025-12-31 rec:1m')).toBe('2025-12-31');
});
it('カスタムキー', () => {
expect(parseDueDate('Custom key custom:value in task')).toBe('9999-99-99');
expect(parseDueDate('Multiple custom keys foo:bar baz:qux in task')).toBe('9999-99-99');
expect(parseDueDate('t:2025-01-15 Short key for date')).toBe('9999-99-99'); // t:はdue:ではない
});
});
describe('エッジケース', () => {
it('期日のみ', () => {
expect(parseDueDate('due:2025-12-31 Task with only key:value no description after')).toBe('2025-12-31');
expect(parseDueDate('due:2025-12-31')).toBe('2025-12-31');
});
it('コロンを含む値', () => {
expect(parseDueDate('Task with key:value:with:colons might break parser')).toBe('9999-99-99');
});
it('順序の影響', () => {
expect(parseDueDate('due:2025-12-31 @context Order matters for key:value')).toBe('2025-12-31');
expect(parseDueDate('@context due:2025-12-31 Different order same elements')).toBe('2025-12-31');
});
it('複雑なタスクでの期日', () => {
expect(parseDueDate('(A) 2025-01-15 All parameters +project @context due:2025-12-31')).toBe('2025-12-31');
expect(parseDueDate('Extremely long task description that goes on and on and on with many words to test how the parser handles very long lines that might exceed buffer limits in some implementations and includes +project @context due:2025-12-31 and continues even further with more text'))
.toBe('2025-12-31');
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,167 @@
// Todo.txt形式の純粋なパーサー関数Obsidianに依存しない
export interface Token {
type: 'completion' | 'priority' | 'completion_date' | 'creation_date' | 'project' | 'context' | 'key_value' | 'text';
value: string;
start: number;
end: number;
}
export interface ParsedLine {
tokens: Token[];
raw: string;
}
export function tokenizeLine(line: string): ParsedLine {
const tokens: Token[] = [];
let pos = 0;
const length = line.length;
let isCompleted = false;
// 先頭の空白をスキップ
while (pos < length && /\s/.test(line[pos])) {
pos++;
}
// 完了フラグxのチェック
if (pos < length && line[pos] === 'x' && (pos + 1 >= length || /\s/.test(line[pos + 1]))) {
tokens.push({
type: 'completion',
value: 'x',
start: pos,
end: pos + 1
});
pos++;
isCompleted = true;
// 完了フラグ後の空白をスキップ
while (pos < length && /\s/.test(line[pos])) {
pos++;
}
}
// 優先度のチェック(完了フラグの後)
if (pos < length && line[pos] === '(') {
const priorityMatch = line.substring(pos).match(/^\(([A-Z0-9][A-Z0-9a-z0-9]*)\)/);
if (priorityMatch) {
tokens.push({
type: 'priority',
value: priorityMatch[0],
start: pos,
end: pos + priorityMatch[0].length
});
pos += priorityMatch[0].length;
// 優先度後の空白をスキップ
while (pos < length && /\s/.test(line[pos])) {
pos++;
}
}
}
// 完了日のチェック(完了タスクの場合のみ、優先度の後)
if (isCompleted) {
const completionDateMatch = line.substring(pos).match(/^(\d{4}-\d{2}-\d{2})/);
if (completionDateMatch) {
tokens.push({
type: 'completion_date',
value: completionDateMatch[1],
start: pos,
end: pos + completionDateMatch[1].length
});
pos += completionDateMatch[1].length;
// 完了日後の空白をスキップ
while (pos < length && /\s/.test(line[pos])) {
pos++;
}
}
}
// 作成日のチェック(優先度/完了日の後)
const creationDateMatch = line.substring(pos).match(/^(\d{4}-\d{2}-\d{2})/);
if (creationDateMatch) {
tokens.push({
type: 'creation_date',
value: creationDateMatch[1],
start: pos,
end: pos + creationDateMatch[1].length
});
pos += creationDateMatch[1].length;
// 作成日後の空白をスキップ
while (pos < length && /\s/.test(line[pos])) {
pos++;
}
}
// 残りの部分(タスク本文)を処理
while (pos < length) {
const remaining = line.substring(pos);
// プロジェクトタグのチェック
const projectMatch = remaining.match(/^(\+[^\s]+)/);
if (projectMatch) {
tokens.push({
type: 'project',
value: projectMatch[1],
start: pos,
end: pos + projectMatch[1].length
});
pos += projectMatch[1].length;
continue;
}
// コンテキストタグのチェック
const contextMatch = remaining.match(/^(@[^\s]+)/);
if (contextMatch) {
tokens.push({
type: 'context',
value: contextMatch[1],
start: pos,
end: pos + contextMatch[1].length
});
pos += contextMatch[1].length;
continue;
}
// key:value形式のチェック
const keyValueMatch = remaining.match(/^([a-zA-Z_][a-zA-Z0-9_]*:[^\s]+)/);
if (keyValueMatch) {
tokens.push({
type: 'key_value',
value: keyValueMatch[1],
start: pos,
end: pos + keyValueMatch[1].length
});
pos += keyValueMatch[1].length;
continue;
}
// 通常のテキストまたは空白
const textMatch = remaining.match(/^([^\s]+|\s+)/);
if (textMatch) {
if (textMatch[1].trim()) { // 空白でない場合のみテキストトークンとして追加
tokens.push({
type: 'text',
value: textMatch[1],
start: pos,
end: pos + textMatch[1].length
});
}
pos += textMatch[1].length;
} else {
// フォールバック
pos++;
}
}
return {
tokens,
raw: line
};
}
export function parsePriorityValue(line: string): number {
if (line.trim().startsWith('x ')) {
return Number.MAX_SAFE_INTEGER;

View file

@ -28,6 +28,12 @@ export interface TodoTxtSettings {
highlightDueDate: boolean;
dueDateColor: string;
highlightCompletionDate: boolean;
completionDateColor: string;
highlightCreationDate: boolean;
creationDateColor: string;
}
export const DEFAULT_SETTINGS: TodoTxtSettings = {
@ -49,7 +55,13 @@ export const DEFAULT_SETTINGS: TodoTxtSettings = {
priorityColor: "#E91E63",
highlightDueDate: true,
dueDateColor: "#607D8B"
dueDateColor: "#607D8B",
highlightCompletionDate: true,
completionDateColor: "#FF9800",
highlightCreationDate: true,
creationDateColor: "#9C27B0"
}
export class TodoTxtSettingTab extends PluginSettingTab {
@ -224,6 +236,22 @@ export class TodoTxtSettingTab extends PluginSettingTab {
'highlightDueDate',
'dueDateColor'
);
this.createHighlightSetting(
containerEl,
'Highlight completion dates',
'Apply color to completion dates in completed tasks ("x 2011-03-02")',
'highlightCompletionDate',
'completionDateColor'
);
this.createHighlightSetting(
containerEl,
'Highlight creation dates',
'Apply color to creation dates ("2011-03-01 Task" or "x date1 2011-03-01")',
'highlightCreationDate',
'creationDateColor'
);
new Setting(containerEl).setHeading().setName('Sort settings');

View file

@ -2,12 +2,8 @@ import { Decoration, DecorationSet, EditorView as CMEditorView, ViewPlugin, View
import { RangeSetBuilder } from '@codemirror/state';
import { App, TFile } from 'obsidian';
import { TodoTxtSettings } from './settings';
import { tokenizeLine, Token } from './parser';
// 正規表現をテスト用にexport
export const projectRegex = /\+[^\s]+/g;
export const contextRegex = /@[^\s]+/g;
export const priorityRegex = /^(\s*(?:x\s+)?\s*)\(([A-Z0-9][A-Z0-9a-z0-9]*)\)/;
export const dueDateRegex = /due:[^\s]+/g;
export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) => boolean, getSettings: () => TodoTxtSettings) {
// 正規表現はモジュールレベルで定義されたものを使用
@ -53,9 +49,13 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
for (let i = viewport.from; i <= viewport.to;) {
const line = doc.lineAt(i);
const lineText = line.text;
const trimmedText = lineText.trim();
if (settings.highlightCompletedTask && trimmedText.startsWith('x ')) {
// 新しいパーサーを使用してトークン化
const parsedLine = tokenizeLine(lineText);
// 完了タスクの行全体ハイライト
const hasCompletion = parsedLine.tokens.some(token => token.type === 'completion');
if (settings.highlightCompletedTask && hasCompletion) {
const deco = Decoration.line({
attributes: { class: "todo-txt-mode-completed" }
});
@ -66,75 +66,58 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
});
}
if (settings.highlightProject) {
projectRegex.lastIndex = 0;
let match: RegExpExecArray | null;
// トークンごとのハイライト処理
for (const token of parsedLine.tokens) {
let className: string | null = null;
let shouldHighlight = false;
while ((match = projectRegex.exec(lineText)) !== null) {
const start = line.from + match.index;
const end = start + match[0].length;
const deco = Decoration.mark({
attributes: { class: "todo-txt-mode-project" }
});
decorations.push({
from: start,
to: end,
decoration: deco
});
switch (token.type) {
case 'project':
if (settings.highlightProject) {
className = "todo-txt-mode-project";
shouldHighlight = true;
}
break;
case 'context':
if (settings.highlightContext) {
className = "todo-txt-mode-context";
shouldHighlight = true;
}
break;
case 'priority':
if (settings.highlightPriority) {
className = "todo-txt-mode-priority";
shouldHighlight = true;
}
break;
case 'key_value':
// due: で始まるkey_valueをdue dateとして扱う
if (settings.highlightDueDate && token.value.startsWith('due:')) {
className = "todo-txt-mode-due-date";
shouldHighlight = true;
}
break;
case 'completion_date':
if (settings.highlightCompletionDate) {
className = "todo-txt-mode-completion-date";
shouldHighlight = true;
}
break;
case 'creation_date':
if (settings.highlightCreationDate) {
className = "todo-txt-mode-creation-date";
shouldHighlight = true;
}
break;
}
}
if (settings.highlightContext) {
contextRegex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = contextRegex.exec(lineText)) !== null) {
const start = line.from + match.index;
const end = start + match[0].length;
if (shouldHighlight && className) {
const deco = Decoration.mark({
attributes: { class: "todo-txt-mode-context" }
attributes: { class: className }
});
decorations.push({
from: start,
to: end,
decoration: deco
});
}
}
if (settings.highlightPriority) {
const priorityMatch = priorityRegex.exec(lineText);
if (priorityMatch) {
const prefixLength = priorityMatch[1].length;
const priorityText = priorityMatch[2];
const start = line.from + prefixLength;
const end = start + priorityText.length + 2;
const deco = Decoration.mark({
attributes: { class: "todo-txt-mode-priority" }
});
decorations.push({
from: start,
to: end,
decoration: deco
});
}
}
if (settings.highlightDueDate) {
dueDateRegex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = dueDateRegex.exec(lineText)) !== null) {
const start = line.from + match.index;
const end = start + match[0].length;
const deco = Decoration.mark({
attributes: { class: "todo-txt-mode-due-date" }
});
decorations.push({
from: start,
to: end,
from: line.from + token.start,
to: line.from + token.end,
decoration: deco
});
}
@ -159,4 +142,4 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
}, {
decorations: v => v.decorations
});
}
}

View file

@ -36,6 +36,20 @@ settings:
type: variable-color
format: hex
default: '#808080'
- id: todo-txt-mode-completion-date-color
title: Completion Date Color
description: Color for completion dates in completed tasks (x 2011-03-02)
type: variable-color
format: hex
default: '#FF9800'
- id: todo-txt-mode-creation-date-color
title: Creation Date Color
description: Color for creation dates (2011-03-01 Task or x date1 2011-03-01 Task)
type: variable-color
format: hex
default: '#9C27B0'
*/
/* Todo.txt Mode スタイル定義 */
@ -73,6 +87,26 @@ settings:
font-weight: normal;
}
/* 完了日x 2011-03-02 */
.todo-txt-mode-completion-date {
color: var(--todo-txt-mode-completion-date-color, #FF9800);
font-size: 90%;
font-weight: 600;
background-color: rgba(255, 152, 0, 0.1);
padding: 1px 4px;
border-radius: 3px;
}
/* 作成日2011-03-01 Task or x date1 2011-03-01 Task */
.todo-txt-mode-creation-date {
color: var(--todo-txt-mode-creation-date-color, #9C27B0);
font-size: 90%;
font-weight: 600;
background-color: rgba(156, 39, 176, 0.1);
padding: 1px 4px;
border-radius: 3px;
}
/* ファイル識別のためのマーカー */
.todo-txt-mode-file .cm-line {
font-family: var(--font-monospace);

165
todo.txt Normal file
View file

@ -0,0 +1,165 @@
Simple task without any parameters
(A) Priority A task
(B) Priority B task
(C) Priority C task
(Z) Priority Z task
(123) Numeric priority task
(1) Single digit priority task
(999) Three digit priority task
2025-01-15 Task with creation date only
(A) 2025-01-15 Priority A with creation date
(B) 2025-01-15 Priority B with creation date
(123) 2025-01-15 Numeric priority with creation date
Task with +project
Task with +MultiWordProject
Task with multiple +project1 +project2 +project3
Task with @context
Task with @home @phone multiple contexts
Task with @MultiWordContext
Task with key:value pair due:2025-12-31
Task with multiple pairs due:2025-12-31 rec:1w
2025-01-15 Creation date with +project
2025-01-15 Creation date with @context
2025-01-15 Creation date with due:2025-12-31
(A) Task with priority and +project
(A) Task with priority and @context
(A) Task with priority and due:2025-12-31
(A) 2025-01-15 Priority, creation date, and +project
(A) 2025-01-15 Priority, creation date, and @context
(A) 2025-01-15 Priority, creation date, and due:2025-12-31
Task with +project @context
Task with +project due:2025-12-31
Task with @context due:2025-12-31
Task with +project @context due:2025-12-31
2025-01-15 Task with date +project @context
2025-01-15 Task with date +project due:2025-12-31
2025-01-15 Task with date @context due:2025-12-31
2025-01-15 Task with date +project @context due:2025-12-31
(A) Task with priority +project @context
(A) Task with priority +project due:2025-12-31
(A) Task with priority @context due:2025-12-31
(A) Task with priority +project @context due:2025-12-31
(A) 2025-01-15 All parameters +project @context due:2025-12-31
(B) 2025-01-14 Different priority and date +project2 @home due:2025-11-30
(123) 2025-01-13 Numeric priority all params +projectX @work due:2025-10-31
x Simple completed task
x 2025-01-16 Completed task with completion date
x 2025-01-16 2025-01-15 Completed task with completion and creation dates
x 2025-01-16 Completed task with +project
x 2025-01-16 Completed task with @context
x 2025-01-16 Completed task with due:2025-12-31
x 2025-01-16 2025-01-15 Completed with dates and +project
x 2025-01-16 2025-01-15 Completed with dates and @context
x 2025-01-16 2025-01-15 Completed with dates and due:2025-12-31
x 2025-01-16 Completed with +project @context
x 2025-01-16 Completed with +project due:2025-12-31
x 2025-01-16 Completed with @context due:2025-12-31
x 2025-01-16 Completed with +project @context due:2025-12-31
x 2025-01-16 2025-01-15 Completed all params +project @context due:2025-12-31
+project Task starting with project tag
@context Task starting with context tag
due:2025-12-31 Task starting with key value
Task with special chars in +project-name @context_name
Task with numbers in +project123 @context456
Task with dots in +project.name @context.place
Complex task with +project1 +project2 @home @work due:2025-12-31 rec:1m pri:H
(1a) Mixed alphanumeric priority task
(A1) Letter first mixed priority task
Task with leading spaces
Task with trailing spaces
(A) 2025-01-15 Task with leading spaces and params
Task with time due:2025-12-31T14:30:00
Multiple same contexts @home @home @home
Multiple same projects +projectA +projectA +projectA
rec:+1w Recurrence with plus sign
Custom key custom:value in task
Multiple custom keys foo:bar baz:qux in task
Task with URL https://example.com/path
Task with email user@example.com might have @ but not context
(0) Zero priority task
(ABC) Multi-letter priority task
(999999) Very large numeric priority
Task with unicode émojis 🎯 and special chars
@context+project Wrong order but both present
t:2025-01-15 Short key for date
p:1 Short key for priority
x 2025-01-16 Completed with double space
x 2025-01-16 2025-01-15 Double space between dates
(A) 2025-01-15 Double space after priority
Task with +CamelCaseProject @PascalCaseContext
Task with +snake_case_project @kebab-case-context
+123project Project starting with numbers
@123context Context starting with numbers
Task with key:value:with:colons might break parser
Escaped special chars \+not-project \@not-context
Task with +über @café unicode in tags
+project@email.com Might confuse parser
Task with (parentheses) in description not priority
Task with [brackets] and {braces} in description
due:2025-12-31 @context Order matters for key:value
@context due:2025-12-31 Different order same elements
Long task description that contains many words and might wrap in some views but should still parse correctly with +project @context due:2025-12-31
Task with emoji project +🚀rocket +📱mobile +🎯target
Task with emoji context @🏠home @💼work @🛒shopping
Task with emoji in description 🎉 Complete the feature 🚀 +dev @office
(A) 2025-01-15 Priority task with emojis 🔥 +🚀deploy @🏠home due:2025-12-31
x 2025-01-16 Completed emoji task 🎉 +🚀project @🏠home
Mixed emoji and text +emoji🎯project @context🔥fire
Multiple emojis in one tag +🚀🎯🔥 @🏠💼🛒
Emoji with skin tone +👋🏻project @👨💻dev
Complex emoji +👨👩👧👦family @🏳🌈pride
Zero-width joiner emoji +🧑🚀astronaut @👨doctor
Task with Japanese text タスク +プロジェクト @コンテキスト
Task with Chinese 任务 +项目 @上下文
Task with Korean 작업 +프로젝트 @컨텍스트
Task with Arabic مهمة +مشروع @سياق
Task with Hebrew משימה +פרויקט @הקשר
Task with Cyrillic задача +проект @контекст
Task with mixed scripts +日本語project @中文context
RTL text עברית task with +project @context due:2025-12-31
Combining marks task̃ +projec̈t @conte͂xt
Task with !@#$%^&*() special chars in description
Task with +project!name @context#tag
Task with +pro-ject_name @con.text_name
Task with +project/subproject @context\subcontext
Task with +project|pipe @context&and
Task with quotes "quoted +project" and 'single @quotes'
Task with backticks `code +example` @context
Task with brackets [not a link] +project
Task with angle brackets <not html> @context
Task with curly braces {not json} +project
Task with backslash\\ in middle
Task with zerowidthspace between words
Task with soft­hyphen in word
(A) 2025-01-15 Task +project1 +project2 +project3 @context1 @context2 @context3 due:2025-12-31 rec:1w pri:H custom:value foo:bar
x (B) 2025-01-16 2025-01-15 Complete +🚀 +📱 @🏠 @💼 due:2025-12-31 done:2025-01-16
(123456789) Very long numeric priority +project
(A1B2C3) Complex alphanumeric priority @context
Multiple dates 2025-01-15 2025-01-16 2025-01-17 in task
Task with very long project name +ThisIsAVeryLongProjectNameThatMightCauseIssuesWithSomeSystemsThatHaveLengthLimitsOnIdentifiers
Task with many projects +p1 +p2 +p3 +p4 +p5 +p6 +p7 +p8 +p9 +p10 +p11 +p12 +p13 +p14 +p15 +p16 +p17 +p18 +p19 +p20
Task with many contexts @c1 @c2 @c3 @c4 @c5 @c6 @c7 @c8 @c9 @c10 @c11 @c12 @c13 @c14 @c15 @c16 @c17 @c18 @c19 @c20
Task with many key:value pairs a:1 b:2 c:3 d:4 e:5 f:6 g:7 h:8 i:9 j:10 k:11 l:12 m:13 n:14 o:15
Extremely long task description that goes on and on and on with many words to test how the parser handles very long lines that might exceed buffer limits in some implementations and includes +project @context due:2025-12-31 and continues even further with more text
+project Task with only project no description after
@context Task with only context no description after
due:2025-12-31 Task with only key:value no description after
(A) Priority only no task description
x Completed marker only no description
x 2025-01-16 Completed with date only no description
2025-01-15 Date only no description
Task with HTML tags <b>bold</b> text
Task with & HTML entity &amp; test
Task with < and > comparison operators
Task with && and || logical operators
Task with -> arrow notation
Task with => fat arrow
Task with ... ellipsis
Task with file.extension.txt mentions
Task with https://example.com/+project/@context/page
Heavily indented task with many spaces
Task with tab indentation
Mixed spaces and tabs indentation
Task with trailing newline
(A) No space after priority works as valid format
Task without trailing newline