@peaske7/readit 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -5
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -710
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +130 -0
  12. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  13. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  14. package/e2e/comments.spec.ts +14 -58
  15. package/e2e/document-load.spec.ts +1 -23
  16. package/e2e/export.spec.ts +4 -4
  17. package/e2e/perf/add-comment.spec.ts +116 -0
  18. package/e2e/perf/fixtures/generate.ts +327 -0
  19. package/e2e/perf/initial-load.spec.ts +49 -0
  20. package/e2e/perf/perf.setup.ts +23 -0
  21. package/e2e/perf/perf.teardown.ts +9 -0
  22. package/e2e/perf/screenshot-final.png +0 -0
  23. package/e2e/perf/scroll.spec.ts +39 -0
  24. package/e2e/perf/tab-switch.spec.ts +69 -0
  25. package/e2e/perf/text-selection.spec.ts +119 -0
  26. package/e2e/perf/utils/metrics.ts +350 -0
  27. package/e2e/perf/utils/perf-cli.ts +86 -0
  28. package/e2e/persistence-file.spec.ts +41 -26
  29. package/e2e/utils/selection.ts +17 -73
  30. package/go/cmd/readit/main.go +416 -0
  31. package/go/go.mod +20 -0
  32. package/go/go.sum +41 -0
  33. package/go/internal/server/anchor.go +302 -0
  34. package/go/internal/server/anchor_test.go +111 -0
  35. package/go/internal/server/comments.go +390 -0
  36. package/go/internal/server/documents.go +113 -0
  37. package/go/internal/server/embed.go +17 -0
  38. package/go/internal/server/headings.go +33 -0
  39. package/go/internal/server/headings_test.go +75 -0
  40. package/go/internal/server/htmltext.go +123 -0
  41. package/go/internal/server/markdown.go +157 -0
  42. package/go/internal/server/markdown_bench_test.go +42 -0
  43. package/go/internal/server/markdown_test.go +79 -0
  44. package/go/internal/server/server.go +453 -0
  45. package/go/internal/server/server_bench_test.go +122 -0
  46. package/go/internal/server/settings.go +110 -0
  47. package/go/internal/server/sse.go +140 -0
  48. package/go/internal/server/storage.go +275 -0
  49. package/go/internal/server/storage_test.go +118 -0
  50. package/go/internal/server/template.go +66 -0
  51. package/go/internal/server/types.go +101 -0
  52. package/go/internal/server/watcher.go +74 -0
  53. package/index.html +4 -14
  54. package/nvim-readit/lua/readit/health.lua +64 -0
  55. package/nvim-readit/lua/readit/init.lua +463 -0
  56. package/nvim-readit/plugin/readit.lua +19 -0
  57. package/package.json +24 -41
  58. package/playwright.config.ts +12 -0
  59. package/shell/_readit +158 -0
  60. package/shell/readit.zsh +87 -0
  61. package/src/App.svelte +881 -0
  62. package/src/{cli/index.ts → cli.ts} +216 -70
  63. package/src/components/ActionsMenu.svelte +95 -0
  64. package/src/components/CommentBadge.svelte +67 -0
  65. package/src/components/CommentErrorBanner.svelte +33 -0
  66. package/src/components/CommentInput.svelte +75 -0
  67. package/src/components/CommentListItem.svelte +95 -0
  68. package/src/components/CommentManager.svelte +129 -0
  69. package/src/components/CommentNav.svelte +109 -0
  70. package/src/components/DocumentViewer.svelte +218 -0
  71. package/src/components/FloatingComment.svelte +107 -0
  72. package/src/components/Header.svelte +76 -0
  73. package/src/components/InlineEditor.svelte +72 -0
  74. package/src/components/MarginNote.svelte +167 -0
  75. package/src/components/MarginNotesContainer.svelte +33 -0
  76. package/src/components/RawModal.svelte +126 -0
  77. package/src/components/ReanchorConfirm.svelte +30 -0
  78. package/src/components/SettingsModal.svelte +220 -0
  79. package/src/components/ShortcutCapture.svelte +82 -0
  80. package/src/components/ShortcutList.svelte +145 -0
  81. package/src/components/TabBar.svelte +52 -0
  82. package/src/components/TableOfContents.svelte +125 -0
  83. package/src/components/ui/ActionLink.svelte +40 -0
  84. package/src/components/ui/Button.svelte +53 -0
  85. package/src/components/ui/Dialog.svelte +97 -0
  86. package/src/components/ui/DropdownMenu.svelte +85 -0
  87. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  88. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  89. package/src/components/ui/Text.svelte +42 -0
  90. package/src/env.d.ts +6 -0
  91. package/src/index.css +36 -166
  92. package/src/lib/__fixtures__/bench-data.ts +1 -54
  93. package/src/lib/anchor.bench.ts +47 -68
  94. package/src/lib/anchor.test.ts +5 -9
  95. package/src/lib/anchor.ts +9 -93
  96. package/src/lib/comment-storage.bench.ts +6 -20
  97. package/src/lib/comment-storage.test.ts +45 -37
  98. package/src/lib/comment-storage.ts +23 -64
  99. package/src/lib/export.bench.ts +9 -23
  100. package/src/lib/export.ts +7 -14
  101. package/src/lib/headings.test.ts +103 -0
  102. package/src/lib/headings.ts +44 -0
  103. package/src/lib/highlight/core.test.ts +1 -6
  104. package/src/lib/highlight/dom.ts +53 -280
  105. package/src/lib/highlight/highlight-registry.ts +221 -0
  106. package/src/lib/highlight/highlight.bench.ts +92 -0
  107. package/src/lib/highlight/highlighter.ts +122 -302
  108. package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
  109. package/src/lib/highlight/types.ts +0 -40
  110. package/src/lib/html-text.test.ts +162 -0
  111. package/src/lib/html-text.ts +161 -0
  112. package/src/lib/i18n/en.ts +13 -36
  113. package/src/lib/i18n/ja.ts +14 -37
  114. package/src/lib/i18n/types.ts +13 -36
  115. package/src/lib/margin-layout.bench.ts +48 -15
  116. package/src/lib/margin-layout.ts +2 -31
  117. package/src/lib/markdown-renderer.test.ts +154 -0
  118. package/src/lib/markdown-renderer.ts +177 -0
  119. package/src/lib/mermaid-config.ts +38 -0
  120. package/src/lib/mermaid-renderer.ts +162 -0
  121. package/src/lib/mermaid-worker.ts +60 -0
  122. package/src/lib/positions.ts +157 -0
  123. package/src/lib/shortcut-registry.ts +138 -103
  124. package/src/lib/utils.ts +2 -48
  125. package/src/main.ts +16 -0
  126. package/src/schema.ts +92 -0
  127. package/src/{server/index.ts → server.ts} +427 -163
  128. package/src/stores/app.svelte.ts +231 -0
  129. package/src/stores/locale.svelte.ts +46 -0
  130. package/src/stores/settings.svelte.ts +90 -0
  131. package/src/stores/shortcuts.svelte.ts +104 -0
  132. package/src/stores/ui.svelte.ts +12 -0
  133. package/src/template.ts +104 -0
  134. package/src/test-setup.ts +47 -0
  135. package/svelte.config.js +5 -0
  136. package/tsconfig.json +2 -2
  137. package/vite.config.ts +31 -3
  138. package/vscode-readit/.mcp.json +7 -0
  139. package/vscode-readit/.vscodeignore +7 -0
  140. package/vscode-readit/bun.lock +78 -0
  141. package/vscode-readit/icon.svg +10 -0
  142. package/vscode-readit/package.json +110 -0
  143. package/vscode-readit/src/extension.ts +117 -0
  144. package/vscode-readit/src/server-manager.ts +272 -0
  145. package/vscode-readit/src/webview-provider.ts +204 -0
  146. package/vscode-readit/tsconfig.json +20 -0
  147. package/e2e/fixtures/sample.html +0 -13
  148. package/src/App.tsx +0 -416
  149. package/src/components/ActionsMenu.tsx +0 -112
  150. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  151. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
  152. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  153. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  154. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
  155. package/src/components/DocumentViewer/index.ts +0 -1
  156. package/src/components/FloatingTOC.tsx +0 -61
  157. package/src/components/Header.tsx +0 -65
  158. package/src/components/InlineEditor.tsx +0 -74
  159. package/src/components/MarginNote.tsx +0 -207
  160. package/src/components/MarginNotes.tsx +0 -50
  161. package/src/components/RawModal.tsx +0 -143
  162. package/src/components/ReanchorConfirm.tsx +0 -36
  163. package/src/components/SettingsModal.tsx +0 -310
  164. package/src/components/ShortcutCapture.tsx +0 -48
  165. package/src/components/ShortcutList.tsx +0 -198
  166. package/src/components/TabBar.tsx +0 -60
  167. package/src/components/TableOfContents.tsx +0 -108
  168. package/src/components/comments/CommentBadge.tsx +0 -49
  169. package/src/components/comments/CommentInput.tsx +0 -114
  170. package/src/components/comments/CommentListItem.tsx +0 -92
  171. package/src/components/comments/CommentManager.tsx +0 -113
  172. package/src/components/comments/CommentMinimap.tsx +0 -62
  173. package/src/components/comments/CommentNav.tsx +0 -109
  174. package/src/components/ui/ActionBar.tsx +0 -16
  175. package/src/components/ui/ActionLink.tsx +0 -32
  176. package/src/components/ui/Button.tsx +0 -55
  177. package/src/components/ui/Dialog.tsx +0 -156
  178. package/src/components/ui/DropdownMenu.tsx +0 -114
  179. package/src/components/ui/SeparatorDot.tsx +0 -9
  180. package/src/components/ui/Text.tsx +0 -54
  181. package/src/contexts/CommentContext.tsx +0 -229
  182. package/src/contexts/LayoutContext.tsx +0 -88
  183. package/src/contexts/LocaleContext.tsx +0 -35
  184. package/src/hooks/useClickOutside.ts +0 -35
  185. package/src/hooks/useClipboard.ts +0 -82
  186. package/src/hooks/useCommentNavigation.ts +0 -130
  187. package/src/hooks/useComments.ts +0 -323
  188. package/src/hooks/useDocument.ts +0 -156
  189. package/src/hooks/useEditorScheme.ts +0 -51
  190. package/src/hooks/useFontPreference.ts +0 -59
  191. package/src/hooks/useHeadings.test.ts +0 -159
  192. package/src/hooks/useHeadings.ts +0 -129
  193. package/src/hooks/useKeybindings.ts +0 -108
  194. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  195. package/src/hooks/useLayoutMode.ts +0 -44
  196. package/src/hooks/useLocalePreference.ts +0 -42
  197. package/src/hooks/useReanchorMode.ts +0 -33
  198. package/src/hooks/useScrollMetrics.ts +0 -56
  199. package/src/hooks/useScrollSpy.ts +0 -81
  200. package/src/hooks/useTextSelection.ts +0 -123
  201. package/src/hooks/useThemePreference.ts +0 -66
  202. package/src/lib/context.bench.ts +0 -41
  203. package/src/lib/context.test.ts +0 -224
  204. package/src/lib/context.ts +0 -193
  205. package/src/lib/editor-links.ts +0 -59
  206. package/src/lib/highlight/colors.ts +0 -37
  207. package/src/lib/highlight/index.ts +0 -23
  208. package/src/lib/highlight/script-builder.ts +0 -485
  209. package/src/lib/html-processor.test.tsx +0 -170
  210. package/src/lib/html-processor.tsx +0 -95
  211. package/src/lib/i18n/completeness.test.ts +0 -51
  212. package/src/lib/i18n/translations.test.ts +0 -39
  213. package/src/lib/layout-constants.ts +0 -12
  214. package/src/lib/scroll.test.ts +0 -118
  215. package/src/lib/scroll.ts +0 -47
  216. package/src/lib/shortcut-registry.test.ts +0 -173
  217. package/src/lib/utils.test.ts +0 -110
  218. package/src/main.tsx +0 -13
  219. package/src/store/index.test.ts +0 -242
  220. package/src/store/index.ts +0 -254
  221. package/src/types/index.ts +0 -127
@@ -1,112 +1,91 @@
1
1
  import { bench, describe } from "vitest";
2
- import { LARGE_DOC } from "./__fixtures__/bench-data";
2
+ import {
3
+ COMMENTS_1,
4
+ COMMENTS_10,
5
+ COMMENTS_50,
6
+ LARGE_DOC,
7
+ MEDIUM_DOC,
8
+ } from "./__fixtures__/bench-data";
3
9
  import {
4
10
  findAnchor,
5
11
  findAnchorFuzzy,
6
12
  findAnchorNormalized,
7
13
  findAnchorWithFallback,
8
- levenshteinDistance,
9
14
  } from "./anchor";
10
15
 
11
- // Text known to exist at specific lines in LARGE_DOC
12
- const EXACT_TEXT_L149 =
13
- "The conclusion of section 8 summarizes the key findings";
14
- const EXACT_TEXT_L250 = "- Item 1 in section 14";
15
- const WHITESPACE_TEXT = "Item 1 in\n section 14";
16
- const FUZZY_TEXT = "- Item 1 in section 1x"; // 1-char typo
17
-
18
- describe("levenshteinDistance", () => {
19
- const str20 = "hello world testing!";
20
-
21
- bench("identical strings (20 chars)", () => {
22
- levenshteinDistance(str20, str20);
23
- });
24
-
25
- bench("1 edit distance (20 chars)", () => {
26
- levenshteinDistance("hello world testing!", "hello world testinx!");
27
- });
28
-
29
- bench("maxDistance early exit", () => {
30
- levenshteinDistance("completely different", "nothing alike here!", 3);
31
- });
32
-
33
- const longA = "a".repeat(50) + "b".repeat(50);
34
- const longB = "a".repeat(50) + "c".repeat(50);
35
-
36
- bench("longer strings (100 chars)", () => {
37
- levenshteinDistance(longA, longB);
38
- });
39
- });
16
+ describe("findAnchor exact match", () => {
17
+ const comment = COMMENTS_10[5];
40
18
 
41
- describe("findAnchor (exact)", () => {
42
- bench("300-line doc, correct hint", () => {
19
+ bench("medium doc (150 lines)", () => {
43
20
  findAnchor({
44
- source: LARGE_DOC,
45
- selectedText: EXACT_TEXT_L149,
46
- lineHint: "L149",
21
+ source: MEDIUM_DOC,
22
+ selectedText: comment.selectedText,
23
+ lineHint: comment.lineHint ?? "L1",
47
24
  });
48
25
  });
49
26
 
50
- bench("300-line doc, wrong hint (global fallback)", () => {
27
+ bench("large doc (300 lines)", () => {
51
28
  findAnchor({
52
29
  source: LARGE_DOC,
53
- selectedText: EXACT_TEXT_L250,
54
- lineHint: "L10",
30
+ selectedText: comment.selectedText,
31
+ lineHint: comment.lineHint ?? "L1",
55
32
  });
56
33
  });
57
34
  });
58
35
 
59
36
  describe("findAnchorNormalized", () => {
60
- bench("300-line doc, whitespace-collapsed match", () => {
37
+ const comment = COMMENTS_10[5];
38
+ const normalizedText = comment.selectedText.replace(/ /g, " ");
39
+
40
+ bench("large doc — normalized whitespace", () => {
61
41
  findAnchorNormalized({
62
42
  source: LARGE_DOC,
63
- selectedText: WHITESPACE_TEXT,
64
- lineHint: "L250",
43
+ selectedText: normalizedText,
44
+ lineHint: comment.lineHint ?? "L1",
65
45
  });
66
46
  });
67
47
  });
68
48
 
69
49
  describe("findAnchorFuzzy", () => {
70
- bench("300-line doc, 1-char typo with hint", () => {
71
- findAnchorFuzzy({
72
- source: LARGE_DOC,
73
- selectedText: FUZZY_TEXT,
74
- lineHint: "L250",
75
- threshold: 3,
76
- });
77
- });
50
+ const comment = COMMENTS_10[5];
51
+ const mutated = `X${comment.selectedText.slice(1, -1)}Z`;
78
52
 
79
- bench("300-line doc, no hint (full scan)", () => {
53
+ bench("large doc fuzzy (mutated text)", () => {
80
54
  findAnchorFuzzy({
81
55
  source: LARGE_DOC,
82
- selectedText: FUZZY_TEXT,
83
- threshold: 3,
56
+ selectedText: mutated.slice(0, 50), // Keep within MAX_FUZZY_TEXT_LENGTH
57
+ lineHint: comment.lineHint ?? "L1",
84
58
  });
85
59
  });
86
60
  });
87
61
 
88
62
  describe("findAnchorWithFallback", () => {
89
- bench("exact match (fast path)", () => {
63
+ bench("1 comment exact hit", () => {
64
+ const c = COMMENTS_1[0];
90
65
  findAnchorWithFallback({
91
66
  source: LARGE_DOC,
92
- selectedText: EXACT_TEXT_L149,
93
- lineHint: "L149",
67
+ selectedText: c.selectedText,
68
+ lineHint: c.lineHint ?? "L1",
94
69
  });
95
70
  });
96
71
 
97
- bench("normalized fallback", () => {
98
- findAnchorWithFallback({
99
- source: LARGE_DOC,
100
- selectedText: WHITESPACE_TEXT,
101
- lineHint: "L250",
102
- });
72
+ bench("10 comments — exact hits", () => {
73
+ for (const c of COMMENTS_10) {
74
+ findAnchorWithFallback({
75
+ source: LARGE_DOC,
76
+ selectedText: c.selectedText,
77
+ lineHint: c.lineHint ?? "L1",
78
+ });
79
+ }
103
80
  });
104
81
 
105
- bench("fuzzy fallback", () => {
106
- findAnchorWithFallback({
107
- source: LARGE_DOC,
108
- selectedText: FUZZY_TEXT,
109
- lineHint: "L250",
110
- });
82
+ bench("50 comments — exact hits", () => {
83
+ for (const c of COMMENTS_50) {
84
+ findAnchorWithFallback({
85
+ source: LARGE_DOC,
86
+ selectedText: c.selectedText,
87
+ lineHint: c.lineHint ?? "L1",
88
+ });
89
+ }
111
90
  });
112
91
  });
@@ -64,12 +64,10 @@ describe("levenshteinDistance", () => {
64
64
  });
65
65
 
66
66
  it("returns Infinity when exceeding maxDistance threshold", () => {
67
- // Length difference alone exceeds threshold
68
67
  expect(levenshteinDistance("hello", "hi", 1)).toBe(
69
68
  Number.POSITIVE_INFINITY,
70
69
  );
71
70
 
72
- // Content difference exceeds threshold during computation
73
71
  expect(levenshteinDistance("hello", "world", 2)).toBe(
74
72
  Number.POSITIVE_INFINITY,
75
73
  );
@@ -135,8 +133,12 @@ describe("parseLineHint", () => {
135
133
  });
136
134
 
137
135
  it("parses line range hint", () => {
136
+ expect(parseLineHint("L42-L45")).toEqual({ start: 42, end: 45 });
137
+ expect(parseLineHint("L10-L20")).toEqual({ start: 10, end: 20 });
138
+ });
139
+
140
+ it("parses legacy format without L prefix on end line", () => {
138
141
  expect(parseLineHint("L42-45")).toEqual({ start: 42, end: 45 });
139
- expect(parseLineHint("L10-20")).toEqual({ start: 10, end: 20 });
140
142
  });
141
143
 
142
144
  it("returns default for invalid hint", () => {
@@ -248,7 +250,6 @@ line eight`;
248
250
 
249
251
  describe("findAnchorNormalized", () => {
250
252
  it("finds text with collapsed whitespace", () => {
251
- // Original had "hello world" but source was reformatted
252
253
  const source = "hello\n world";
253
254
  const result = findAnchorNormalized({
254
255
  source,
@@ -271,7 +272,6 @@ describe("findAnchorNormalized", () => {
271
272
  });
272
273
 
273
274
  it("returns null when text has no collapsible whitespace", () => {
274
- // If original text has no extra whitespace, exact match would have worked
275
275
  const result = findAnchorNormalized({
276
276
  source: "hello world",
277
277
  selectedText: "hello world",
@@ -407,7 +407,6 @@ describe("findAnchorWithFallback", () => {
407
407
  });
408
408
 
409
409
  it("falls back to normalized match when exact fails", () => {
410
- // Source was reformatted (newlines instead of spaces)
411
410
  const result = findAnchorWithFallback({
412
411
  source: "hello\nworld",
413
412
  selectedText: "hello world",
@@ -440,7 +439,6 @@ describe("findAnchorWithFallback", () => {
440
439
  describe("findClosestOccurrence", () => {
441
440
  it("finds the occurrence closest to hint", () => {
442
441
  const content = "the cat sat on the mat and the rat";
443
- // "the" appears at positions 0, 15, and 27
444
442
 
445
443
  const result = findClosestOccurrence({
446
444
  source: content,
@@ -448,7 +446,6 @@ describe("findClosestOccurrence", () => {
448
446
  lineHint: "L1",
449
447
  });
450
448
  expect(result).not.toBeUndefined();
451
- // Should find the first "the" at position 0 since hint is L1
452
449
  expect(result?.start).toBe(0);
453
450
  });
454
451
 
@@ -459,7 +456,6 @@ line three the
459
456
  line four
460
457
  line five the`;
461
458
 
462
- // Test finding closest to line 3
463
459
  const result = findClosestOccurrence({
464
460
  source: content,
465
461
  selectedText: "the",
package/src/lib/anchor.ts CHANGED
@@ -1,16 +1,11 @@
1
- import { type Anchor, AnchorConfidences } from "../types";
1
+ import { type Anchor, AnchorConfidences } from "../schema";
2
2
  import { getLineNumber } from "./comment-storage";
3
3
 
4
- // Anchor matching configuration
5
- const DEFAULT_SEARCH_WINDOW = 500; // chars before/after line hint for exact match
6
- const DEFAULT_FUZZY_THRESHOLD = 5; // max Levenshtein distance for fuzzy match
7
- const MAX_FUZZY_TEXT_LENGTH = 200; // skip fuzzy matching for texts longer than this
8
- const FUZZY_SEARCH_WINDOW = 2000; // larger window for fuzzy search near line hint
4
+ const DEFAULT_SEARCH_WINDOW = 500;
5
+ const DEFAULT_FUZZY_THRESHOLD = 5;
6
+ const MAX_FUZZY_TEXT_LENGTH = 200;
7
+ const FUZZY_SEARCH_WINDOW = 2000;
9
8
 
10
- /**
11
- * Common parameters for anchor finding functions.
12
- * Using object destructuring per style guide §3.5 for multiple string parameters.
13
- */
14
9
  export interface FindAnchorParams {
15
10
  source: string;
16
11
  selectedText: string;
@@ -32,27 +27,15 @@ export interface FindAnchorWithFallbackParams {
32
27
  fuzzyThreshold?: number;
33
28
  }
34
29
 
35
- /**
36
- * Normalize whitespace for comparison: collapse runs of whitespace to single space.
37
- * This allows matching text that was reformatted (line breaks, indentation changes).
38
- */
39
30
  export function normalizeWhitespace(text: string): string {
40
31
  return text.replace(/\s+/g, " ").trim();
41
32
  }
42
33
 
43
- /**
44
- * Calculate Levenshtein distance between two strings.
45
- * Uses Wagner-Fischer algorithm with O(min(m,n)) space.
46
- *
47
- * @param maxDistance Optional early exit threshold. If set, returns Infinity
48
- * when distance is guaranteed to exceed this value.
49
- */
50
34
  export function levenshteinDistance(
51
35
  a: string,
52
36
  b: string,
53
37
  maxDistance?: number,
54
38
  ): number {
55
- // Ensure a is the shorter string for space optimization
56
39
  if (a.length > b.length) {
57
40
  [a, b] = [b, a];
58
41
  }
@@ -60,20 +43,16 @@ export function levenshteinDistance(
60
43
  const m = a.length;
61
44
  const n = b.length;
62
45
 
63
- // Early termination for empty strings
64
46
  if (m === 0) return n;
65
47
  if (n === 0) return m;
66
48
 
67
- // Early exit: length difference alone exceeds threshold
68
49
  if (maxDistance !== undefined && Math.abs(m - n) > maxDistance) {
69
50
  return Number.POSITIVE_INFINITY;
70
51
  }
71
52
 
72
- // Use single row for space optimization
73
53
  let prevRow = new Array(m + 1);
74
54
  let currRow = new Array(m + 1);
75
55
 
76
- // Initialize first row
77
56
  for (let i = 0; i <= m; i++) {
78
57
  prevRow[i] = i;
79
58
  }
@@ -81,20 +60,18 @@ export function levenshteinDistance(
81
60
  for (let j = 1; j <= n; j++) {
82
61
  currRow[0] = j;
83
62
 
84
- // Track minimum value in this row for early exit
85
63
  let rowMin = currRow[0];
86
64
 
87
65
  for (let i = 1; i <= m; i++) {
88
66
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
89
67
  currRow[i] = Math.min(
90
- prevRow[i] + 1, // deletion
91
- currRow[i - 1] + 1, // insertion
92
- prevRow[i - 1] + cost, // substitution
68
+ prevRow[i] + 1,
69
+ currRow[i - 1] + 1,
70
+ prevRow[i - 1] + cost,
93
71
  );
94
72
  rowMin = Math.min(rowMin, currRow[i]);
95
73
  }
96
74
 
97
- // Early exit: minimum possible distance exceeds threshold
98
75
  if (maxDistance !== undefined && rowMin > maxDistance) {
99
76
  return Number.POSITIVE_INFINITY;
100
77
  }
@@ -105,9 +82,6 @@ export function levenshteinDistance(
105
82
  return prevRow[m];
106
83
  }
107
84
 
108
- /**
109
- * Get character offset for the start of a line number (1-indexed).
110
- */
111
85
  export function getLineOffset(content: string, lineNumber: number): number {
112
86
  if (lineNumber <= 1) return 0;
113
87
 
@@ -125,15 +99,11 @@ export function getLineOffset(content: string, lineNumber: number): number {
125
99
  return content.length;
126
100
  }
127
101
 
128
- /**
129
- * Parse line hint string to get line number(s).
130
- * Supports "L42" and "L42-45" formats.
131
- */
132
102
  export function parseLineHint(lineHint: string): {
133
103
  start: number;
134
104
  end: number;
135
105
  } {
136
- const match = lineHint.match(/^L(\d+)(?:-(\d+))?$/);
106
+ const match = lineHint.match(/^L(\d+)(?:-L?(\d+))?$/);
137
107
  if (!match) {
138
108
  return { start: 1, end: 1 };
139
109
  }
@@ -143,10 +113,6 @@ export function parseLineHint(lineHint: string): {
143
113
  return { start, end };
144
114
  }
145
115
 
146
- /**
147
- * Find anchor position for selected text in source content.
148
- * Uses line hint for fast lookup, falls back to global search.
149
- */
150
116
  export function findAnchor({
151
117
  source,
152
118
  selectedText,
@@ -158,7 +124,6 @@ export function findAnchor({
158
124
  }
159
125
  const { start: hintLine } = parseLineHint(lineHint);
160
126
 
161
- // Fast path: search near line hint
162
127
  const lineOffset = getLineOffset(source, hintLine);
163
128
  const windowStart = Math.max(0, lineOffset - searchWindow);
164
129
  const windowEnd = Math.min(source.length, lineOffset + searchWindow);
@@ -176,7 +141,6 @@ export function findAnchor({
176
141
  };
177
142
  }
178
143
 
179
- // Fallback: global search
180
144
  const globalIndex = source.indexOf(selectedText);
181
145
  if (globalIndex !== -1) {
182
146
  return {
@@ -190,10 +154,6 @@ export function findAnchor({
190
154
  return undefined;
191
155
  }
192
156
 
193
- /**
194
- * Build a position map from normalized string positions back to original positions.
195
- * Returns array where normalizedToOriginal[i] = original position for normalized char i.
196
- */
197
157
  function buildNormalizedPositionMap(text: string): {
198
158
  normalized: string;
199
159
  toOriginal: number[];
@@ -208,7 +168,6 @@ function buildNormalizedPositionMap(text: string): {
208
168
 
209
169
  if (isSpace) {
210
170
  if (!inWhitespace && normalized.length > 0) {
211
- // First whitespace after content - emit single space
212
171
  normalized += " ";
213
172
  toOriginal.push(i);
214
173
  }
@@ -220,7 +179,6 @@ function buildNormalizedPositionMap(text: string): {
220
179
  }
221
180
  }
222
181
 
223
- // Trim trailing space
224
182
  if (normalized.endsWith(" ")) {
225
183
  normalized = normalized.slice(0, -1);
226
184
  toOriginal.pop();
@@ -229,16 +187,6 @@ function buildNormalizedPositionMap(text: string): {
229
187
  return { normalized, toOriginal };
230
188
  }
231
189
 
232
- /**
233
- * Find anchor using whitespace-normalized matching.
234
- * Useful when document was reformatted but content is unchanged.
235
- * Returns "normalized" confidence level.
236
- *
237
- * Algorithm:
238
- * 1. Normalize source and build position map
239
- * 2. Find normalized text in normalized source (fast substring search)
240
- * 3. Map positions back to original source
241
- */
242
190
  export function findAnchorNormalized({
243
191
  source,
244
192
  selectedText,
@@ -254,31 +202,24 @@ export function findAnchorNormalized({
254
202
  return undefined;
255
203
  }
256
204
 
257
- // Skip if text has no collapsible whitespace (exact match would have worked)
258
205
  if (normalizedText === selectedText) {
259
206
  return undefined;
260
207
  }
261
208
  const { start: hintLine } = parseLineHint(lineHint);
262
209
  const lineOffset = getLineOffset(source, hintLine);
263
210
 
264
- // Define search window
265
211
  const windowStart = Math.max(0, lineOffset - searchWindow);
266
212
  const windowEnd = Math.min(source.length, lineOffset + searchWindow);
267
213
  const window = source.slice(windowStart, windowEnd);
268
214
 
269
- // Build normalized version with position mapping
270
215
  const { normalized: normalizedWindow, toOriginal } =
271
216
  buildNormalizedPositionMap(window);
272
217
 
273
- // Fast substring search on normalized text
274
218
  const normalizedIndex = normalizedWindow.indexOf(normalizedText);
275
219
  if (normalizedIndex !== -1) {
276
- // Map back to original positions
277
220
  const originalStart = windowStart + toOriginal[normalizedIndex];
278
221
  const endNormIndex = normalizedIndex + normalizedText.length - 1;
279
- // Find original end: scan forward from mapped position to include trailing whitespace
280
222
  let originalEnd = windowStart + toOriginal[endNormIndex] + 1;
281
- // Extend to include any trailing whitespace that was collapsed
282
223
  while (originalEnd < source.length && /\s/.test(source[originalEnd])) {
283
224
  originalEnd++;
284
225
  }
@@ -291,7 +232,6 @@ export function findAnchorNormalized({
291
232
  };
292
233
  }
293
234
 
294
- // Global fallback (outside hint window)
295
235
  const { normalized: fullNormalized, toOriginal: fullToOriginal } =
296
236
  buildNormalizedPositionMap(source);
297
237
  const globalIndex = fullNormalized.indexOf(normalizedText);
@@ -314,10 +254,6 @@ export function findAnchorNormalized({
314
254
  return undefined;
315
255
  }
316
256
 
317
- /**
318
- * Find anchor using fuzzy matching with Levenshtein distance.
319
- * Scans the source for substrings similar to the selected text.
320
- */
321
257
  export function findAnchorFuzzy({
322
258
  source,
323
259
  selectedText,
@@ -330,7 +266,6 @@ export function findAnchorFuzzy({
330
266
 
331
267
  const textLen = selectedText.length;
332
268
 
333
- // For very long texts, skip fuzzy matching (too expensive)
334
269
  if (textLen > MAX_FUZZY_TEXT_LENGTH) {
335
270
  return undefined;
336
271
  }
@@ -338,26 +273,22 @@ export function findAnchorFuzzy({
338
273
  let bestMatch: Anchor | undefined;
339
274
  let bestDistance = threshold + 1;
340
275
 
341
- // Determine search range based on line hint
342
276
  let searchStart = 0;
343
277
  let searchEnd = source.length;
344
278
 
345
279
  if (lineHint) {
346
280
  const { start: hintLine } = parseLineHint(lineHint);
347
281
  const lineOffset = getLineOffset(source, hintLine);
348
- // Search in a larger window for fuzzy matching
349
282
  searchStart = Math.max(0, lineOffset - FUZZY_SEARCH_WINDOW);
350
283
  searchEnd = Math.min(source.length, lineOffset + FUZZY_SEARCH_WINDOW);
351
284
  }
352
285
 
353
- // Slide window of similar length through the search range
354
286
  const minLen = Math.max(1, textLen - threshold);
355
287
  const maxLen = textLen + threshold;
356
288
 
357
289
  for (let len = minLen; len <= maxLen; len++) {
358
290
  for (let i = searchStart; i <= searchEnd - len; i++) {
359
291
  const candidate = source.slice(i, i + len);
360
- // Use early exit: only compute if distance could improve on current best
361
292
  const distance = levenshteinDistance(
362
293
  selectedText,
363
294
  candidate,
@@ -374,7 +305,6 @@ export function findAnchorFuzzy({
374
305
  distance,
375
306
  };
376
307
 
377
- // Early exit if we found an exact match
378
308
  if (distance === 0) {
379
309
  return bestMatch;
380
310
  }
@@ -385,27 +315,17 @@ export function findAnchorFuzzy({
385
315
  return bestMatch;
386
316
  }
387
317
 
388
- /**
389
- * Find anchor with fallback chain: exact → normalized → fuzzy.
390
- *
391
- * Matching strategies in order of preference:
392
- * 1. Exact: Fast substring match (O(n))
393
- * 2. Normalized: Whitespace-collapsed match for reformatted text (O(n))
394
- * 3. Fuzzy: Levenshtein distance for small edits (O(n × m × threshold))
395
- */
396
318
  export function findAnchorWithFallback({
397
319
  source,
398
320
  selectedText,
399
321
  lineHint,
400
322
  fuzzyThreshold,
401
323
  }: FindAnchorWithFallbackParams): Anchor | undefined {
402
- // Try exact match first (fastest)
403
324
  const exactMatch = findAnchor({ source, selectedText, lineHint });
404
325
  if (exactMatch) {
405
326
  return exactMatch;
406
327
  }
407
328
 
408
- // Try normalized match (handles reformatting)
409
329
  const normalizedMatch = findAnchorNormalized({
410
330
  source,
411
331
  selectedText,
@@ -415,7 +335,6 @@ export function findAnchorWithFallback({
415
335
  return normalizedMatch;
416
336
  }
417
337
 
418
- // Fall back to fuzzy matching (handles small edits)
419
338
  return findAnchorFuzzy({
420
339
  source,
421
340
  selectedText,
@@ -424,9 +343,6 @@ export function findAnchorWithFallback({
424
343
  });
425
344
  }
426
345
 
427
- /**
428
- * Find the closest match when multiple occurrences exist.
429
- */
430
346
  export function findClosestOccurrence({
431
347
  source,
432
348
  selectedText,
@@ -4,12 +4,12 @@ import {
4
4
  COMMENT_FILE_MEDIUM,
5
5
  COMMENT_FILE_OBJ_LARGE,
6
6
  COMMENT_FILE_OBJ_MEDIUM,
7
+ COMMENT_FILE_OBJ_SMALL,
7
8
  COMMENT_FILE_SMALL,
8
9
  LARGE_DOC,
9
10
  } from "./__fixtures__/bench-data";
10
11
  import {
11
12
  computeHash,
12
- createComment,
13
13
  parseCommentFile,
14
14
  serializeComments,
15
15
  } from "./comment-storage";
@@ -29,6 +29,10 @@ describe("parseCommentFile", () => {
29
29
  });
30
30
 
31
31
  describe("serializeComments", () => {
32
+ bench("1 comment", () => {
33
+ serializeComments(COMMENT_FILE_OBJ_SMALL);
34
+ });
35
+
32
36
  bench("10 comments", () => {
33
37
  serializeComments(COMMENT_FILE_OBJ_MEDIUM);
34
38
  });
@@ -39,25 +43,7 @@ describe("serializeComments", () => {
39
43
  });
40
44
 
41
45
  describe("computeHash", () => {
42
- const shortString = "x".repeat(100);
43
-
44
- bench("short string (100 chars)", () => {
45
- computeHash(shortString);
46
- });
47
-
48
- bench("large doc (~10k chars)", () => {
46
+ bench("300-line document", () => {
49
47
  computeHash(LARGE_DOC);
50
48
  });
51
49
  });
52
-
53
- describe("createComment", () => {
54
- bench("short selection", () => {
55
- createComment("selected text here", "my comment", 100, 118, LARGE_DOC);
56
- });
57
-
58
- const longSelection = "a".repeat(2000);
59
-
60
- bench("long selection (triggers truncation)", () => {
61
- createComment(longSelection, "my comment", 0, 2000, LARGE_DOC);
62
- });
63
- });