From 0fa34ea3fec46d8fab7172fc0caafdbab8d54b5d Mon Sep 17 00:00:00 2001 From: Riosk It <23426017+rioskit@users.noreply.github.com> Date: Wed, 25 Jun 2025 22:21:54 +0900 Subject: [PATCH] feat: Add syntax highlighting for completion and creation dates, refine parser,tests --- no-todo.txt | 68 ++ src/__tests__/parser.test.ts | 349 +++++++++++ src/__tests__/sort.test.ts | 416 +++++++++++-- src/__tests__/syntax.test.ts | 1143 +++++++++++++++++++++++++++++++--- src/parser.ts | 162 +++++ src/settings.ts | 30 +- src/syntax.ts | 125 ++-- styles.css | 34 + todo.txt | 165 +++++ 9 files changed, 2284 insertions(+), 208 deletions(-) create mode 100644 no-todo.txt create mode 100644 src/__tests__/parser.test.ts create mode 100644 todo.txt diff --git a/no-todo.txt b/no-todo.txt new file mode 100644 index 0000000..34a357b --- /dev/null +++ b/no-todo.txt @@ -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 \ No newline at end of file diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts new file mode 100644 index 0000000..c7acc47 --- /dev/null +++ b/src/__tests__/parser.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/sort.test.ts b/src/__tests__/sort.test.ts index 6b298a3..48ef221 100644 --- a/src/__tests__/sort.test.ts +++ b/src/__tests__/sort.test.ts @@ -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'); + }); }); }); }); \ No newline at end of file diff --git a/src/__tests__/syntax.test.ts b/src/__tests__/syntax.test.ts index ffc3be1..ee72a04 100644 --- a/src/__tests__/syntax.test.ts +++ b/src/__tests__/syntax.test.ts @@ -1,140 +1,1095 @@ -import { projectRegex, contextRegex, priorityRegex, dueDateRegex } from '../syntax'; +import { tokenizeLine } from '../parser'; +import { createTodoTxtExtension } from '../syntax'; -describe('Todo.txt Syntax - 実装正規表現のテスト', () => { - describe('projectRegex', () => { - beforeEach(() => { - // グローバル正規表現のlastIndexをリセット - projectRegex.lastIndex = 0; +describe('tokenizeLine', () => { + describe('基本構文テスト(Basic Syntax Tests)', () => { + it('シンプルなタスク(パラメータなし)', () => { + const testCases = [ + 'Simple task without any parameters', + 'Simple task', + 'Task', + 'A' + ]; + + testCases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.every(t => t.type === 'text')).toBe(true); + expect(result.tokens.map(t => t.value).join(' ')).toBe(line); + }); }); - it('プロジェクトタグを正しくマッチ', () => { - expect('+project'.match(projectRegex)).toEqual(['+project']); - projectRegex.lastIndex = 0; - expect('Task +project1 +project2'.match(projectRegex)).toEqual(['+project1', '+project2']); - projectRegex.lastIndex = 0; - expect('+ProjectAtStart and +ProjectAtEnd'.match(projectRegex)).toEqual(['+ProjectAtStart', '+ProjectAtEnd']); + describe('優先度パターン', () => { + it('大文字優先度 (A)-(Z)', () => { + const priorities = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']; + + priorities.forEach(letter => { + const line = `(${letter}) Task with priority ${letter}`; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens[0].value).toBe(`(${letter})`); + }); + }); + + it('数値優先度', () => { + const numericPriorities = [ + '(0) Zero priority task', + '(1) Single digit priority task', + '(7) Single digit priority task', + '(123) Numeric priority task', + '(999) Three digit priority task', + '(999999) Very large numeric priority' + ]; + + numericPriorities.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('priority'); + }); + }); + + it('無効な優先度形式', () => { + const invalidPriorities = [ + '(1a) Mixed alphanumeric priority task', + '(A1) Letter first mixed priority task', + '(ABC) Multi-letter priority task', + '(A1B2C3) Complex alphanumeric priority', + '(123456789) Very long numeric priority' + ]; + + invalidPriorities.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('priority'); + }); + }); + + it('優先度のみ(説明なし)', () => { + const line = '(A) Priority only no task description'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens[0].value).toBe('(A)'); + expect(result.tokens.slice(1).every(t => t.type === 'text')).toBe(true); + }); }); - it('プロジェクトタグがない場合はnull', () => { - expect('No project here'.match(projectRegex)).toBeNull(); - projectRegex.lastIndex = 0; - expect('+ space after plus'.match(projectRegex)).toBeNull(); + describe('日付パターン', () => { + it('作成日のみ', () => { + const dateCases = [ + '2025-01-15 Task with creation date only', + '2024-12-31 Last day of year task', + '2023-01-01 First day of year task', + '2025-01-15 Date only no description' + ]; + + dateCases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('creation_date'); + expect(result.tokens[0].value).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); + + it('複数の日付(最初のみ認識)', () => { + const line = 'Multiple dates 2025-01-15 2025-01-16 2025-01-17 in task'; + const result = tokenizeLine(line); + + // パーサーは日付形式の文字列をすべてテキストとして扱う(前にテキストがある場合) + const dateTokens = result.tokens.filter(t => + t.value === '2025-01-15' || t.value === '2025-01-16' || t.value === '2025-01-17' + ); + dateTokens.forEach(token => { + expect(token.type).toBe('text'); + }); + }); }); - it('特殊文字を含むプロジェクトタグ', () => { - expect('Task +project-name'.match(projectRegex)).toEqual(['+project-name']); - projectRegex.lastIndex = 0; - expect('Task +project_name'.match(projectRegex)).toEqual(['+project_name']); - projectRegex.lastIndex = 0; - expect('Task +project123'.match(projectRegex)).toEqual(['+project123']); + describe('基本タグ', () => { + it('単一プロジェクトタグ', () => { + const projectCases = [ + 'Task with +project', + 'Task with +MultiWordProject', + '+project Task starting with project tag', + 'Task with +CamelCaseProject', + 'Task with +snake_case_project' + ]; + + projectCases.forEach(line => { + const result = tokenizeLine(line); + const projectTokens = result.tokens.filter(t => t.type === 'project'); + expect(projectTokens.length).toBeGreaterThan(0); + projectTokens.forEach(token => { + expect(token.value).toMatch(/^\+\w+/); + }); + }); + }); + + it('単一コンテキストタグ', () => { + const contextCases = [ + 'Task with @context', + 'Task with @MultiWordContext', + '@context Task starting with context tag', + 'Task with @PascalCaseContext', + 'Task with @kebab-case-context' + ]; + + contextCases.forEach(line => { + const result = tokenizeLine(line); + const contextTokens = result.tokens.filter(t => t.type === 'context'); + expect(contextTokens.length).toBeGreaterThan(0); + contextTokens.forEach(token => { + expect(token.value).toMatch(/^@\w+/); + }); + }); + }); + + it('key:value ペア', () => { + const keyValueCases = [ + 'Task with key:value pair due:2025-12-31', + 'Task with time due:2025-12-31T14:30:00', + 'rec:+1w Recurrence with plus sign', + 'Custom key custom:value in task', + 't:2025-01-15 Short key for date', + 'p:1 Short key for priority' + ]; + + keyValueCases.forEach(line => { + const result = tokenizeLine(line); + const keyValueTokens = result.tokens.filter(t => t.type === 'key_value'); + expect(keyValueTokens.length).toBeGreaterThan(0); + keyValueTokens.forEach(token => { + expect(token.value).toContain(':'); + }); + }); + }); + + it('パラメータのみ(説明なし)', () => { + const paramOnlyCases = [ + '+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' + ]; + + paramOnlyCases.forEach(line => { + const result = tokenizeLine(line); + const firstToken = result.tokens[0]; + expect(['project', 'context', 'key_value']).toContain(firstToken.type); + }); + }); }); }); - describe('contextRegex', () => { - beforeEach(() => { - contextRegex.lastIndex = 0; + describe('完了タスクテスト(Completed Task Tests)', () => { + it('基本的な完了タスク', () => { + const completedCases = [ + 'x Simple completed task', + 'x Completed marker only no description', + 'x' + ]; + + completedCases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('completion'); + expect(result.tokens[0].value).toBe('x'); + }); }); - it('コンテキストタグを正しくマッチ', () => { - expect('@home'.match(contextRegex)).toEqual(['@home']); - contextRegex.lastIndex = 0; - expect('Task @context1 @context2'.match(contextRegex)).toEqual(['@context1', '@context2']); - contextRegex.lastIndex = 0; - expect('@ContextAtStart and @ContextAtEnd'.match(contextRegex)).toEqual(['@ContextAtStart', '@ContextAtEnd']); + it('完了日付付き', () => { + const cases = [ + 'x 2025-01-16 Completed task with completion date', + 'x 2025-01-16 Completed with date only no description', + 'x 2025-01-16 Completed with double space' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('completion'); + expect(result.tokens[1].type).toBe('completion_date'); + expect(result.tokens[1].value).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); }); - it('コンテキストタグがない場合はnull', () => { - expect('No context here'.match(contextRegex)).toBeNull(); - contextRegex.lastIndex = 0; - expect('@ space after at'.match(contextRegex)).toBeNull(); + it('完了日付+作成日付', () => { + const cases = [ + 'x 2025-01-16 2025-01-15 Completed task with completion and creation dates', + 'x 2025-01-16 2025-01-15 Double space between dates', + 'x 2011-03-02 2011-03-01 Review Tim\'s pull request' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('completion'); + expect(result.tokens[1].type).toBe('completion_date'); + expect(result.tokens[2].type).toBe('creation_date'); + }); }); - it('特殊文字を含むコンテキストタグ', () => { - expect('Task @context-name'.match(contextRegex)).toEqual(['@context-name']); - contextRegex.lastIndex = 0; - expect('Task @context_name'.match(contextRegex)).toEqual(['@context_name']); - contextRegex.lastIndex = 0; - expect('Task @context123'.match(contextRegex)).toEqual(['@context123']); + it('完了タスクとプロジェクトタグ', () => { + const cases = [ + 'x 2025-01-16 Completed task with +project', + 'x 2025-01-16 2025-01-15 Completed with dates and +project', + 'x Completed with +project1 +project2' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('completion'); + const projectTokens = result.tokens.filter(t => t.type === 'project'); + expect(projectTokens.length).toBeGreaterThan(0); + }); + }); + + it('完了タスクとコンテキストタグ', () => { + const cases = [ + 'x 2025-01-16 Completed task with @context', + 'x 2025-01-16 2025-01-15 Completed with dates and @context', + 'x Completed with @home @work' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('completion'); + const contextTokens = result.tokens.filter(t => t.type === 'context'); + expect(contextTokens.length).toBeGreaterThan(0); + }); + }); + + it('完了タスクとkey:value', () => { + const cases = [ + 'x 2025-01-16 Completed task with due:2025-12-31', + 'x 2025-01-16 2025-01-15 Completed with dates and due:2025-12-31', + 'x Completed with due:2025-12-31 done:2025-01-16' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('completion'); + const keyValueTokens = result.tokens.filter(t => t.type === 'key_value'); + expect(keyValueTokens.length).toBeGreaterThan(0); + }); + }); + + it('完了タスクと複数パラメータ', () => { + const cases = [ + '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' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('completion'); + + const hasProject = result.tokens.some(t => t.type === 'project'); + const hasContext = result.tokens.some(t => t.type === 'context'); + const hasKeyValue = result.tokens.some(t => t.type === 'key_value'); + + expect(hasProject || hasContext || hasKeyValue).toBe(true); + }); + }); + + it('優先度付き完了タスク(x が優先)', () => { + const line = 'x (B) 2025-01-16 2025-01-15 Complete +🚀 +📱 @🏠 @💼 due:2025-12-31 done:2025-01-16'; + const result = tokenizeLine(line); + + 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'); }); }); - describe('priorityRegex', () => { - it('アルファベット優先度をマッチ', () => { - expect('(A) Task'.match(priorityRegex)).toBeTruthy(); - expect('(B) Task'.match(priorityRegex)).toBeTruthy(); - expect('(Z) Task'.match(priorityRegex)).toBeTruthy(); + describe('組み合わせパターンテスト(Combination Pattern Tests)', () => { + describe('優先度と日付の組み合わせ', () => { + it('優先度+作成日', () => { + const cases = [ + '(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', + '(A) 2025-01-15 No space after priority works as valid format' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens[1].type).toBe('creation_date'); + }); + }); + + it('優先度+日付+スペースバリエーション', () => { + const line = '(A) 2025-01-15 Double space after priority'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens[1].type).toBe('creation_date'); + }); }); - it('数値優先度をマッチ', () => { - expect('(1) Task'.match(priorityRegex)).toBeTruthy(); - expect('(10) Task'.match(priorityRegex)).toBeTruthy(); - expect('(123) Task'.match(priorityRegex)).toBeTruthy(); + describe('優先度とタグの組み合わせ', () => { + it('優先度+プロジェクト', () => { + const cases = [ + '(A) Task with priority and +project', + '(123456789) Very long numeric priority +project', + '(A1B2C3) Complex alphanumeric priority @context' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('priority'); + const hasProjectOrContext = result.tokens.some(t => t.type === 'project' || t.type === 'context'); + expect(hasProjectOrContext).toBe(true); + }); + }); + + it('優先度+コンテキスト', () => { + const line = '(A) Task with priority and @context'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + }); + + it('優先度+key:value', () => { + const line = '(A) Task with priority and due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); }); - it('混合優先度をマッチ', () => { - expect('(A1) Task'.match(priorityRegex)).toBeTruthy(); - expect('(1a) Task'.match(priorityRegex)).toBeTruthy(); - expect('(A1b2) Task'.match(priorityRegex)).toBeTruthy(); + describe('日付とタグの組み合わせ', () => { + it('作成日+プロジェクト', () => { + const line = '2025-01-15 Creation date with +project'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + }); + + it('作成日+コンテキスト', () => { + const line = '2025-01-15 Creation date with @context'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + }); + + it('作成日+key:value', () => { + const line = '2025-01-15 Creation date with due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); }); - it('スペースありでもマッチ', () => { - expect(' (A) Task with spaces'.match(priorityRegex)).toBeTruthy(); - expect(' (B) Another task'.match(priorityRegex)).toBeTruthy(); + describe('タグの組み合わせ', () => { + it('プロジェクト+コンテキスト', () => { + const cases = [ + 'Task with +project @context', + '@context +project Wrong order but both present', + 'Task with +project1 @context1 +project2 @context2' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + }); + }); + + it('プロジェクト+key:value', () => { + const line = 'Task with +project due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); + + it('コンテキスト+key:value', () => { + const cases = [ + 'Task with @context due:2025-12-31', + 'due:2025-12-31 @context Order matters for key:value', + '@context due:2025-12-31 Different order same elements' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); + }); + + it('プロジェクト+コンテキスト+key:value', () => { + const line = 'Task with +project @context due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); }); - it('完了タスクでもマッチ', () => { - expect('x (A) Completed task'.match(priorityRegex)).toBeTruthy(); - expect(' x (B) Completed with spaces'.match(priorityRegex)).toBeTruthy(); + describe('3要素の組み合わせ', () => { + it('優先度+日付+プロジェクト', () => { + const line = '(A) 2025-01-15 Priority, creation date, and +project'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens[1].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + }); + + it('優先度+日付+コンテキスト', () => { + const line = '(A) 2025-01-15 Priority, creation date, and @context'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens[1].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + }); + + it('優先度+日付+key:value', () => { + const line = '(A) 2025-01-15 Priority, creation date, and due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens[1].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); + + it('日付+プロジェクト+コンテキスト', () => { + const line = '2025-01-15 Task with date +project @context'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + }); + + it('日付+プロジェクト+key:value', () => { + const line = '2025-01-15 Task with date +project due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); + + it('日付+コンテキスト+key:value', () => { + const line = '2025-01-15 Task with date @context due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); + + it('優先度+プロジェクト+コンテキスト', () => { + const line = '(A) Task with priority +project @context'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + }); + + it('優先度+プロジェクト+key:value', () => { + const line = '(A) Task with priority +project due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); + + it('優先度+コンテキスト+key:value', () => { + const line = '(A) Task with priority @context due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); }); - it('無効な優先度はマッチしない', () => { - expect('(a) Lower case'.match(priorityRegex)).toBeNull(); - expect('No priority here'.match(priorityRegex)).toBeNull(); - expect('Middle (A) priority'.match(priorityRegex)).toBeNull(); + describe('4要素の組み合わせ', () => { + it('日付+プロジェクト+コンテキスト+key:value', () => { + const line = '2025-01-15 Task with date +project @context due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); + + it('優先度+プロジェクト+コンテキスト+key:value', () => { + const line = '(A) Task with priority +project @context due:2025-12-31'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); + }); + + describe('全要素の組み合わせ', () => { + it('優先度+日付+プロジェクト+コンテキスト+key:value', () => { + const cases = [ + '(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' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens[1].type).toBe('creation_date'); + expect(result.tokens.some(t => t.type === 'project')).toBe(true); + expect(result.tokens.some(t => t.type === 'context')).toBe(true); + expect(result.tokens.some(t => t.type === 'key_value')).toBe(true); + }); + }); + + it('複雑な組み合わせ', () => { + const line = '(A) 2025-01-15 Task +project1 +project2 +project3 @context1 @context2 @context3 due:2025-12-31 rec:1w pri:H custom:value foo:bar'; + const result = tokenizeLine(line); + + expect(result.tokens[0].type).toBe('priority'); + expect(result.tokens[1].type).toBe('creation_date'); + + const projectTokens = result.tokens.filter(t => t.type === 'project'); + const contextTokens = result.tokens.filter(t => t.type === 'context'); + const keyValueTokens = result.tokens.filter(t => t.type === 'key_value'); + + expect(projectTokens.length).toBe(3); + expect(contextTokens.length).toBe(3); + expect(keyValueTokens.length).toBeGreaterThan(0); + }); }); }); - describe('dueDateRegex', () => { - beforeEach(() => { - dueDateRegex.lastIndex = 0; + describe('複数要素・重複テスト(Multiple Elements & Duplication Tests)', () => { + it('複数プロジェクト', () => { + const cases = [ + 'Task with multiple +project1 +project2 +project3', + 'Multiple same projects +projectA +projectA +projectA', + 'Task with many projects +p1 +p2 +p3 +p4 +p5 +p6 +p7 +p8 +p9 +p10 +p11 +p12 +p13 +p14 +p15 +p16 +p17 +p18 +p19 +p20' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + const projectTokens = result.tokens.filter(t => t.type === 'project'); + expect(projectTokens.length).toBeGreaterThan(1); + }); }); - it('標準的な期日フォーマットをマッチ', () => { - expect('due:2024-12-31'.match(dueDateRegex)).toEqual(['due:2024-12-31']); - dueDateRegex.lastIndex = 0; - expect('Task due:2024-01-01 text'.match(dueDateRegex)).toEqual(['due:2024-01-01']); + it('複数コンテキスト', () => { + const cases = [ + 'Task with @home @phone multiple contexts', + 'Multiple same contexts @home @home @home', + 'Task with many contexts @c1 @c2 @c3 @c4 @c5 @c6 @c7 @c8 @c9 @c10 @c11 @c12 @c13 @c14 @c15 @c16 @c17 @c18 @c19 @c20' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + const contextTokens = result.tokens.filter(t => t.type === 'context'); + expect(contextTokens.length).toBeGreaterThan(1); + }); }); - it('様々な期日フォーマットをマッチ', () => { - expect('due:tomorrow'.match(dueDateRegex)).toEqual(['due:tomorrow']); - dueDateRegex.lastIndex = 0; - expect('due:today'.match(dueDateRegex)).toEqual(['due:today']); - dueDateRegex.lastIndex = 0; - expect('due:2024/12/31'.match(dueDateRegex)).toEqual(['due:2024/12/31']); + it('複数key:valueペア', () => { + const cases = [ + 'Task with multiple pairs due:2025-12-31 rec:1w', + 'Multiple custom keys foo:bar baz:qux in task', + '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' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + const keyValueTokens = result.tokens.filter(t => t.type === 'key_value'); + expect(keyValueTokens.length).toBeGreaterThan(1); + }); }); - it('複数の期日をマッチ', () => { - expect('Task due:2024-01-01 and due:2024-12-31'.match(dueDateRegex)) - .toEqual(['due:2024-01-01', 'due:2024-12-31']); - }); - - it('期日がない場合はnull', () => { - expect('No due date here'.match(dueDateRegex)).toBeNull(); - dueDateRegex.lastIndex = 0; - expect('due: space after colon'.match(dueDateRegex)).toBeNull(); + it('複雑な複数要素の組み合わせ', () => { + const line = 'Complex task with +project1 +project2 @home @work due:2025-12-31 rec:1m pri:H'; + const result = tokenizeLine(line); + + const projectTokens = result.tokens.filter(t => t.type === 'project'); + const contextTokens = result.tokens.filter(t => t.type === 'context'); + const keyValueTokens = result.tokens.filter(t => t.type === 'key_value'); + + expect(projectTokens.length).toBe(2); + expect(contextTokens.length).toBe(2); + expect(keyValueTokens.length).toBe(3); }); }); - describe('完了タスク判定', () => { - it('完了タスクを正しく判定', () => { - expect('x Completed task'.trim().startsWith('x ')).toBe(true); - expect('x 2024-01-01 Completed task with date'.trim().startsWith('x ')).toBe(true); + describe('特殊文字テスト(Special Character Tests)', () => { + describe('絵文字', () => { + it('絵文字を含むプロジェクト', () => { + const cases = [ + 'Task with emoji project +🚀rocket +📱mobile +🎯target', + 'Mixed emoji and text +emoji🎯project', + 'Multiple emojis in one tag +🚀🎯🔥', + 'Emoji with skin tone +👋🏻project', + 'Complex emoji +👨‍👩‍👧‍👦family', + 'Zero-width joiner emoji +🧑‍🚀astronaut' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + const projectTokens = result.tokens.filter(t => t.type === 'project'); + expect(projectTokens.length).toBeGreaterThan(0); + }); + }); + + it('絵文字を含むコンテキスト', () => { + const cases = [ + 'Task with emoji context @🏠home @💼work @🛒shopping', + 'Mixed emoji and text @context🔥fire', + 'Multiple emojis in one tag @🏠💼🛒', + 'Complex emoji @🏳️‍🌈pride', + 'Zero-width joiner emoji @👨‍⚕️doctor' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + const contextTokens = result.tokens.filter(t => t.type === 'context'); + expect(contextTokens.length).toBeGreaterThan(0); + }); + }); + + it('絵文字を含む説明文', () => { + const cases = [ + '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', + 'Task with unicode émojis 🎯 and special chars' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); }); - it('未完了タスクは判定しない', () => { - expect('Active task'.trim().startsWith('x ')).toBe(false); - expect('(A) Priority task'.trim().startsWith('x ')).toBe(false); - expect('Task with x in middle'.trim().startsWith('x ')).toBe(false); + describe('特殊記号', () => { + it('説明文中の特殊記号', () => { + const cases = [ + 'Task with !@#$%^&*() special chars in description', + '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 @context', + 'Task with curly braces {not json} +project', + 'Task with (parentheses) in description not priority', + 'Task with [brackets] and {braces} in description' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('タグ内の特殊文字', () => { + const cases = [ + 'Task with special chars in +project-name @context_name', + 'Task with numbers in +project123 @context456', + 'Task with dots in +project.name @context.place', + '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', + '+123project Project starting with numbers', + '@123context Context starting with numbers' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + const tagTokens = result.tokens.filter(t => t.type === 'project' || t.type === 'context'); + expect(tagTokens.length).toBeGreaterThan(0); + }); + }); + + it('エスケープ文字', () => { + const cases = [ + 'Escaped special chars \\+not-project \\@not-context', + 'Task with backslash\\\\ in middle' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('URLとメールアドレス', () => { + const cases = [ + 'Task with URL https://example.com/path', + 'Task with email user@example.com might have @ but not context', + 'Task with https://example.com/+project/@context/page', + '+project@email.com Might confuse parser' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('HTMLとエンティティ', () => { + const cases = [ + 'Task with HTML tags bold text', + 'Task with & HTML entity & test', + 'Task with < and > comparison operators', + 'Task with && and || logical operators' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('矢印と演算子', () => { + const cases = [ + 'Task with -> arrow notation', + 'Task with => fat arrow', + 'Task with ... ellipsis' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('複雑なkey:value', () => { + const line = 'Task with key:value:with:colons might break parser'; + const result = tokenizeLine(line); + const keyValueTokens = result.tokens.filter(t => t.type === 'key_value'); + expect(keyValueTokens.length).toBeGreaterThan(0); + }); + }); + + describe('不可視文字', () => { + it('ゼロ幅文字', () => { + const line = 'Task with zero​width​space between words'; + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + + it('ソフトハイフン', () => { + const line = 'Task with soft­hyphen in word'; + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + }); + + describe('多言語テスト(Multilingual Tests)', () => { + it('日本語', () => { + const cases = [ + 'Task with Japanese text タスク +プロジェクト @コンテキスト', + 'x (A) 2024-01-15 2024-01-10 牛乳を買う +買い物 @スーパー due:2024-01-20' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('中国語', () => { + const line = 'Task with Chinese 任务 +项目 @上下文'; + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + + it('韓国語', () => { + const line = 'Task with Korean 작업 +프로젝트 @컨텍스트'; + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + + it('アラビア語(RTL)', () => { + const line = 'Task with Arabic مهمة +مشروع @سياق'; + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + + it('ヘブライ語(RTL)', () => { + const cases = [ + 'Task with Hebrew משימה +פרויקט @הקשר', + 'RTL text עברית task with +project @context due:2025-12-31' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('キリル文字', () => { + const line = 'Task with Cyrillic задача +проект @контекст'; + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + + it('混在スクリプト', () => { + const line = 'Task with mixed scripts +日本語project @中文context'; + const result = tokenizeLine(line); + const projectTokens = result.tokens.filter(t => t.type === 'project'); + const contextTokens = result.tokens.filter(t => t.type === 'context'); + expect(projectTokens.length).toBe(1); + expect(contextTokens.length).toBe(1); + }); + + it('結合文字', () => { + const line = 'Combining marks task̃ +projec̈t @conte͂xt'; + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + + it('Unicode タグ', () => { + const line = 'Task with +über @café unicode in tags'; + const result = tokenizeLine(line); + const projectTokens = result.tokens.filter(t => t.type === 'project'); + const contextTokens = result.tokens.filter(t => t.type === 'context'); + expect(projectTokens.length).toBe(1); + expect(contextTokens.length).toBe(1); + }); + }); + + describe('空白・インデントテスト(Whitespace & Indentation Tests)', () => { + it('先頭の空白', () => { + const cases = [ + ' Task with leading spaces', + ' (A) 2025-01-15 Task with leading spaces and params ', + ' Heavily indented task with many spaces' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + + const nonWhitespaceTokens = result.tokens.filter(t => t.value.trim() !== ''); + expect(nonWhitespaceTokens.length).toBeGreaterThan(0); + }); + }); + + it('末尾の空白', () => { + const cases = [ + 'Task with trailing spaces ', + ' (A) 2025-01-15 Task with leading spaces and params ' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('タブインデント', () => { + const cases = [ + '\t\tTask with tab indentation', + '\t \tMixed spaces and tabs indentation' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('要素間の複数スペース', () => { + const cases = [ + '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' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('改行', () => { + const cases = [ + 'Task with trailing newline\n', + 'Task without trailing newline' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + }); + + describe('エッジケース(Edge Cases)', () => { + it('不正な順序', () => { + const cases = [ + '(A) x 2016-04-30 Invalid order task', + '(A) 2016-05-20 2016-04-30 Task with two dates', + '(A) No space after priority works as valid format' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens[0].type).toBe('priority'); + }); + }); + + it('極端に長い要素', () => { + const cases = [ + 'Task with very long project name +ThisIsAVeryLongProjectNameThatMightCauseIssuesWithSomeSystemsThatHaveLengthLimitsOnIdentifiers', + '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', + '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' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('ファイル拡張子', () => { + const line = 'Task with file.extension.txt mentions'; + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + + it('パーサーを混乱させる可能性のあるパターン', () => { + const cases = [ + '+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' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + }); + + describe('追加テストケース(Additional Test Cases)', () => { + it('セキュリティ関連のパターン', () => { + const cases = [ + 'Task with attempt', + 'Task with ${injection} attempt', + 'Task with `command` injection', + 'Task with "; DROP TABLE tasks; --', + 'Task with \\\\n\\\\r control characters' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(0); + }); + }); + + it('境界値テスト', () => { + const cases = [ + '', // 空文字列 + ' ', // スペースのみ + '()', // 空の優先度 + '+ ', // 不完全なプロジェクト + '@ ', // 不完全なコンテキスト + 'key:', // 値のないkey:value + ':value', // キーのないkey:value + '2025-13-32', // 無効な日付 + '(999999999999999999999)', // 極端に大きな数値優先度 + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + // パーサーがクラッシュしないことを確認 + expect(result).toBeDefined(); + expect(result.tokens).toBeDefined(); + }); + }); + + it('パフォーマンステスト用巨大入力', () => { + // 1000個のプロジェクトタグ + const manyProjects = Array(1000).fill(0).map((_, i) => `+project${i}`).join(' '); + const line = `Task with ${manyProjects}`; + + const result = tokenizeLine(line); + const projectTokens = result.tokens.filter(t => t.type === 'project'); + expect(projectTokens.length).toBe(1000); + }); + + it('実用的な複雑なタスク', () => { + const cases = [ + '(A) 2025-01-15 Review and merge PR #123 +Development +CodeReview @office @urgent due:2025-01-16 pr:123 assignee:john', + 'x 2025-01-16 2025-01-10 🚀 Deploy v2.3.4 to production +Deployment +Release @aws @production version:2.3.4 status:completed', + '(B) 2025-01-20 📧 Send weekly report to stakeholders +Communication +Reports @email @weekly due:2025-01-20T17:00:00 rec:+1w', + '(C) Fix bug: Unicode handling in parser 🐛 +BugFix +Parser @development @testing issue:456 estimated:3h', + 'x 2025-01-15 2025-01-14 Refactor authentication module +Backend +Security @code @review pr:789 loc:1234 coverage:95%' + ]; + + cases.forEach(line => { + const result = tokenizeLine(line); + expect(result.tokens.length).toBeGreaterThan(5); + }); + }); + }); + + describe('トークン位置の正確性(Token Position Accuracy)', () => { + it('基本的な位置確認', () => { + const line = 'x (A) 2016-05-20 task +project @context'; + const result = tokenizeLine(line); + + result.tokens.forEach(token => { + const extractedValue = line.substring(token.start, token.end); + expect(extractedValue).toBe(token.value); + }); + }); + + it('空白を含む位置確認', () => { + const line = ' x (A) 2016-05-20 task'; + const result = tokenizeLine(line); + + result.tokens.forEach(token => { + const extractedValue = line.substring(token.start, token.end); + expect(extractedValue).toBe(token.value); + }); + }); + + it('複雑な行での位置確認', () => { + const line = '(A) 2025-01-15 Complex task with +project1 +project2 @context1 @context2 due:2025-12-31 rec:1w'; + const result = tokenizeLine(line); + + result.tokens.forEach(token => { + const extractedValue = line.substring(token.start, token.end); + expect(extractedValue).toBe(token.value); + }); + }); + + it('絵文字を含む行での位置確認', () => { + const line = 'Task with 🚀 emoji +🎯project @🏠home'; + const result = tokenizeLine(line); + + result.tokens.forEach(token => { + const extractedValue = line.substring(token.start, token.end); + expect(extractedValue).toBe(token.value); + }); }); }); }); \ No newline at end of file diff --git a/src/parser.ts b/src/parser.ts index a3df5df..754ef79 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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; diff --git a/src/settings.ts b/src/settings.ts index 9157d1e..c2b612f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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'); diff --git a/src/syntax.ts b/src/syntax.ts index d59da79..cb6ccd4 100644 --- a/src/syntax.ts +++ b/src/syntax.ts @@ -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 }); -} \ No newline at end of file +} diff --git a/styles.css b/styles.css index d737570..a3a2783 100644 --- a/styles.css +++ b/styles.css @@ -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); diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..9f69550 --- /dev/null +++ b/todo.txt @@ -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 @context +Task with curly braces {not json} +project +Task with backslash\\ in middle +Task with zero​width​space 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 bold text +Task with & HTML entity & 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 \ No newline at end of file