From 4d9273f1e059b4127ee2d97ab33b294f9f6e64d6 Mon Sep 17 00:00:00 2001 From: spenpal Date: Wed, 2 Jul 2025 16:18:31 -0500 Subject: [PATCH 01/12] feat: implement bracket syntax detection with migration errors - Add regex detection for new path[selector] syntax - Implement backwards compatibility detection for old colon syntax - Add helpful migration error messages for deprecated syntax - Support colons in file paths when not followed by line selectors - All tests passing (22/22) including new migration error tests Part of Python-like slicing syntax implementation (Task 1.1) --- plugin/src/parseIncludeExampleTag.test.ts | 31 ++++++++++--- plugin/src/parseIncludeExampleTag.ts | 53 ++++++++++++++++------- 2 files changed, 61 insertions(+), 23 deletions(-) diff --git a/plugin/src/parseIncludeExampleTag.test.ts b/plugin/src/parseIncludeExampleTag.test.ts index 5ae3f61..9a1af5f 100644 --- a/plugin/src/parseIncludeExampleTag.test.ts +++ b/plugin/src/parseIncludeExampleTag.test.ts @@ -2,20 +2,37 @@ import { expect, test } from "vitest"; import { parseIncludeExampleTag } from "./parseIncludeExampleTag.js"; test("it should parse include example tag", () => { - const result = parseIncludeExampleTag("path/to/file"); - expect(result).toEqual({ path: "path/to/file" }); + const result = parseIncludeExampleTag("path/to/file"); + expect(result).toEqual({ path: "path/to/file" }); }); test("it should parse include example tag with a line selector", () => { - const result = parseIncludeExampleTag("path/to/file:2-4"); - expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4] }); + const result = parseIncludeExampleTag("path/to/file[2-4]"); + expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4] }); }); test("it should parse include example tag with multiple line selectors", () => { - const result = parseIncludeExampleTag("path/to/file:2-4,15"); - expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4, 15] }); + const result = parseIncludeExampleTag("path/to/file[2-4,15]"); + expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4, 15] }); }); test("it should throw on empty path", () => { - expect(() => parseIncludeExampleTag("")).toThrowError("Path not found !"); + expect(() => parseIncludeExampleTag("")).toThrowError("Path not found !"); +}); + +test("it should throw migration error for old colon syntax", () => { + expect(() => parseIncludeExampleTag("path/to/file:2-4")).toThrowError( + /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4' is no longer supported/ + ); +}); + +test("it should throw migration error for old colon syntax with multiple selectors", () => { + expect(() => parseIncludeExampleTag("path/to/file:2-4,15")).toThrowError( + /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4,15' is no longer supported/ + ); +}); + +test("it should allow colons in file paths without selectors", () => { + const result = parseIncludeExampleTag("path:with:colons/file.ts"); + expect(result).toEqual({ path: "path:with:colons/file.ts" }); }); diff --git a/plugin/src/parseIncludeExampleTag.ts b/plugin/src/parseIncludeExampleTag.ts index 303d2b6..968a57c 100644 --- a/plugin/src/parseIncludeExampleTag.ts +++ b/plugin/src/parseIncludeExampleTag.ts @@ -2,26 +2,47 @@ import type { IncludeExampleTag } from "./IncludeExampleTag.js"; import { parseLineSelector } from "./parseLineSelector.js"; export function parseIncludeExampleTag( - tag: string, - filePath?: string, + tag: string, + filePath?: string ): IncludeExampleTag { - const splittedTag = tag.split(":")[Symbol.iterator](); - const path: string | undefined = splittedTag.next().value || filePath; + // Check for new bracket syntax: path/to/file[selector] + const bracketMatch = tag.match(/^(.+?)\[(.+)\]$/); - if (!path) { - throw new Error("Path not found !"); - } + if (bracketMatch) { + // New bracket syntax + const [, path, selectorString] = bracketMatch; + const includeExampleTag: IncludeExampleTag = { path }; - const includeExampleTag: IncludeExampleTag = { path }; - const lineNumbersString: string | undefined = splittedTag.next().value; + // Parse the selector string (will be enhanced in next task) + includeExampleTag.lines = selectorString + .split(",") + .flatMap(parseLineSelector); - if (lineNumbersString === undefined) { - return includeExampleTag; - } + return includeExampleTag; + } - includeExampleTag.lines = lineNumbersString - .split(",") - .flatMap(parseLineSelector); + // Check for old colon syntax: path/to/file:selector + // Only treat as selector if it looks like line numbers/ranges + const colonIndex = tag.lastIndexOf(":"); + if (colonIndex !== -1) { + const potentialPath = tag.substring(0, colonIndex); + const potentialSelector = tag.substring(colonIndex + 1); - return includeExampleTag; + // Check if the part after colon looks like a line selector + if (potentialSelector.trim() && /^[\d\-,\s]+$/.test(potentialSelector)) { + // This looks like old colon syntax with line selectors + throw new Error( + `BREAKING CHANGE: The colon syntax '${tag}' is no longer supported. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector}]'. See documentation for the new Python-like slicing syntax.` + ); + } + } + + // No selector syntax - use entire file + const path: string | undefined = tag || filePath; + + if (!path) { + throw new Error("Path not found !"); + } + + return { path }; } From 362c9f9bef1fe1bae59d4652d0e84b9948acc023 Mon Sep 17 00:00:00 2001 From: spenpal Date: Wed, 2 Jul 2025 18:52:44 -0500 Subject: [PATCH 02/12] feat: enhance line selection parsing and update example syntax - Refactor parsing logic to support new Python-like syntax for line selectors - Implement backwards compatibility for old dash syntax - Update example tags in Author, Chapter, and Library classes to use new bracket syntax - Add new interfaces for line selection and parsed line selectors - Introduce resolveLineSelections function for handling parsed selections - Ensure all tests pass, including new cases for the updated syntax --- demo/src/Author.ts | 12 +- demo/src/Chapter.ts | 2 +- demo/src/Library.ts | 18 +- plugin/src/IncludeExampleTag.ts | 3 + plugin/src/LineSelection.ts | 7 + plugin/src/ParsedLineSelector.ts | 7 + plugin/src/applyLineSelection.ts | 24 ++ plugin/src/parseIncludeExampleTag.test.ts | 30 +-- plugin/src/parseIncludeExampleTag.ts | 91 ++++--- plugin/src/parseLineSelector.test.ts | 313 +++++++++++++++++++++- plugin/src/parseLineSelector.ts | 185 ++++++++++++- plugin/src/resolveLineSelections.ts | 114 ++++++++ 12 files changed, 708 insertions(+), 98 deletions(-) create mode 100644 plugin/src/LineSelection.ts create mode 100644 plugin/src/ParsedLineSelector.ts create mode 100644 plugin/src/resolveLineSelections.ts diff --git a/demo/src/Author.ts b/demo/src/Author.ts index 5a80afd..b2c20a8 100644 --- a/demo/src/Author.ts +++ b/demo/src/Author.ts @@ -3,13 +3,13 @@ import type { Book } from "./Book"; /** * A class representing an author. * - * @includeExample ./src/Author.example.ts:7 + * @includeExample ./src/Author.example.ts[7] */ export class Author { - books: Book[] = []; + books: Book[] = []; - addBook(book: Book): Author { - this.books.push(book); - return this; - } + addBook(book: Book): Author { + this.books.push(book); + return this; + } } diff --git a/demo/src/Chapter.ts b/demo/src/Chapter.ts index 7cba35d..4a261db 100644 --- a/demo/src/Chapter.ts +++ b/demo/src/Chapter.ts @@ -1,6 +1,6 @@ /** * Class representing a chapter. * - * @includeExample ./src/Chapter.example.ts:5-7,11,13 + * @includeExample ./src/Chapter.example.ts[5-7,11,13] */ export class Chapter {} diff --git a/demo/src/Library.ts b/demo/src/Library.ts index 29139c4..86da4d1 100644 --- a/demo/src/Library.ts +++ b/demo/src/Library.ts @@ -3,17 +3,17 @@ import type { Book } from "./Book"; /** * A class representing a library. * - * @includeExample ./src/Library.example.ts:5-9 + * @includeExample ./src/Library.example.ts[5-9] */ export class Library { - books: Book[] = []; + books: Book[] = []; - addBook(book: Book): Library { - this.books.push(book); - return this; - } + addBook(book: Book): Library { + this.books.push(book); + return this; + } - listBooks() { - return this.books.map((book) => book.title).join(", "); - } + listBooks() { + return this.books.map((book) => book.title).join(", "); + } } diff --git a/plugin/src/IncludeExampleTag.ts b/plugin/src/IncludeExampleTag.ts index f35d8a4..98d48da 100644 --- a/plugin/src/IncludeExampleTag.ts +++ b/plugin/src/IncludeExampleTag.ts @@ -1,4 +1,7 @@ +import type { ParsedLineSelector } from "./ParsedLineSelector.js"; + export interface IncludeExampleTag { path: string; lines?: number[]; + parsedSelector?: ParsedLineSelector; } diff --git a/plugin/src/LineSelection.ts b/plugin/src/LineSelection.ts new file mode 100644 index 0000000..79aab1d --- /dev/null +++ b/plugin/src/LineSelection.ts @@ -0,0 +1,7 @@ +export interface LineSelection { + type: "inclusion" | "exclusion"; + start?: number; + end?: number; + single?: number; + isNegative?: boolean; +} diff --git a/plugin/src/ParsedLineSelector.ts b/plugin/src/ParsedLineSelector.ts new file mode 100644 index 0000000..2294254 --- /dev/null +++ b/plugin/src/ParsedLineSelector.ts @@ -0,0 +1,7 @@ +import type { LineSelection } from "./LineSelection.js"; + +export interface ParsedLineSelector { + selections: LineSelection[]; + hasNegativeIndexing: boolean; + hasExclusions: boolean; +} diff --git a/plugin/src/applyLineSelection.ts b/plugin/src/applyLineSelection.ts index aed5b70..592faf6 100644 --- a/plugin/src/applyLineSelection.ts +++ b/plugin/src/applyLineSelection.ts @@ -1,4 +1,5 @@ import type { IncludeExampleTag } from "./IncludeExampleTag.js"; +import { resolveLineSelections } from "./resolveLineSelections.js"; export function applyLineSelection( content: string, @@ -6,6 +7,29 @@ export function applyLineSelection( ): string { const lines = content.split("\n"); + // Handle new parsed selector syntax + if (includeExampleTag.parsedSelector) { + const resolvedLines = resolveLineSelections( + includeExampleTag.parsedSelector, + lines.length, + ); + + return resolvedLines + .map((lineNumber: number) => { + const line = lines[lineNumber - 1]; + + if (line === undefined) { + throw new Error( + `Line number ${lineNumber} is out of range for file ${includeExampleTag.path}`, + ); + } + + return line; + }) + .join("\n"); + } + + // Handle old lines array (backwards compatibility) if (includeExampleTag.lines === undefined) { return content; } diff --git a/plugin/src/parseIncludeExampleTag.test.ts b/plugin/src/parseIncludeExampleTag.test.ts index 9a1af5f..301469a 100644 --- a/plugin/src/parseIncludeExampleTag.test.ts +++ b/plugin/src/parseIncludeExampleTag.test.ts @@ -2,37 +2,37 @@ import { expect, test } from "vitest"; import { parseIncludeExampleTag } from "./parseIncludeExampleTag.js"; test("it should parse include example tag", () => { - const result = parseIncludeExampleTag("path/to/file"); - expect(result).toEqual({ path: "path/to/file" }); + const result = parseIncludeExampleTag("path/to/file"); + expect(result).toEqual({ path: "path/to/file" }); }); test("it should parse include example tag with a line selector", () => { - const result = parseIncludeExampleTag("path/to/file[2-4]"); - expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4] }); + const result = parseIncludeExampleTag("path/to/file[2-4]"); + expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4] }); }); test("it should parse include example tag with multiple line selectors", () => { - const result = parseIncludeExampleTag("path/to/file[2-4,15]"); - expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4, 15] }); + const result = parseIncludeExampleTag("path/to/file[2-4,15]"); + expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4, 15] }); }); test("it should throw on empty path", () => { - expect(() => parseIncludeExampleTag("")).toThrowError("Path not found !"); + expect(() => parseIncludeExampleTag("")).toThrowError("Path not found !"); }); test("it should throw migration error for old colon syntax", () => { - expect(() => parseIncludeExampleTag("path/to/file:2-4")).toThrowError( - /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4' is no longer supported/ - ); + expect(() => parseIncludeExampleTag("path/to/file:2-4")).toThrowError( + /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4' is no longer supported/, + ); }); test("it should throw migration error for old colon syntax with multiple selectors", () => { - expect(() => parseIncludeExampleTag("path/to/file:2-4,15")).toThrowError( - /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4,15' is no longer supported/ - ); + expect(() => parseIncludeExampleTag("path/to/file:2-4,15")).toThrowError( + /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4,15' is no longer supported/, + ); }); test("it should allow colons in file paths without selectors", () => { - const result = parseIncludeExampleTag("path:with:colons/file.ts"); - expect(result).toEqual({ path: "path:with:colons/file.ts" }); + const result = parseIncludeExampleTag("path:with:colons/file.ts"); + expect(result).toEqual({ path: "path:with:colons/file.ts" }); }); diff --git a/plugin/src/parseIncludeExampleTag.ts b/plugin/src/parseIncludeExampleTag.ts index 968a57c..88ee963 100644 --- a/plugin/src/parseIncludeExampleTag.ts +++ b/plugin/src/parseIncludeExampleTag.ts @@ -2,47 +2,54 @@ import type { IncludeExampleTag } from "./IncludeExampleTag.js"; import { parseLineSelector } from "./parseLineSelector.js"; export function parseIncludeExampleTag( - tag: string, - filePath?: string + tag: string, + filePath?: string, ): IncludeExampleTag { - // Check for new bracket syntax: path/to/file[selector] - const bracketMatch = tag.match(/^(.+?)\[(.+)\]$/); - - if (bracketMatch) { - // New bracket syntax - const [, path, selectorString] = bracketMatch; - const includeExampleTag: IncludeExampleTag = { path }; - - // Parse the selector string (will be enhanced in next task) - includeExampleTag.lines = selectorString - .split(",") - .flatMap(parseLineSelector); - - return includeExampleTag; - } - - // Check for old colon syntax: path/to/file:selector - // Only treat as selector if it looks like line numbers/ranges - const colonIndex = tag.lastIndexOf(":"); - if (colonIndex !== -1) { - const potentialPath = tag.substring(0, colonIndex); - const potentialSelector = tag.substring(colonIndex + 1); - - // Check if the part after colon looks like a line selector - if (potentialSelector.trim() && /^[\d\-,\s]+$/.test(potentialSelector)) { - // This looks like old colon syntax with line selectors - throw new Error( - `BREAKING CHANGE: The colon syntax '${tag}' is no longer supported. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector}]'. See documentation for the new Python-like slicing syntax.` - ); - } - } - - // No selector syntax - use entire file - const path: string | undefined = tag || filePath; - - if (!path) { - throw new Error("Path not found !"); - } - - return { path }; + // Check for new bracket syntax: path/to/file[selector] + const bracketMatch = tag.match(/^(.+?)\[(.+)\]$/); + + if (bracketMatch) { + // New bracket syntax + const [, path, selectorString] = bracketMatch; + const includeExampleTag: IncludeExampleTag = { path }; + + // Parse the selector string using new Python-like syntax + const parsed = parseLineSelector(selectorString); + + // Handle the two possible return types + if (Array.isArray(parsed)) { + // Old dash syntax returned number[] directly + includeExampleTag.lines = parsed; + } else { + // New syntax returned ParsedLineSelector - store for later resolution + includeExampleTag.parsedSelector = parsed; + } + + return includeExampleTag; + } + + // Check for old colon syntax: path/to/file:selector + // Only treat as selector if it looks like line numbers/ranges + const colonIndex = tag.lastIndexOf(":"); + if (colonIndex !== -1) { + const potentialPath = tag.substring(0, colonIndex); + const potentialSelector = tag.substring(colonIndex + 1); + + // Check if the part after colon looks like a line selector + if (potentialSelector.trim() && /^[\d\-,\s]+$/.test(potentialSelector)) { + // This looks like old colon syntax with line selectors + throw new Error( + `BREAKING CHANGE: The colon syntax '${tag}' is no longer supported. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector}]'. See documentation for the new Python-like slicing syntax.`, + ); + } + } + + // No selector syntax - use entire file + const path: string | undefined = tag || filePath; + + if (!path) { + throw new Error("Path not found !"); + } + + return { path }; } diff --git a/plugin/src/parseLineSelector.test.ts b/plugin/src/parseLineSelector.test.ts index a91d561..78b0412 100644 --- a/plugin/src/parseLineSelector.test.ts +++ b/plugin/src/parseLineSelector.test.ts @@ -1,70 +1,355 @@ import { expect, test } from "vitest"; +import type { ParsedLineSelector } from "./ParsedLineSelector.js"; import { parseLineSelector } from "./parseLineSelector.js"; +import { resolveLineSelections } from "./resolveLineSelections.js"; -test("Should parse a line", () => { +// ============= BACKWARDS COMPATIBILITY TESTS (Old Dash Syntax) ============= + +test("Should parse a line (old syntax)", () => { const result = parseLineSelector("15"); + expect(Array.isArray(result)).toBe(true); expect(result).toEqual([15]); }); -test("Should parse a when equal to 1", () => { +test("Should parse a line when equal to 1 (old syntax)", () => { const result = parseLineSelector("1"); + expect(Array.isArray(result)).toBe(true); expect(result).toEqual([1]); }); -test("Should parse a range of lines", () => { +test("Should parse a range of lines (old syntax)", () => { const result = parseLineSelector("2-4"); + expect(Array.isArray(result)).toBe(true); expect(result).toEqual([2, 3, 4]); }); -test("Should parse a range of lines starting with 1", () => { +test("Should parse a range of lines starting with 1 (old syntax)", () => { const result = parseLineSelector("1-4"); + expect(Array.isArray(result)).toBe(true); expect(result).toEqual([1, 2, 3, 4]); }); -test("Should throw error on missing range start", () => { - expect(() => parseLineSelector("-4")).toThrowError( +test("Should throw error on missing range start (old syntax)", () => { + expect(() => parseLineSelector("x-4")).toThrowError( "Failed to parse range start !", ); }); -test("Should throw error on missing range end", () => { +test("Should throw error on missing range end (old syntax)", () => { expect(() => parseLineSelector("2-")).toThrowError( "Failed to parse range end !", ); }); -test("Should throw error on bad range start", () => { +test("Should throw error on bad range start (old syntax)", () => { expect(() => parseLineSelector("bad-4")).toThrowError( "Failed to parse range start !", ); }); -test("Should throw error on bad range end", () => { +test("Should throw error on bad range end (old syntax)", () => { expect(() => parseLineSelector("2-bad")).toThrowError( "Failed to parse range end !", ); }); -test("Should throw error on end being smaller than start", () => { +test("Should throw error on end being smaller than start (old syntax)", () => { expect(() => parseLineSelector("4-2")).toThrowError( "Range start is greater or equal to range end !", ); }); -test("Should throw error on end being equal to start", () => { +test("Should throw error on end being equal to start (old syntax)", () => { expect(() => parseLineSelector("2-2")).toThrowError( "Range start is greater or equal to range end !", ); }); -test("Should throw error on start being smaller than 1", () => { +test("Should throw error on start being smaller than 1 (old syntax)", () => { expect(() => parseLineSelector("0-2")).toThrowError( "Range start not positive !", ); }); -test("Should throw error on bad single line", () => { +test("Should throw error on bad single line (old syntax)", () => { expect(() => parseLineSelector("bad")).toThrowError( - "Failed to parse line number !", + "Invalid line number: bad", + ); +}); + +// ============= NEW PYTHON-LIKE SYNTAX TESTS ============= + +// Helper function to get ParsedLineSelector from result +function getParsedSelector( + result: number[] | ParsedLineSelector, +): ParsedLineSelector { + if (Array.isArray(result)) { + throw new Error("Expected ParsedLineSelector but got number[]"); + } + return result; +} + +// Single line tests +test("Should parse single positive line with colon syntax", () => { + const result = getParsedSelector(parseLineSelector("10:10")); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "inclusion", + start: 10, + end: 10, + isNegative: false, + }); + expect(result.hasNegativeIndexing).toBe(false); + expect(result.hasExclusions).toBe(false); +}); + +test("Should parse single negative line", () => { + const result = getParsedSelector(parseLineSelector("-5")); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "inclusion", + single: 5, + isNegative: true, + }); + expect(result.hasNegativeIndexing).toBe(true); + expect(result.hasExclusions).toBe(false); +}); + +// Range tests +test("Should parse open-ended range from start", () => { + const result = getParsedSelector(parseLineSelector("5:")); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "inclusion", + start: 5, + end: undefined, + isNegative: false, + }); +}); + +test("Should parse open-ended range to end", () => { + const result = getParsedSelector(parseLineSelector(":10")); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "inclusion", + start: undefined, + end: 10, + isNegative: false, + }); +}); + +test("Should parse closed range", () => { + const result = getParsedSelector(parseLineSelector("2:8")); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "inclusion", + start: 2, + end: 8, + isNegative: false, + }); +}); + +test("Should parse negative range", () => { + const result = getParsedSelector(parseLineSelector("-10:-5")); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "inclusion", + start: 10, + end: 5, + isNegative: true, + }); + expect(result.hasNegativeIndexing).toBe(true); +}); + +test("Should parse negative open-ended range", () => { + const result = getParsedSelector(parseLineSelector("-5:")); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "inclusion", + start: 5, + end: undefined, + isNegative: true, + }); + expect(result.hasNegativeIndexing).toBe(true); +}); + +// Multiple selections +test("Should parse multiple selections", () => { + const result = getParsedSelector(parseLineSelector("2:5,10,15:20")); + expect(result.selections).toHaveLength(3); + expect(result.selections[0]).toEqual({ + type: "inclusion", + start: 2, + end: 5, + isNegative: false, + }); + expect(result.selections[1]).toEqual({ + type: "inclusion", + single: 10, + isNegative: false, + }); + expect(result.selections[2]).toEqual({ + type: "inclusion", + start: 15, + end: 20, + isNegative: false, + }); +}); + +// Exclusion tests +test("Should parse exclusions", () => { + const result = getParsedSelector(parseLineSelector("1:10,!5:7")); + expect(result.selections).toHaveLength(2); + expect(result.selections[0]).toEqual({ + type: "inclusion", + start: 1, + end: 10, + isNegative: false, + }); + expect(result.selections[1]).toEqual({ + type: "exclusion", + start: 5, + end: 7, + isNegative: false, + }); + expect(result.hasExclusions).toBe(true); +}); + +test("Should parse single line exclusion", () => { + const result = getParsedSelector(parseLineSelector("1:10,!5")); + expect(result.selections).toHaveLength(2); + expect(result.selections[1]).toEqual({ + type: "exclusion", + single: 5, + isNegative: false, + }); + expect(result.hasExclusions).toBe(true); +}); + +// Empty selector tests +test("Should handle empty selector", () => { + const result = getParsedSelector(parseLineSelector("")); + expect(result.selections).toHaveLength(0); + expect(result.hasNegativeIndexing).toBe(false); + expect(result.hasExclusions).toBe(false); +}); + +test("Should handle colon-only selector", () => { + const result = getParsedSelector(parseLineSelector(":")); + expect(result.selections).toHaveLength(0); + expect(result.hasNegativeIndexing).toBe(false); + expect(result.hasExclusions).toBe(false); +}); + +// Error handling tests +test("Should throw error on invalid line number", () => { + expect(() => parseLineSelector("abc")).toThrowError( + "Invalid line number: abc", + ); +}); + +test("Should throw error on invalid range start", () => { + expect(() => parseLineSelector("abc:5")).toThrowError( + "Invalid range start: abc", + ); +}); + +test("Should throw error on invalid range end", () => { + expect(() => parseLineSelector("5:abc")).toThrowError( + "Invalid range end: abc", ); }); + +test("Should throw error on zero in range", () => { + expect(() => parseLineSelector("0:5")).toThrowError( + "Range start must be positive or negative, not zero", + ); +}); + +test("Should throw error on invalid positive range", () => { + expect(() => parseLineSelector("5:3")).toThrowError( + "Range start (5) must be less than or equal to range end (3)", + ); +}); + +// ============= LINE RESOLUTION TESTS ============= + +test("Should resolve single positive line", () => { + const parsed = getParsedSelector(parseLineSelector("5:5")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([5]); +}); + +test("Should resolve single negative line", () => { + const parsed = getParsedSelector(parseLineSelector("-2")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([9]); // 10 - 2 + 1 = 9 +}); + +test("Should resolve range", () => { + const parsed = getParsedSelector(parseLineSelector("3:6")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([3, 4, 5, 6]); +}); + +test("Should resolve open-ended range from start", () => { + const parsed = getParsedSelector(parseLineSelector("8:")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([8, 9, 10]); +}); + +test("Should resolve open-ended range to end", () => { + const parsed = getParsedSelector(parseLineSelector(":3")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([1, 2, 3]); +}); + +test("Should resolve negative range", () => { + const parsed = getParsedSelector(parseLineSelector("-5:-2")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([6, 7, 8, 9]); // Lines 6-9 (10-5+1 to 10-2+1) +}); + +test("Should resolve multiple selections", () => { + const parsed = getParsedSelector(parseLineSelector("2:4,8,10")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([2, 3, 4, 8, 10]); +}); + +test("Should resolve exclusions", () => { + const parsed = getParsedSelector(parseLineSelector("1:10,!5:7")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([1, 2, 3, 4, 8, 9, 10]); +}); + +test("Should resolve exclusions with all lines implicit", () => { + const parsed = getParsedSelector(parseLineSelector("!3,!7")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([1, 2, 4, 5, 6, 8, 9, 10]); +}); + +test("Should handle out of bounds positive line", () => { + const parsed = getParsedSelector(parseLineSelector("15:15")); + expect(() => resolveLineSelections(parsed, 10)).toThrowError( + "Line 15 is out of range (file has 10 lines)", + ); +}); + +test("Should handle out of bounds negative line", () => { + const parsed = getParsedSelector(parseLineSelector("-15")); + expect(() => resolveLineSelections(parsed, 10)).toThrowError( + "Line -15 is out of range (file has 10 lines)", + ); +}); + +test("Should handle empty file", () => { + const parsed = getParsedSelector(parseLineSelector("5:5")); + const result = resolveLineSelections(parsed, 0); + expect(result).toEqual([]); +}); + +test("Should resolve with bounds clamping for ranges", () => { + const parsed = getParsedSelector(parseLineSelector("8:15")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([8, 9, 10]); // Clamped to file bounds +}); diff --git a/plugin/src/parseLineSelector.ts b/plugin/src/parseLineSelector.ts index 8a4b153..aa2f019 100644 --- a/plugin/src/parseLineSelector.ts +++ b/plugin/src/parseLineSelector.ts @@ -1,6 +1,78 @@ -export function parseLineSelector(lineSelectorString: string): number[] { - if (lineSelectorString.includes("-")) { - const lineRange: string[] = lineSelectorString.split("-"); +import type { LineSelection } from "./LineSelection.js"; +import type { ParsedLineSelector } from "./ParsedLineSelector.js"; + +export function parseLineSelector( + lineSelectorString: string, +): number[] | ParsedLineSelector { + // Handle empty or whitespace-only selectors + const trimmed = lineSelectorString.trim(); + if (!trimmed || trimmed === ":") { + return { selections: [], hasNegativeIndexing: false, hasExclusions: false }; + } + + // Check if this is old syntax (backwards compatibility) + // Old syntax: single positive numbers, dash ranges, or comma-separated combinations + // But exclude single negative numbers and anything with colons or exclamations + if (isOldSyntax(trimmed)) { + return parseOldSyntax(trimmed); + } + + // Parse new Python-like syntax + return parseNewSlicingSyntax(trimmed); +} + +function isOldSyntax(selector: string): boolean { + // Single positive number + if (/^\d+$/.test(selector)) { + return true; + } + + // Contains new syntax features - definitely not old syntax + if (selector.includes(":") || selector.includes("!")) { + return false; + } + + // Single negative number - definitely new syntax + if (/^-\d+$/.test(selector)) { + return false; + } + + // Check if all comma-separated parts are old-style (numbers or dash ranges) + const parts = selector.split(",").map((p) => p.trim()); + return parts.every((part) => { + // Single positive number + if (/^\d+$/.test(part)) { + return true; + } + // Dash range (valid or malformed) - anything with dash that's not a single negative number + if (part.includes("-") && !/^-\d+$/.test(part)) { + return true; + } + return false; + }); +} + +function parseOldSyntax(selector: string): number[] { + const result: number[] = []; + + // Split by comma to handle multiple selectors + const parts = selector.split(",").map((p) => p.trim()); + + for (const part of parts) { + if (!part) continue; + + // Handle single line number + if (!part.includes("-")) { + const line = Number.parseInt(part); + if (!Number.isFinite(line)) { + throw new Error("Failed to parse line number !"); + } + result.push(line); + continue; + } + + // Handle dash range + const lineRange: string[] = part.split("-"); const startString: string | undefined = lineRange[0]; const endString: string | undefined = lineRange[1]; const start: number = Number.parseInt(startString); @@ -22,20 +94,111 @@ export function parseLineSelector(lineSelectorString: string): number[] { throw new Error("Range start is greater or equal to range end !"); } - const range: number[] = []; - for (let i = start; i <= end; i++) { - range.push(i); + result.push(i); + } + } + + return result; +} + +function parseNewSlicingSyntax(selector: string): ParsedLineSelector { + const selections: LineSelection[] = []; + let hasNegativeIndexing = false; + let hasExclusions = false; + + // Split by comma to handle multiple selections + const parts = selector.split(",").map((part) => part.trim()); + + for (const part of parts) { + if (!part) continue; + + // Check for exclusion syntax + const isExclusion = part.startsWith("!"); + const cleanPart = isExclusion ? part.slice(1) : part; + + if (isExclusion) { + hasExclusions = true; + } + + // Parse the individual selection + const selection = parseIndividualSelection(cleanPart); + selection.type = isExclusion ? "exclusion" : "inclusion"; + + if (selection.isNegative) { + hasNegativeIndexing = true; + } + + selections.push(selection); + } + + return { selections, hasNegativeIndexing, hasExclusions }; +} + +function parseIndividualSelection(part: string): LineSelection { + // Handle single line (positive or negative) + if (!part.includes(":")) { + const num = Number.parseInt(part); + if (!Number.isFinite(num)) { + throw new Error(`Invalid line number: ${part}`); } + return { + type: "inclusion", + single: Math.abs(num), + isNegative: num < 0, + }; + } + + // Handle range syntax (start:end, start:, :end, :) + const colonIndex = part.indexOf(":"); + const startStr = part.slice(0, colonIndex); + const endStr = part.slice(colonIndex + 1); - return range; + let start: number | undefined; + let end: number | undefined; + let isNegative = false; + + // Parse start + if (startStr) { + start = Number.parseInt(startStr); + if (!Number.isFinite(start)) { + throw new Error(`Invalid range start: ${startStr}`); + } + if (start < 0) { + isNegative = true; + start = Math.abs(start); + } else if (start < 1) { + throw new Error("Range start must be positive or negative, not zero"); + } } - const line = Number.parseInt(lineSelectorString); + // Parse end + if (endStr) { + end = Number.parseInt(endStr); + if (!Number.isFinite(end)) { + throw new Error(`Invalid range end: ${endStr}`); + } + if (end < 0) { + isNegative = true; + end = Math.abs(end); + } else if (end < 1) { + throw new Error("Range end must be positive or negative, not zero"); + } + } - if (!Number.isFinite(line)) { - throw new Error("Failed to parse line number !"); + // Validate range logic for positive numbers + if (start !== undefined && end !== undefined && !isNegative) { + if (start > end) { + throw new Error( + `Range start (${start}) must be less than or equal to range end (${end})`, + ); + } } - return [line]; + return { + type: "inclusion", + start, + end, + isNegative, + }; } diff --git a/plugin/src/resolveLineSelections.ts b/plugin/src/resolveLineSelections.ts new file mode 100644 index 0000000..77a1e0d --- /dev/null +++ b/plugin/src/resolveLineSelections.ts @@ -0,0 +1,114 @@ +import type { LineSelection } from "./LineSelection.js"; +import type { ParsedLineSelector } from "./ParsedLineSelector.js"; + +export function resolveLineSelections( + parsed: ParsedLineSelector, + totalLines: number, +): number[] { + if (totalLines <= 0) { + return []; + } + + const includedLines = new Set(); + const excludedLines = new Set(); + + // Process all selections + for (const selection of parsed.selections) { + const lines = resolveSelection(selection, totalLines); + + if (selection.type === "inclusion") { + for (const line of lines) { + includedLines.add(line); + } + } else { + for (const line of lines) { + excludedLines.add(line); + } + } + } + + // If no inclusions specified, include all lines + if ( + parsed.selections.length === 0 || + parsed.selections.every((s) => s.type === "exclusion") + ) { + for (let i = 1; i <= totalLines; i++) { + includedLines.add(i); + } + } + + // Apply exclusions + for (const excludedLine of excludedLines) { + includedLines.delete(excludedLine); + } + + // Return sorted array + return Array.from(includedLines).sort((a, b) => a - b); +} + +function resolveSelection( + selection: LineSelection, + totalLines: number, +): number[] { + // Handle single line + if (selection.single !== undefined) { + const line = selection.isNegative + ? totalLines - selection.single + 1 + : selection.single; + + if (line < 1 || line > totalLines) { + throw new Error( + `Line ${ + selection.isNegative ? -selection.single : selection.single + } is out of range (file has ${totalLines} lines)`, + ); + } + + return [line]; + } + + // Handle range + let start = selection.start; + let end = selection.end; + + // Resolve negative indexing + if (selection.isNegative) { + if (start !== undefined) { + start = totalLines - start + 1; + } + if (end !== undefined) { + end = totalLines - end + 1; + } + // For negative ranges, swap start and end if needed + if (start !== undefined && end !== undefined && start > end) { + [start, end] = [end, start]; + } + } + + // Default to full range if not specified + if (start === undefined) start = 1; + if (end === undefined) end = totalLines; + + // Check for completely out-of-bounds ranges + if (start > totalLines) { + throw new Error( + `Line ${start} is out of range (file has ${totalLines} lines)`, + ); + } + + // Validate bounds (clamp to valid range) + start = Math.max(1, Math.min(start, totalLines)); + end = Math.max(1, Math.min(end, totalLines)); + + if (start > end) { + return []; + } + + // Generate range + const result: number[] = []; + for (let i = start; i <= end; i++) { + result.push(i); + } + + return result; +} From f90d9c3f05ab17490aa7283076afc93d19a61288 Mon Sep 17 00:00:00 2001 From: spenpal Date: Wed, 2 Jul 2025 19:05:24 -0500 Subject: [PATCH 03/12] feat: add changelog and update README for v3.0.0 - Introduced Python-like slicing syntax for line selection with advanced features - Deprecated old colon syntax; migration guide provided in README - Updated README to reflect new syntax and breaking changes - Updated Dockerfile to use new plugin version - Enhanced error messages for deprecated syntax in parsing logic - Ensured comprehensive test coverage with 52 tests --- CHANGELOG.md | 60 +++++++++++++++++++++++ README.md | 59 ++++++++++++++++++++-- demo/Dockerfile | 2 +- demo/src/Author.ts | 10 ++-- demo/src/Library.ts | 16 +++--- plugin/package.json | 2 +- plugin/src/parseIncludeExampleTag.test.ts | 4 +- plugin/src/parseIncludeExampleTag.ts | 2 +- 8 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..30be0e0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +## [3.0.0] - 2024-12-19 + +### 🚀 Features + +- **Python-like slicing syntax**: New bracket syntax for line selection with advanced features + - Single line: `path/to/file[10]` + - Open-ended ranges: `path/to/file[2:]` or `path/to/file[:5]` + - Closed range: `path/to/file[1:5]` + - Multiple selections: `path/to/file[2:5,10]` + - Exclusions: `path/to/file[1:10,!5:7]` or `path/to/file[:10,!3,!7]` + - Negative indexing: `path/to/file[-5]`, `path/to/file[-5:]`, `path/to/file[:-5]`, `path/to/file[-5:-2]` + +### 💥 BREAKING CHANGES + +- **Colon syntax deprecated**: The old colon-based syntax (`path/to/file:2-4`) is no longer supported +- **Migration required**: All existing colon syntax must be migrated to bracket syntax +- **Version requirement**: Requires TypeDoc 0.26.x, 0.27.x, or 0.28.x + +### 🔄 Migration Guide + +#### Old Syntax → New Syntax + +```diff +- @includeExample path/to/file:15 ++ @includeExample path/to/file[15] + +- @includeExample path/to/file:2-4 ++ @includeExample path/to/file[2:4] + +- @includeExample path/to/file:2-4,15 ++ @includeExample path/to/file[2:4,15] +``` + +#### New Advanced Features + +```typescript +// Negative indexing (last 5 lines) +@includeExample path/to/file[-5:] + +// Exclusions (lines 1-10 except 5-7) +@includeExample path/to/file[1:10,!5:7] + +// Open-ended ranges (from line 5 to end) +@includeExample path/to/file[5:] +``` + +### 🛠️ Technical Changes + +- Complete rewrite of line selector parsing system +- New `ParsedLineSelector` interface for complex syntax support +- Enhanced error handling with descriptive migration messages +- Full backwards compatibility detection +- Comprehensive test coverage (52 tests) + +### 📦 Dependencies + +- No new dependencies added +- Maintains compatibility with existing TypeDoc versions diff --git a/README.md b/README.md index ab043e1..e2c68ad 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![npm](https://img.shields.io/badge/coverage-blue)](https://ferdodo.github.io/typedoc-plugin-include-example/reports/mutation/mutation.html) [![npm](https://img.shields.io/badge/demo-green)](https://ferdodo.github.io/typedoc-plugin-include-example/) -Include code examples in your [typedoc](https://typedoc.org/) documentations. +Include code examples in your [typedoc](https://typedoc.org/) documentations with powerful Python-like slicing syntax. ## Installation @@ -22,8 +22,8 @@ Write your example in a `*.example.ts` file. ```javascript // greet.example.ts -import { greet } from "./greet.js" -greet(); // Prints greetings +import { greet } from "./greet.js"; +greet(); // Prints greetings ``` Add the @includeExample tag to the actual code. @@ -33,11 +33,11 @@ Add the @includeExample tag to the actual code. /** * Says hello ! - * + * * @includeExample */ export function greet() { - console.log("Hello there.") + console.log("Hello there."); } ``` @@ -47,6 +47,55 @@ Then generate your documentation using typedoc using this plugin. $ npx typedoc --plugin typedoc-plugin-include-example ``` +## Line Selection Syntax + +### Basic Usage + +```typescript +/** + * @includeExample greet.example.ts // Include entire file + * @includeExample greet.example.ts[5] // Include line 5 only + * @includeExample greet.example.ts[2:8] // Include lines 2-8 + */ +``` + +### Advanced Python-like Slicing + +```typescript +/** + * @includeExample greet.example.ts[5:] // From line 5 to end + * @includeExample greet.example.ts[:10] // From start to line 10 + * @includeExample greet.example.ts[-5:] // Last 5 lines + * @includeExample greet.example.ts[:-3] // All except last 3 lines + * @includeExample greet.example.ts[-5:-2] // Lines -5 to -2 (negative indexing) + */ +``` + +### Multiple Selections & Exclusions + +```typescript +/** + * @includeExample greet.example.ts[2:5,10,15:20] // Lines 2-5, 10, and 15-20 + * @includeExample greet.example.ts[1:20,!8:12] // Lines 1-20 except 8-12 + * @includeExample greet.example.ts[:10,!3,!7] // Lines 1-10 except 3 and 7 + */ +``` + +## 🚨 Breaking Changes in v3.0.0 + +The old colon syntax is no longer supported. Please migrate: + +```diff +- @includeExample path/to/file:15 ++ @includeExample path/to/file[15] + +- @includeExample path/to/file:2-4 ++ @includeExample path/to/file[2:4] + +- @includeExample path/to/file:2-4,15 ++ @includeExample path/to/file[2:4,15] +``` + ## Features See the [Documentation](./docs.md) for full usage. diff --git a/demo/Dockerfile b/demo/Dockerfile index 172c614..b529771 100644 --- a/demo/Dockerfile +++ b/demo/Dockerfile @@ -13,7 +13,7 @@ COPY --from=plugin /typedoc-plugin-include-example/plugin /typedoc-plugin-includ WORKDIR /typedoc-plugin-include-example/plugin RUN npm pack WORKDIR /typedoc-plugin-include-example/demo -RUN npm install ../plugin/typedoc-plugin-include-example-2.1.2.tgz +RUN npm install ../plugin/typedoc-plugin-include-example-3.0.0.tgz COPY . . RUN npm run build diff --git a/demo/src/Author.ts b/demo/src/Author.ts index b2c20a8..9fbfbfa 100644 --- a/demo/src/Author.ts +++ b/demo/src/Author.ts @@ -6,10 +6,10 @@ import type { Book } from "./Book"; * @includeExample ./src/Author.example.ts[7] */ export class Author { - books: Book[] = []; + books: Book[] = []; - addBook(book: Book): Author { - this.books.push(book); - return this; - } + addBook(book: Book): Author { + this.books.push(book); + return this; + } } diff --git a/demo/src/Library.ts b/demo/src/Library.ts index 86da4d1..575608b 100644 --- a/demo/src/Library.ts +++ b/demo/src/Library.ts @@ -6,14 +6,14 @@ import type { Book } from "./Book"; * @includeExample ./src/Library.example.ts[5-9] */ export class Library { - books: Book[] = []; + books: Book[] = []; - addBook(book: Book): Library { - this.books.push(book); - return this; - } + addBook(book: Book): Library { + this.books.push(book); + return this; + } - listBooks() { - return this.books.map((book) => book.title).join(", "); - } + listBooks() { + return this.books.map((book) => book.title).join(", "); + } } diff --git a/plugin/package.json b/plugin/package.json index 5ea5faa..7cf9321 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,6 +1,6 @@ { "name": "typedoc-plugin-include-example", - "version": "2.1.2", + "version": "3.0.0", "license": "MIT", "type": "module", "description": "Typedoc plugin to include files as example", diff --git a/plugin/src/parseIncludeExampleTag.test.ts b/plugin/src/parseIncludeExampleTag.test.ts index 301469a..376dee1 100644 --- a/plugin/src/parseIncludeExampleTag.test.ts +++ b/plugin/src/parseIncludeExampleTag.test.ts @@ -22,13 +22,13 @@ test("it should throw on empty path", () => { test("it should throw migration error for old colon syntax", () => { expect(() => parseIncludeExampleTag("path/to/file:2-4")).toThrowError( - /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4' is no longer supported/, + /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4' is no longer supported in v3\.0\.0\+/, ); }); test("it should throw migration error for old colon syntax with multiple selectors", () => { expect(() => parseIncludeExampleTag("path/to/file:2-4,15")).toThrowError( - /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4,15' is no longer supported/, + /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4,15' is no longer supported in v3\.0\.0\+/, ); }); diff --git a/plugin/src/parseIncludeExampleTag.ts b/plugin/src/parseIncludeExampleTag.ts index 88ee963..6d18f17 100644 --- a/plugin/src/parseIncludeExampleTag.ts +++ b/plugin/src/parseIncludeExampleTag.ts @@ -39,7 +39,7 @@ export function parseIncludeExampleTag( if (potentialSelector.trim() && /^[\d\-,\s]+$/.test(potentialSelector)) { // This looks like old colon syntax with line selectors throw new Error( - `BREAKING CHANGE: The colon syntax '${tag}' is no longer supported. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector}]'. See documentation for the new Python-like slicing syntax.`, + `BREAKING CHANGE: The colon syntax '${tag}' is no longer supported in v3.0.0+. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector}]'. See documentation for the new Python-like slicing syntax.`, ); } } From 930dfe427ba481a4313fc453e491db0585854243 Mon Sep 17 00:00:00 2001 From: spenpal Date: Wed, 2 Jul 2025 21:54:52 -0500 Subject: [PATCH 04/12] feat: add BookStore, Magazine, Publisher, and Review classes with examples - Introduced BookStore class for inventory management with example usage - Added Magazine class for article processing and featuring books - Implemented Publisher class for managing book publication - Created Review class for handling book reviews with validation - Included example files for each new class demonstrating functionality - Updated index.ts to export new classes for easier access --- demo/src/BookStore.example.ts | 23 ++ demo/src/BookStore.ts | 31 +++ demo/src/Magazine.example.ts | 25 ++ demo/src/Magazine.ts | 33 +++ demo/src/Publisher.example.ts | 20 ++ demo/src/Publisher.ts | 33 +++ demo/src/Review.example.ts | 24 ++ demo/src/Review.ts | 34 +++ demo/src/index.ts | 4 + demo/tests/complex-mixed.spec.ts | 53 ++++ demo/tests/exclusion.spec.ts | 45 ++++ demo/tests/negative-indexing.spec.ts | 36 +++ demo/tests/open-ended-range.spec.ts | 36 +++ plugin/src/applyLineSelection.test.ts | 292 ++++++++++++++++++++++ plugin/src/findExample.ts | 8 + plugin/src/parseIncludeExampleTag.test.ts | 113 +++++++++ plugin/src/parseIncludeExampleTag.ts | 4 +- plugin/src/parseLineSelector.test.ts | 110 +++++++- plugin/src/parseLineSelector.ts | 2 +- 19 files changed, 922 insertions(+), 4 deletions(-) create mode 100644 demo/src/BookStore.example.ts create mode 100644 demo/src/BookStore.ts create mode 100644 demo/src/Magazine.example.ts create mode 100644 demo/src/Magazine.ts create mode 100644 demo/src/Publisher.example.ts create mode 100644 demo/src/Publisher.ts create mode 100644 demo/src/Review.example.ts create mode 100644 demo/src/Review.ts create mode 100644 demo/tests/complex-mixed.spec.ts create mode 100644 demo/tests/exclusion.spec.ts create mode 100644 demo/tests/negative-indexing.spec.ts create mode 100644 demo/tests/open-ended-range.spec.ts diff --git a/demo/src/BookStore.example.ts b/demo/src/BookStore.example.ts new file mode 100644 index 0000000..7bc453a --- /dev/null +++ b/demo/src/BookStore.example.ts @@ -0,0 +1,23 @@ +import { Book } from "./Book"; +import { BookStore } from "./BookStore"; + +// Initialize bookstore +const bookstore = new BookStore("The Corner Bookshop", "Downtown"); + +// Add inventory +const book1 = new Book("1984"); +const book2 = new Book("Pride and Prejudice"); +// SENSITIVE: Pricing logic - should be excluded from docs +const basePrice = 15.99; +const markup = 0.25; +const finalPrice = basePrice * (1 + markup); +console.log(`Internal pricing: $${finalPrice}`); +// END SENSITIVE SECTION + +bookstore.addToInventory(book1); +bookstore.addToInventory(book2); + +// Check stock +console.log(`Books in stock: ${bookstore.getInventoryCount()}`); +console.log(`Has '1984': ${bookstore.hasBook("1984")}`); +console.log(`Store: ${bookstore.name} at ${bookstore.location}`); diff --git a/demo/src/BookStore.ts b/demo/src/BookStore.ts new file mode 100644 index 0000000..6aa62fb --- /dev/null +++ b/demo/src/BookStore.ts @@ -0,0 +1,31 @@ +import type { Book } from "./Book"; + +/** + * A class representing a bookstore with inventory management. + * + * @example Inventory management (excluding pricing logic) + * @includeExample ./src/BookStore.example.ts[!10:15] + */ +export class BookStore { + name: string; + inventory: Book[] = []; + location: string; + + constructor(name: string, location: string) { + this.name = name; + this.location = location; + } + + addToInventory(book: Book): BookStore { + this.inventory.push(book); + return this; + } + + getInventoryCount(): number { + return this.inventory.length; + } + + hasBook(title: string): boolean { + return this.inventory.some((book) => book.title === title); + } +} diff --git a/demo/src/Magazine.example.ts b/demo/src/Magazine.example.ts new file mode 100644 index 0000000..6cefb33 --- /dev/null +++ b/demo/src/Magazine.example.ts @@ -0,0 +1,25 @@ +import { Book } from "./Book"; +import { Magazine } from "./Magazine"; + +// Magazine header setup +const magazine = new Magazine("Literary Quarterly", 42); +console.log(`Creating ${magazine.getIssueInfo()}`); + +// Initialize featured books +const book1 = new Book("Beloved"); +const book2 = new Book("One Hundred Years of Solitude"); +// End of header processing section + +// Content processing section starts here +magazine.featureBook(book1); +magazine.featureBook(book2); + +// Add articles to magazine +magazine.addArticle("The Evolution of Modern Literature"); +magazine.addArticle("Magical Realism in Contemporary Fiction"); +magazine.addArticle("Interview with Emerging Authors"); + +// Finalize magazine content +console.log(`Featured books: ${magazine.featuredBooks.length}`); +console.log(`Total articles: ${magazine.articles.length}`); +console.log(`${magazine.getIssueInfo()} ready for publication`); diff --git a/demo/src/Magazine.ts b/demo/src/Magazine.ts new file mode 100644 index 0000000..682667a --- /dev/null +++ b/demo/src/Magazine.ts @@ -0,0 +1,33 @@ +import type { Book } from "./Book"; + +/** + * A class representing a literary magazine with article processing. + * + * @example Article header processing (first 11 lines) + * @includeExample ./src/Magazine.example.ts[:11] + */ +export class Magazine { + title: string; + articles: string[] = []; + featuredBooks: Book[] = []; + issueNumber: number; + + constructor(title: string, issueNumber: number) { + this.title = title; + this.issueNumber = issueNumber; + } + + addArticle(article: string): Magazine { + this.articles.push(article); + return this; + } + + featureBook(book: Book): Magazine { + this.featuredBooks.push(book); + return this; + } + + getIssueInfo(): string { + return `${this.title} - Issue #${this.issueNumber}`; + } +} diff --git a/demo/src/Publisher.example.ts b/demo/src/Publisher.example.ts new file mode 100644 index 0000000..3d53584 --- /dev/null +++ b/demo/src/Publisher.example.ts @@ -0,0 +1,20 @@ +import { Book } from "./Book"; +import { Publisher } from "./Publisher"; + +// Initialize publisher +const publisher = new Publisher("Penguin Random House", 1927); + +// Add books to catalog +const book1 = new Book("The Great Gatsby"); +const book2 = new Book("To Kill a Mockingbird"); +publisher.publishBook(book1); +publisher.publishBook(book2); + +// Validate catalog +const totalBooks = publisher.getPublishedCount(); +console.log(`Catalog validated: ${totalBooks} books ready`); + +// Final publishing steps (these lines demonstrate negative indexing) +console.log("Finalizing publication process..."); +console.log("Updating distribution channels..."); +console.log(`${publisher.getInfo()} - Publication complete!`); diff --git a/demo/src/Publisher.ts b/demo/src/Publisher.ts new file mode 100644 index 0000000..2d67502 --- /dev/null +++ b/demo/src/Publisher.ts @@ -0,0 +1,33 @@ +import type { Book } from "./Book"; + +/** + * A class representing a book publisher. + * + * @example Publishing workflow (last 5 steps) + * @includeExample ./src/Publisher.example.ts[-5:] + */ +export class Publisher { + name: string; + books: Book[] = []; + establishedYear: number; + + constructor(name: string, establishedYear: number) { + this.name = name; + this.establishedYear = establishedYear; + } + + publishBook(book: Book): Publisher { + this.books.push(book); + return this; + } + + getPublishedCount(): number { + return this.books.length; + } + + getInfo(): string { + return `${this.name} (Est. ${ + this.establishedYear + }) - ${this.getPublishedCount()} books published`; + } +} diff --git a/demo/src/Review.example.ts b/demo/src/Review.example.ts new file mode 100644 index 0000000..ecc2303 --- /dev/null +++ b/demo/src/Review.example.ts @@ -0,0 +1,24 @@ +import { Book } from "./Book"; +import { Review } from "./Review"; + +// Setup review data +const book = new Book("The Catcher in the Rye"); +// VALIDATION: Input sanitization - should be excluded +const sanitizedComment = "A profound coming-of-age story"; +const validatedRating = Math.min(Math.max(5, 1), 5); +console.log(`Validation: Rating ${validatedRating} approved`); +// END VALIDATION SECTION + +// Create review instance +const review = new Review(book, 5, sanitizedComment, "Literary Critic"); + +// Process review +console.log("Processing review..."); +const isValidReview = review.isValid(); + +// Summary and output +console.log("Review processing complete"); +console.log(`Valid review: ${isValidReview}`); +console.log(`Formatted: ${review.getFormattedReview()}`); +console.log(`Book: ${review.book.title}`); +console.log("Review ready for publication"); diff --git a/demo/src/Review.ts b/demo/src/Review.ts new file mode 100644 index 0000000..cae0850 --- /dev/null +++ b/demo/src/Review.ts @@ -0,0 +1,34 @@ +import type { Book } from "./Book"; + +/** + * A class representing a book review with processing workflow. + * + * @example Review processing (setup + process, excluding validation) + * @includeExample ./src/Review.example.ts[1:17,!6:10] + */ +export class Review { + book: Book; + rating: number; + comment: string; + reviewerName: string; + + constructor( + book: Book, + rating: number, + comment: string, + reviewerName: string, + ) { + this.book = book; + this.rating = rating; + this.comment = comment; + this.reviewerName = reviewerName; + } + + isValid(): boolean { + return this.rating >= 1 && this.rating <= 5 && this.comment.length > 0; + } + + getFormattedReview(): string { + return `"${this.comment}" - ${this.reviewerName} (${this.rating}/5 stars)`; + } +} diff --git a/demo/src/index.ts b/demo/src/index.ts index e79caaf..0f667c8 100644 --- a/demo/src/index.ts +++ b/demo/src/index.ts @@ -2,3 +2,7 @@ export * from "./Author"; export * from "./Book"; export * from "./Chapter"; export * from "./Library"; +export * from "./Publisher"; +export * from "./BookStore"; +export * from "./Review"; +export * from "./Magazine"; diff --git a/demo/tests/complex-mixed.spec.ts b/demo/tests/complex-mixed.spec.ts new file mode 100644 index 0000000..906f594 --- /dev/null +++ b/demo/tests/complex-mixed.spec.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; + +const filePath = `file://${path.join( + process.cwd(), + "docs/classes/Review.html", +)}`; + +test("Should inject a title named 'Example'", async ({ page }) => { + await page.goto(filePath); + const title = page.getByRole("heading", { level: 3, name: "Example" }); + await expect(title).toBeVisible(); +}); + +test("Should handle complex mixed syntax with ranges and exclusions", async ({ + page, +}) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should include the setup and processing sections + await expect(code).toContainText("Setup review data"); + await expect(code).toContainText("Process review"); +}); + +test("Should exclude validation section when using mixed syntax", async ({ + page, +}) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should not include the validation section (lines 6-10) + await expect(code).not.toContainText("VALIDATION: Input sanitization"); + await expect(code).not.toContainText("Math.min(Math.max"); + await expect(code).not.toContainText("END VALIDATION SECTION"); +}); + +test("Should include lines from specified ranges while excluding others", async ({ + page, +}) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should include the review creation and processing + await expect(code).toContainText("Create review instance"); + await expect(code).toContainText("Processing review"); + + // Should include the variable usage (even though definition was excluded) + await expect(code).toContainText("sanitizedComment"); + + // Should exclude the validation logic + await expect(code).not.toContainText("Math.min(Math.max"); +}); diff --git a/demo/tests/exclusion.spec.ts b/demo/tests/exclusion.spec.ts new file mode 100644 index 0000000..1828781 --- /dev/null +++ b/demo/tests/exclusion.spec.ts @@ -0,0 +1,45 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; + +const filePath = `file://${path.join( + process.cwd(), + "docs/classes/BookStore.html", +)}`; + +test("Should inject a title named 'Example'", async ({ page }) => { + await page.goto(filePath); + const title = page.getByRole("heading", { level: 3, name: "Example" }); + await expect(title).toBeVisible(); +}); + +test("Should handle exclusion syntax to exclude sensitive lines", async ({ + page, +}) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should include the setup and inventory management + await expect(code).toContainText("Initialize bookstore"); + await expect(code).toContainText("Add inventory"); +}); + +test("Should exclude lines marked as sensitive pricing logic", async ({ + page, +}) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should not include the sensitive pricing logic (lines 10-15) + await expect(code).not.toContainText("SENSITIVE: Pricing logic"); + await expect(code).not.toContainText("basePrice"); + await expect(code).not.toContainText("markup"); + await expect(code).not.toContainText("finalPrice"); +}); + +test("Should include lines after the excluded range", async ({ page }) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should include the inventory operations that come after the excluded section + await expect(code).toContainText("addToInventory"); +}); diff --git a/demo/tests/negative-indexing.spec.ts b/demo/tests/negative-indexing.spec.ts new file mode 100644 index 0000000..d7bdfef --- /dev/null +++ b/demo/tests/negative-indexing.spec.ts @@ -0,0 +1,36 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; + +const filePath = `file://${path.join( + process.cwd(), + "docs/classes/Publisher.html", +)}`; + +test("Should inject a title named 'Example'", async ({ page }) => { + await page.goto(filePath); + const title = page.getByRole("heading", { level: 3, name: "Example" }); + await expect(title).toBeVisible(); +}); + +test("Should handle negative indexing syntax to include last 5 lines", async ({ + page, +}) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should include the final publishing steps (last 5 lines) + await expect(code).toContainText("Final publishing steps"); + await expect(code).toContainText("Finalizing publication process"); + await expect(code).toContainText("Publication complete"); +}); + +test("Should exclude earlier lines when using negative indexing", async ({ + page, +}) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should not include the initial setup lines + await expect(code).not.toContainText("Initialize publisher"); + await expect(code).not.toContainText("Add books to catalog"); +}); diff --git a/demo/tests/open-ended-range.spec.ts b/demo/tests/open-ended-range.spec.ts new file mode 100644 index 0000000..f4c99cd --- /dev/null +++ b/demo/tests/open-ended-range.spec.ts @@ -0,0 +1,36 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; + +const filePath = `file://${path.join( + process.cwd(), + "docs/classes/Magazine.html", +)}`; + +test("Should inject a title named 'Example'", async ({ page }) => { + await page.goto(filePath); + const title = page.getByRole("heading", { level: 3, name: "Example" }); + await expect(title).toBeVisible(); +}); + +test("Should handle open-ended range syntax to include first 11 lines", async ({ + page, +}) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should include the header processing section (first 11 lines) + await expect(code).toContainText("Magazine header setup"); + await expect(code).toContainText("Literary Quarterly"); + await expect(code).toContainText("End of header processing section"); +}); + +test("Should exclude lines after the open-ended range", async ({ page }) => { + await page.goto(filePath); + const code = page.getByRole("code").first(); + + // Should not include content processing section that comes after line 11 + await expect(code).not.toContainText( + "Content processing section starts here", + ); + await expect(code).not.toContainText("Add articles to magazine"); +}); diff --git a/plugin/src/applyLineSelection.test.ts b/plugin/src/applyLineSelection.test.ts index a5ed106..157418a 100644 --- a/plugin/src/applyLineSelection.test.ts +++ b/plugin/src/applyLineSelection.test.ts @@ -1,5 +1,18 @@ import { expect, test } from "vitest"; +import type { ParsedLineSelector } from "./ParsedLineSelector.js"; import { applyLineSelection } from "./applyLineSelection.js"; +import { parseLineSelector } from "./parseLineSelector.js"; + +// Helper function to create parsed selector +function createParsedSelector(selector: string): ParsedLineSelector { + const result = parseLineSelector(selector); + if (Array.isArray(result)) { + throw new Error("Expected ParsedLineSelector but got number[]"); + } + return result; +} + +// ============= BACKWARDS COMPATIBILITY TESTS (Old lines array) ============= test("It should select lines from file", () => { const file = "hello\nthis\nis\na\nmultiline\nfile"; @@ -32,3 +45,282 @@ test("It throw when line is out of range", () => { "Line number 8 is out of range for file fake/file", ); }); + +// ============= NEW PARSED SELECTOR TESTS ============= + +test("It should apply single line selection with new syntax", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("3:3"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line3"); +}); + +test("It should apply range selection with new syntax", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("2:4"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line2\nline3\nline4"); +}); + +test("It should apply negative indexing", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-2"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line4"); // Second to last line +}); + +test("It should apply negative range", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-3:-1"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line3\nline4\nline5"); // Last 3 lines +}); + +test("It should apply open-ended range from start", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("3:"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line3\nline4\nline5"); +}); + +test("It should apply open-ended range to end", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector(":3"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline3"); +}); + +test("It should apply multiple selections", () => { + const file = "line1\nline2\nline3\nline4\nline5\nline6"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:2,5,6:6"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline5\nline6"); +}); + +test("It should apply exclusions", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:5,!3"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline4\nline5"); +}); + +test("It should apply range exclusions", () => { + const file = "line1\nline2\nline3\nline4\nline5\nline6\nline7"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:7,!3:5"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline6\nline7"); +}); + +test("It should apply negative exclusions", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-5:,!-2"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline3\nline5"); // All lines except second to last +}); + +test("It should handle implicit full range with exclusions", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("!2,!4"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline3\nline5"); +}); + +// ============= EDGE CASES ============= + +test("It should handle empty files", () => { + const file = ""; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:5"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual(""); +}); + +test("It should handle single line files", () => { + const file = "only line"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:1"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("only line"); +}); + +test("It should handle single line with negative indexing", () => { + const file = "only line"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-1"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("only line"); +}); + +test("It should handle files with empty lines using new syntax", () => { + const file = "line1\n\nline3\n\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("2:2,4:4"), // Use new syntax explicitly + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("\n"); // Empty lines 2 and 4 +}); + +test("It should handle files ending with newline", () => { + const file = "line1\nline2\nline3\n"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("2:4"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line2\nline3\n"); +}); + +test("It should handle complex overlapping ranges and exclusions", () => { + const file = "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:10,3:7,!5,!8:9"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("L1\nL2\nL3\nL4\nL6\nL7\nL10"); // Combined ranges minus exclusions +}); + +// ============= ERROR HANDLING ============= + +test("It should throw when parsed selector results in out of range line", () => { + const file = "line1\nline2\nline3"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("5:5"), + }; + + // The error message comes from resolveLineSelections, not applyLineSelection + expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( + "Line 5 is out of range (file has 3 lines)", + ); +}); + +test("It should throw when negative indexing goes out of bounds", () => { + const file = "line1\nline2"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-5"), + }; + + expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( + "Line -5 is out of range (file has 2 lines)", + ); +}); + +test("It should handle empty result from exclusions", () => { + const file = "line1\nline2\nline3"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:3,!1:3"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual(""); // All lines excluded +}); + +test("It should handle empty selector (no lines selected)", () => { + const file = "line1\nline2\nline3"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector(""), // Empty selector returns all lines + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline3"); // Empty selector means all lines selected +}); + +// ============= PERFORMANCE AND LARGE FILE TESTS ============= + +test("It should handle large line numbers efficiently", () => { + // Create a file with 1000 lines + const lines = Array.from({ length: 1000 }, (_, i) => `line${i + 1}`); + const file = lines.join("\n"); + + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("995:1000"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual( + "line995\nline996\nline997\nline998\nline999\nline1000", + ); +}); + +test("It should handle complex selections on large files", () => { + // Create a file with 100 lines + const lines = Array.from({ length: 100 }, (_, i) => `line${i + 1}`); + const file = lines.join("\n"); + + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:10,50:60,90:100,!5,!55,!95"), + }; + + const result = applyLineSelection(file, includeExampleFile); + const resultLines = result.split("\n"); + + // Should have 10 + 11 + 11 - 3 = 29 lines + expect(resultLines).toHaveLength(29); + expect(resultLines[0]).toBe("line1"); + expect(resultLines).not.toContain("line5"); + expect(resultLines).not.toContain("line55"); + expect(resultLines).not.toContain("line95"); +}); diff --git a/plugin/src/findExample.ts b/plugin/src/findExample.ts index 0350b08..1a34c8c 100644 --- a/plugin/src/findExample.ts +++ b/plugin/src/findExample.ts @@ -23,6 +23,14 @@ export function findExample(comment: Comment): string | null { exampleFilePath, ); + if (!existsSync(includeExampleTag.path)) { + // Try resolving relative to source file directory + const relativePath = join(dir, includeExampleTag.path); + if (existsSync(relativePath)) { + includeExampleTag.path = relativePath; + } + } + if (!existsSync(includeExampleTag.path)) { throw new Error(`File not found for ${includeExampleTag.path}`); } diff --git a/plugin/src/parseIncludeExampleTag.test.ts b/plugin/src/parseIncludeExampleTag.test.ts index 376dee1..547bd80 100644 --- a/plugin/src/parseIncludeExampleTag.test.ts +++ b/plugin/src/parseIncludeExampleTag.test.ts @@ -36,3 +36,116 @@ test("it should allow colons in file paths without selectors", () => { const result = parseIncludeExampleTag("path:with:colons/file.ts"); expect(result).toEqual({ path: "path:with:colons/file.ts" }); }); + +// ============= ADDITIONAL BRACKET SYNTAX TESTS ============= + +test("it should handle empty brackets (select all lines)", () => { + const result = parseIncludeExampleTag("path/to/file[]"); + expect(result).toEqual({ path: "path/to/file[]" }); +}); + +test("it should handle colon-only brackets (select all lines)", () => { + const result = parseIncludeExampleTag("path/to/file[:]"); + expect(result).toEqual({ + path: "path/to/file", + parsedSelector: { + selections: [], + hasNegativeIndexing: false, + hasExclusions: false, + }, + }); +}); + +test("it should parse bracket syntax in brackets", () => { + const result = parseIncludeExampleTag("path/to/file[2:8]"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector).toBeDefined(); + expect(result.parsedSelector?.selections).toHaveLength(1); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "inclusion", + start: 2, + end: 8, + isNegative: false, + }); +}); + +test("it should parse negative indexing in brackets", () => { + const result = parseIncludeExampleTag("path/to/file[-5:]"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector?.hasNegativeIndexing).toBe(true); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "inclusion", + start: 5, + end: undefined, + isNegative: true, + }); +}); + +test("it should parse exclusions in brackets", () => { + const result = parseIncludeExampleTag("path/to/file[1:10,!5:7]"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector?.hasExclusions).toBe(true); + expect(result.parsedSelector?.selections).toHaveLength(2); + expect(result.parsedSelector?.selections[1].type).toBe("exclusion"); +}); + +test("it should handle file paths with brackets in the path itself", () => { + const result = parseIncludeExampleTag("path/with[brackets]/file.ts"); + expect(result).toEqual({ path: "path/with[brackets]/file.ts" }); +}); + +test("it should handle Windows-style paths with brackets (single number = old syntax)", () => { + const result = parseIncludeExampleTag("C:\\path\\to\\file.ts[10]"); + expect(result.path).toBe("C:\\path\\to\\file.ts"); + expect(result.lines).toEqual([10]); +}); + +test("it should handle URLs with brackets", () => { + const result = parseIncludeExampleTag("https://example.com/file.ts[1:5]"); + expect(result.path).toBe("https://example.com/file.ts"); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "inclusion", + start: 1, + end: 5, + isNegative: false, + }); +}); + +test("it should handle paths with spaces and brackets", () => { + const result = parseIncludeExampleTag("path with spaces/file.ts[2:4]"); + expect(result.path).toBe("path with spaces/file.ts"); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "inclusion", + start: 2, + end: 4, + isNegative: false, + }); +}); + +test("it should detect old syntax even with complex selectors", () => { + expect(() => parseIncludeExampleTag("path/to/file:1,3,5-7,10")).toThrowError( + /BREAKING CHANGE: The colon syntax 'path\/to\/file:1,3,5-7,10' is no longer supported in v3\.0\.0\+/, + ); +}); + +test("it should not confuse colons in paths with old syntax", () => { + const result = parseIncludeExampleTag("http://example.com:8080/file.ts"); + expect(result).toEqual({ path: "http://example.com:8080/file.ts" }); +}); + +test("it should handle relative paths with brackets", () => { + const result = parseIncludeExampleTag("../parent/file.ts[5:]"); + expect(result.path).toBe("../parent/file.ts"); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "inclusion", + start: 5, + end: undefined, + isNegative: false, + }); +}); + +test("it should handle single character file names (single number = old syntax)", () => { + const result = parseIncludeExampleTag("a[1]"); + expect(result.path).toBe("a"); + expect(result.lines).toEqual([1]); +}); diff --git a/plugin/src/parseIncludeExampleTag.ts b/plugin/src/parseIncludeExampleTag.ts index 6d18f17..20d0370 100644 --- a/plugin/src/parseIncludeExampleTag.ts +++ b/plugin/src/parseIncludeExampleTag.ts @@ -13,7 +13,7 @@ export function parseIncludeExampleTag( const [, path, selectorString] = bracketMatch; const includeExampleTag: IncludeExampleTag = { path }; - // Parse the selector string using new Python-like syntax + // Parse the selector string using new bracket syntax const parsed = parseLineSelector(selectorString); // Handle the two possible return types @@ -39,7 +39,7 @@ export function parseIncludeExampleTag( if (potentialSelector.trim() && /^[\d\-,\s]+$/.test(potentialSelector)) { // This looks like old colon syntax with line selectors throw new Error( - `BREAKING CHANGE: The colon syntax '${tag}' is no longer supported in v3.0.0+. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector}]'. See documentation for the new Python-like slicing syntax.`, + `BREAKING CHANGE: The colon syntax '${tag}' is no longer supported in v3.0.0+. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector}]'. See documentation for the new bracket syntax.`, ); } } diff --git a/plugin/src/parseLineSelector.test.ts b/plugin/src/parseLineSelector.test.ts index 78b0412..34bfb5e 100644 --- a/plugin/src/parseLineSelector.test.ts +++ b/plugin/src/parseLineSelector.test.ts @@ -29,6 +29,12 @@ test("Should parse a range of lines starting with 1 (old syntax)", () => { expect(result).toEqual([1, 2, 3, 4]); }); +test("Should parse multiple line selectors (old syntax)", () => { + const result = parseLineSelector("2-4,15"); + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual([2, 3, 4, 15]); +}); + test("Should throw error on missing range start (old syntax)", () => { expect(() => parseLineSelector("x-4")).toThrowError( "Failed to parse range start !", @@ -77,7 +83,7 @@ test("Should throw error on bad single line (old syntax)", () => { ); }); -// ============= NEW PYTHON-LIKE SYNTAX TESTS ============= +// ============= NEW BRACKET SYNTAX TESTS ============= // Helper function to get ParsedLineSelector from result function getParsedSelector( @@ -272,6 +278,62 @@ test("Should throw error on invalid positive range", () => { ); }); +// ============= ADDITIONAL EDGE CASE TESTS ============= + +test("Should parse complex mixed syntax", () => { + const result = getParsedSelector(parseLineSelector("1:5,10,15:,!3,!12:14")); + expect(result.selections).toHaveLength(5); + expect(result.hasExclusions).toBe(true); + expect(result.hasNegativeIndexing).toBe(false); +}); + +test("Should parse negative exclusions", () => { + const result = getParsedSelector(parseLineSelector(":-5,!-3")); + expect(result.selections).toHaveLength(2); + expect(result.selections[0]).toEqual({ + type: "inclusion", + start: undefined, + end: 5, + isNegative: true, + }); + expect(result.selections[1]).toEqual({ + type: "exclusion", + single: 3, + isNegative: true, + }); + expect(result.hasNegativeIndexing).toBe(true); + expect(result.hasExclusions).toBe(true); +}); + +test("Should handle whitespace in selectors", () => { + const result = getParsedSelector(parseLineSelector(" 2:5 , 10 , !7 ")); + expect(result.selections).toHaveLength(3); + expect(result.selections[0].start).toBe(2); + expect(result.selections[0].end).toBe(5); + expect(result.selections[1].single).toBe(10); + expect(result.selections[2].single).toBe(7); + expect(result.selections[2].type).toBe("exclusion"); +}); + +test("Should parse only exclusions (implicit full range)", () => { + const result = getParsedSelector(parseLineSelector("!3,!7:9")); + expect(result.selections).toHaveLength(2); + expect(result.selections[0].type).toBe("exclusion"); + expect(result.selections[1].type).toBe("exclusion"); + expect(result.hasExclusions).toBe(true); +}); + +test("Should handle single colon (empty range)", () => { + const result = getParsedSelector(parseLineSelector(":")); + expect(result.selections).toHaveLength(0); +}); + +test("Should handle bracket-like patterns in new syntax", () => { + const result = getParsedSelector(parseLineSelector("1:3,5:7")); + expect(result.selections).toHaveLength(2); + // Should not be confused with old bracket syntax since we're using colon +}); + // ============= LINE RESOLUTION TESTS ============= test("Should resolve single positive line", () => { @@ -353,3 +415,49 @@ test("Should resolve with bounds clamping for ranges", () => { const result = resolveLineSelections(parsed, 10); expect(result).toEqual([8, 9, 10]); // Clamped to file bounds }); + +// ============= ADDITIONAL RESOLUTION EDGE CASES ============= + +test("Should resolve negative open-ended range to end", () => { + const parsed = getParsedSelector(parseLineSelector(":-3")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); // All except last 2 lines +}); + +test("Should resolve mixed positive and negative ranges", () => { + const parsed = getParsedSelector(parseLineSelector("1:3,-3:-1")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([1, 2, 3, 8, 9, 10]); // First 3 and last 3 +}); + +test("Should resolve complex exclusion patterns", () => { + const parsed = getParsedSelector(parseLineSelector("1:20,!5:7,!15,!-2")); + const result = resolveLineSelections(parsed, 20); + expect(result).toEqual([ + 1, 2, 3, 4, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 20, + ]); // Exclude 5-7, 15, and 19 (second to last) +}); + +test("Should handle single line file with negative indexing", () => { + const parsed = getParsedSelector(parseLineSelector("-1")); + const result = resolveLineSelections(parsed, 1); + expect(result).toEqual([1]); // Only line in file +}); + +test("Should handle overlapping ranges and exclusions", () => { + const parsed = getParsedSelector(parseLineSelector("1:10,5:15,!8:12")); + const result = resolveLineSelections(parsed, 20); + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 13, 14, 15]); // Combined ranges minus exclusions +}); + +test("Should resolve empty result when all lines excluded", () => { + const parsed = getParsedSelector(parseLineSelector("1:5,!1:5")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([]); // All included lines are excluded +}); + +test("Should handle negative exclusions properly", () => { + const parsed = getParsedSelector(parseLineSelector("-5:,!-2")); + const result = resolveLineSelections(parsed, 10); + expect(result).toEqual([6, 7, 8, 10]); // Last 5 lines except second to last +}); diff --git a/plugin/src/parseLineSelector.ts b/plugin/src/parseLineSelector.ts index aa2f019..0404b9c 100644 --- a/plugin/src/parseLineSelector.ts +++ b/plugin/src/parseLineSelector.ts @@ -17,7 +17,7 @@ export function parseLineSelector( return parseOldSyntax(trimmed); } - // Parse new Python-like syntax + // Parse new bracket syntax return parseNewSlicingSyntax(trimmed); } From b784916b8d02ed93c7d7c0f541e9743dcc66b014 Mon Sep 17 00:00:00 2001 From: spenpal Date: Wed, 2 Jul 2025 22:55:42 -0500 Subject: [PATCH 05/12] fix: update example syntax and improve test coverage - Changed example syntax in Chapter and Library classes from dash to colon format - Refactored tests to remove old dash syntax and ensure compatibility with new parsing logic - Enhanced error handling for out-of-range selections in tests - Updated parsing logic to throw errors for deprecated dash syntax - Improved test cases for line selection, including complex scenarios and exclusions --- demo/src/Chapter.ts | 2 +- demo/src/Library.ts | 2 +- plugin/src/IncludeExampleTag.ts | 1 - plugin/src/applyLineSelection.test.ts | 220 ++++++++----- plugin/src/applyLineSelection.ts | 22 +- plugin/src/parseIncludeExampleTag.test.ts | 67 +++- plugin/src/parseIncludeExampleTag.ts | 15 +- plugin/src/parseLineSelector.test.ts | 358 ++++++---------------- plugin/src/parseLineSelector.ts | 105 ++----- 9 files changed, 333 insertions(+), 459 deletions(-) diff --git a/demo/src/Chapter.ts b/demo/src/Chapter.ts index 4a261db..2462829 100644 --- a/demo/src/Chapter.ts +++ b/demo/src/Chapter.ts @@ -1,6 +1,6 @@ /** * Class representing a chapter. * - * @includeExample ./src/Chapter.example.ts[5-7,11,13] + * @includeExample ./src/Chapter.example.ts[5:7,11,13] */ export class Chapter {} diff --git a/demo/src/Library.ts b/demo/src/Library.ts index 575608b..312bced 100644 --- a/demo/src/Library.ts +++ b/demo/src/Library.ts @@ -3,7 +3,7 @@ import type { Book } from "./Book"; /** * A class representing a library. * - * @includeExample ./src/Library.example.ts[5-9] + * @includeExample ./src/Library.example.ts[5:9] */ export class Library { books: Book[] = []; diff --git a/plugin/src/IncludeExampleTag.ts b/plugin/src/IncludeExampleTag.ts index 98d48da..7347025 100644 --- a/plugin/src/IncludeExampleTag.ts +++ b/plugin/src/IncludeExampleTag.ts @@ -2,6 +2,5 @@ import type { ParsedLineSelector } from "./ParsedLineSelector.js"; export interface IncludeExampleTag { path: string; - lines?: number[]; parsedSelector?: ParsedLineSelector; } diff --git a/plugin/src/applyLineSelection.test.ts b/plugin/src/applyLineSelection.test.ts index 157418a..6681e45 100644 --- a/plugin/src/applyLineSelection.test.ts +++ b/plugin/src/applyLineSelection.test.ts @@ -3,63 +3,32 @@ import type { ParsedLineSelector } from "./ParsedLineSelector.js"; import { applyLineSelection } from "./applyLineSelection.js"; import { parseLineSelector } from "./parseLineSelector.js"; -// Helper function to create parsed selector +// Helper function to create parsed selectors for testing function createParsedSelector(selector: string): ParsedLineSelector { - const result = parseLineSelector(selector); - if (Array.isArray(result)) { - throw new Error("Expected ParsedLineSelector but got number[]"); - } - return result; + return parseLineSelector(selector); } -// ============= BACKWARDS COMPATIBILITY TESTS (Old lines array) ============= +// ============= BASIC FUNCTIONALITY TESTS ============= -test("It should select lines from file", () => { - const file = "hello\nthis\nis\na\nmultiline\nfile"; - - const includeExampleFile = { - path: "fake/file", - lines: [2, 4, 6], - }; - - const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("this\na\nfile"); -}); - -test("It return same content when no line selector is supplied", () => { +test("It should return same content when no line selector is supplied", () => { const file = "hello\nthis\nis\na\nmultiline\nfile"; const includeExampleFile = { path: "fake/file" }; const result = applyLineSelection(file, includeExampleFile); expect(result).toEqual(file); }); -test("It throw when line is out of range", () => { - const file = "hello\nthis\nis\na\nmultiline\nfile"; - - const includeExampleFile = { - path: "fake/file", - lines: [2, 4, 6, 8], - }; - - expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( - "Line number 8 is out of range for file fake/file", - ); -}); - -// ============= NEW PARSED SELECTOR TESTS ============= - -test("It should apply single line selection with new syntax", () => { +test("It should apply single line selection", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("3:3"), + parsedSelector: createParsedSelector("3"), }; const result = applyLineSelection(file, includeExampleFile); expect(result).toEqual("line3"); }); -test("It should apply range selection with new syntax", () => { +test("It should apply range selection", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { path: "test/file", @@ -70,6 +39,21 @@ test("It should apply range selection with new syntax", () => { expect(result).toEqual("line2\nline3\nline4"); }); +test("It should throw when line is out of range", () => { + const file = "hello\nthis\nis\na\nmultiline\nfile"; + + const includeExampleFile = { + path: "fake/file", + parsedSelector: createParsedSelector("8"), + }; + + expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( + "Line 8 is out of range (file has 6 lines)", + ); +}); + +// ============= NEGATIVE INDEXING TESTS ============= + test("It should apply negative indexing", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { @@ -92,6 +76,8 @@ test("It should apply negative range", () => { expect(result).toEqual("line3\nline4\nline5"); // Last 3 lines }); +// ============= OPEN-ENDED RANGE TESTS ============= + test("It should apply open-ended range from start", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { @@ -114,17 +100,43 @@ test("It should apply open-ended range to end", () => { expect(result).toEqual("line1\nline2\nline3"); }); +test("It should apply negative open-ended range", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-3:"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line3\nline4\nline5"); // Last 3 lines +}); + +// ============= MULTIPLE SELECTIONS TESTS ============= + test("It should apply multiple selections", () => { const file = "line1\nline2\nline3\nline4\nline5\nline6"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("1:2,5,6:6"), + parsedSelector: createParsedSelector("1:2,5,6"), }; const result = applyLineSelection(file, includeExampleFile); expect(result).toEqual("line1\nline2\nline5\nline6"); }); +test("It should apply complex multiple selections", () => { + const file = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:3,5:6,8"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline3\nline5\nline6\nline8"); +}); + +// ============= EXCLUSION TESTS ============= + test("It should apply exclusions", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { @@ -151,7 +163,7 @@ test("It should apply negative exclusions", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("-5:,!-2"), + parsedSelector: createParsedSelector("1:5,!-2"), }; const result = applyLineSelection(file, includeExampleFile); @@ -169,6 +181,18 @@ test("It should handle implicit full range with exclusions", () => { expect(result).toEqual("line1\nline3\nline5"); }); +test("It should apply complex exclusion patterns", () => { + const file = + "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:10,!3:5,!8,!-1"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline6\nline7\nline9"); +}); + // ============= EDGE CASES ============= test("It should handle empty files", () => { @@ -186,7 +210,7 @@ test("It should handle single line files", () => { const file = "only line"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("1:1"), + parsedSelector: createParsedSelector("1"), }; const result = applyLineSelection(file, includeExampleFile); @@ -204,86 +228,85 @@ test("It should handle single line with negative indexing", () => { expect(result).toEqual("only line"); }); -test("It should handle files with empty lines using new syntax", () => { +test("It should handle files with empty lines", () => { const file = "line1\n\nline3\n\nline5"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("2:2,4:4"), // Use new syntax explicitly + parsedSelector: createParsedSelector("2,4"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("\n"); // Empty lines 2 and 4 + expect(result).toEqual("\n"); }); -test("It should handle files ending with newline", () => { - const file = "line1\nline2\nline3\n"; +test("It should handle out of bounds scenarios gracefully", () => { + const file = "line1\nline2\nline3"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("2:4"), + parsedSelector: createParsedSelector("1:10"), // Range beyond file }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line2\nline3\n"); + expect(result).toEqual("line1\nline2\nline3"); // Should clamp to available lines }); -test("It should handle complex overlapping ranges and exclusions", () => { - const file = "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10"; +test("It should handle negative ranges that resolve to empty", () => { + const file = "line1\nline2"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("1:10,3:7,!5,!8:9"), + parsedSelector: createParsedSelector("-10"), // Single negative line beyond bounds }; - const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("L1\nL2\nL3\nL4\nL6\nL7\nL10"); // Combined ranges minus exclusions + expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( + "Line -10 is out of range (file has 2 lines)", + ); }); -// ============= ERROR HANDLING ============= - -test("It should throw when parsed selector results in out of range line", () => { - const file = "line1\nline2\nline3"; +test("It should handle complex mixed positive and negative selections", () => { + const file = + "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("5:5"), + parsedSelector: createParsedSelector("1:3,-3:,-6:-4,!2,!-2"), }; - // The error message comes from resolveLineSelections, not applyLineSelection - expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( - "Line 5 is out of range (file has 3 lines)", - ); + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline3\nline5\nline6\nline7\nline8\nline10"); }); -test("It should throw when negative indexing goes out of bounds", () => { - const file = "line1\nline2"; +// ============= WHITESPACE AND FORMATTING TESTS ============= + +test("It should preserve line content exactly", () => { + const file = " indented line \n\ttab line\t\n \n normal line"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("-5"), + parsedSelector: createParsedSelector("1:4"), }; - expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( - "Line -5 is out of range (file has 2 lines)", - ); + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual(" indented line \n\ttab line\t\n \n normal line"); }); -test("It should handle empty result from exclusions", () => { - const file = "line1\nline2\nline3"; +test("It should handle files ending with newlines", () => { + const file = "line1\nline2\nline3\n"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("1:3,!1:3"), + parsedSelector: createParsedSelector("2:3"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual(""); // All lines excluded + expect(result).toEqual("line2\nline3"); }); -test("It should handle empty selector (no lines selected)", () => { - const file = "line1\nline2\nline3"; +test("It should handle files with multiple consecutive newlines", () => { + const file = "line1\n\n\nline4"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector(""), // Empty selector returns all lines + parsedSelector: createParsedSelector("2:3"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line1\nline2\nline3"); // Empty selector means all lines selected + expect(result).toEqual("\n"); }); // ============= PERFORMANCE AND LARGE FILE TESTS ============= @@ -324,3 +347,48 @@ test("It should handle complex selections on large files", () => { expect(resultLines).not.toContain("line55"); expect(resultLines).not.toContain("line95"); }); + +test("It should handle negative indexing on large files", () => { + // Create a file with 500 lines + const lines = Array.from({ length: 500 }, (_, i) => `line${i + 1}`); + const file = lines.join("\n"); + + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-10:-1"), + }; + + const result = applyLineSelection(file, includeExampleFile); + const resultLines = result.split("\n"); + + expect(resultLines).toHaveLength(10); + expect(resultLines[0]).toBe("line491"); + expect(resultLines[9]).toBe("line500"); +}); + +test("It should handle many exclusions efficiently", () => { + // Create a file with 50 lines + const lines = Array.from({ length: 50 }, (_, i) => `line${i + 1}`); + const file = lines.join("\n"); + + // Exclude every 5th line + const exclusions = Array.from( + { length: 10 }, + (_, i) => `!${(i + 1) * 5}`, + ).join(","); + const selector = `1:50,${exclusions}`; + + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector(selector), + }; + + const result = applyLineSelection(file, includeExampleFile); + const resultLines = result.split("\n"); + + // Should have 50 - 10 = 40 lines + expect(resultLines).toHaveLength(40); + expect(resultLines).not.toContain("line5"); + expect(resultLines).not.toContain("line10"); + expect(resultLines).not.toContain("line50"); +}); diff --git a/plugin/src/applyLineSelection.ts b/plugin/src/applyLineSelection.ts index 592faf6..de4a773 100644 --- a/plugin/src/applyLineSelection.ts +++ b/plugin/src/applyLineSelection.ts @@ -7,7 +7,7 @@ export function applyLineSelection( ): string { const lines = content.split("\n"); - // Handle new parsed selector syntax + // Handle parsed selector syntax if (includeExampleTag.parsedSelector) { const resolvedLines = resolveLineSelections( includeExampleTag.parsedSelector, @@ -29,22 +29,6 @@ export function applyLineSelection( .join("\n"); } - // Handle old lines array (backwards compatibility) - if (includeExampleTag.lines === undefined) { - return content; - } - - return includeExampleTag.lines - .map((lineNumber: number) => { - const line = lines[lineNumber - 1]; - - if (line === undefined) { - throw new Error( - `Line number ${lineNumber} is out of range for file ${includeExampleTag.path}`, - ); - } - - return line; - }) - .join("\n"); + // No selector - use entire file + return content; } diff --git a/plugin/src/parseIncludeExampleTag.test.ts b/plugin/src/parseIncludeExampleTag.test.ts index 547bd80..3569dd5 100644 --- a/plugin/src/parseIncludeExampleTag.test.ts +++ b/plugin/src/parseIncludeExampleTag.test.ts @@ -6,14 +6,16 @@ test("it should parse include example tag", () => { expect(result).toEqual({ path: "path/to/file" }); }); -test("it should parse include example tag with a line selector", () => { - const result = parseIncludeExampleTag("path/to/file[2-4]"); - expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4] }); +test("it should throw error for old dash syntax in brackets", () => { + expect(() => parseIncludeExampleTag("path/to/file[2-4]")).toThrowError( + /BREAKING CHANGE: The dash syntax '2-4' inside brackets is no longer supported in v3\.0\.0\+/, + ); }); -test("it should parse include example tag with multiple line selectors", () => { - const result = parseIncludeExampleTag("path/to/file[2-4,15]"); - expect(result).toEqual({ path: "path/to/file", lines: [2, 3, 4, 15] }); +test("it should throw error for old dash syntax with multiple selectors", () => { + expect(() => parseIncludeExampleTag("path/to/file[2-4,15]")).toThrowError( + /BREAKING CHANGE: The dash syntax '2-4,15' inside brackets is no longer supported in v3\.0\.0\+/, + ); }); test("it should throw on empty path", () => { @@ -37,7 +39,7 @@ test("it should allow colons in file paths without selectors", () => { expect(result).toEqual({ path: "path:with:colons/file.ts" }); }); -// ============= ADDITIONAL BRACKET SYNTAX TESTS ============= +// ============= BRACKET SYNTAX TESTS ============= test("it should handle empty brackets (select all lines)", () => { const result = parseIncludeExampleTag("path/to/file[]"); @@ -56,7 +58,7 @@ test("it should handle colon-only brackets (select all lines)", () => { }); }); -test("it should parse bracket syntax in brackets", () => { +test("it should parse bracket syntax with colon ranges", () => { const result = parseIncludeExampleTag("path/to/file[2:8]"); expect(result.path).toBe("path/to/file"); expect(result.parsedSelector).toBeDefined(); @@ -69,6 +71,16 @@ test("it should parse bracket syntax in brackets", () => { }); }); +test("it should parse single line selection", () => { + const result = parseIncludeExampleTag("path/to/file[10]"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "inclusion", + single: 10, + isNegative: false, + }); +}); + test("it should parse negative indexing in brackets", () => { const result = parseIncludeExampleTag("path/to/file[-5:]"); expect(result.path).toBe("path/to/file"); @@ -94,10 +106,14 @@ test("it should handle file paths with brackets in the path itself", () => { expect(result).toEqual({ path: "path/with[brackets]/file.ts" }); }); -test("it should handle Windows-style paths with brackets (single number = old syntax)", () => { +test("it should handle Windows-style paths with brackets", () => { const result = parseIncludeExampleTag("C:\\path\\to\\file.ts[10]"); expect(result.path).toBe("C:\\path\\to\\file.ts"); - expect(result.lines).toEqual([10]); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "inclusion", + single: 10, + isNegative: false, + }); }); test("it should handle URLs with brackets", () => { @@ -144,8 +160,35 @@ test("it should handle relative paths with brackets", () => { }); }); -test("it should handle single character file names (single number = old syntax)", () => { +test("it should handle single character file names", () => { const result = parseIncludeExampleTag("a[1]"); expect(result.path).toBe("a"); - expect(result.lines).toEqual([1]); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "inclusion", + single: 1, + isNegative: false, + }); +}); + +test("it should handle multiple selections with colon syntax", () => { + const result = parseIncludeExampleTag("path/to/file[2:4,10,15:20]"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector?.selections).toHaveLength(3); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "inclusion", + start: 2, + end: 4, + isNegative: false, + }); + expect(result.parsedSelector?.selections[1]).toEqual({ + type: "inclusion", + single: 10, + isNegative: false, + }); + expect(result.parsedSelector?.selections[2]).toEqual({ + type: "inclusion", + start: 15, + end: 20, + isNegative: false, + }); }); diff --git a/plugin/src/parseIncludeExampleTag.ts b/plugin/src/parseIncludeExampleTag.ts index 20d0370..dd05547 100644 --- a/plugin/src/parseIncludeExampleTag.ts +++ b/plugin/src/parseIncludeExampleTag.ts @@ -16,14 +16,8 @@ export function parseIncludeExampleTag( // Parse the selector string using new bracket syntax const parsed = parseLineSelector(selectorString); - // Handle the two possible return types - if (Array.isArray(parsed)) { - // Old dash syntax returned number[] directly - includeExampleTag.lines = parsed; - } else { - // New syntax returned ParsedLineSelector - store for later resolution - includeExampleTag.parsedSelector = parsed; - } + // Store the parsed selector for later resolution + includeExampleTag.parsedSelector = parsed; return includeExampleTag; } @@ -39,7 +33,10 @@ export function parseIncludeExampleTag( if (potentialSelector.trim() && /^[\d\-,\s]+$/.test(potentialSelector)) { // This looks like old colon syntax with line selectors throw new Error( - `BREAKING CHANGE: The colon syntax '${tag}' is no longer supported in v3.0.0+. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector}]'. See documentation for the new bracket syntax.`, + `BREAKING CHANGE: The colon syntax '${tag}' is no longer supported in v3.0.0+. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector.replace( + /-/g, + ":", + )}]'. See documentation for the new bracket syntax.`, ); } } diff --git a/plugin/src/parseLineSelector.test.ts b/plugin/src/parseLineSelector.test.ts index 34bfb5e..e506e56 100644 --- a/plugin/src/parseLineSelector.test.ts +++ b/plugin/src/parseLineSelector.test.ts @@ -1,108 +1,36 @@ import { expect, test } from "vitest"; -import type { ParsedLineSelector } from "./ParsedLineSelector.js"; import { parseLineSelector } from "./parseLineSelector.js"; import { resolveLineSelections } from "./resolveLineSelections.js"; -// ============= BACKWARDS COMPATIBILITY TESTS (Old Dash Syntax) ============= +// ============= BREAKING CHANGE TESTS (Old Dash Syntax) ============= -test("Should parse a line (old syntax)", () => { - const result = parseLineSelector("15"); - expect(Array.isArray(result)).toBe(true); - expect(result).toEqual([15]); -}); - -test("Should parse a line when equal to 1 (old syntax)", () => { - const result = parseLineSelector("1"); - expect(Array.isArray(result)).toBe(true); - expect(result).toEqual([1]); -}); - -test("Should parse a range of lines (old syntax)", () => { - const result = parseLineSelector("2-4"); - expect(Array.isArray(result)).toBe(true); - expect(result).toEqual([2, 3, 4]); -}); - -test("Should parse a range of lines starting with 1 (old syntax)", () => { - const result = parseLineSelector("1-4"); - expect(Array.isArray(result)).toBe(true); - expect(result).toEqual([1, 2, 3, 4]); -}); - -test("Should parse multiple line selectors (old syntax)", () => { - const result = parseLineSelector("2-4,15"); - expect(Array.isArray(result)).toBe(true); - expect(result).toEqual([2, 3, 4, 15]); -}); - -test("Should throw error on missing range start (old syntax)", () => { - expect(() => parseLineSelector("x-4")).toThrowError( - "Failed to parse range start !", - ); -}); - -test("Should throw error on missing range end (old syntax)", () => { - expect(() => parseLineSelector("2-")).toThrowError( - "Failed to parse range end !", +test("Should throw error for old dash syntax single range", () => { + expect(() => parseLineSelector("2-4")).toThrowError( + /BREAKING CHANGE: The dash syntax '2-4' inside brackets is no longer supported in v3\.0\.0\+/, ); }); -test("Should throw error on bad range start (old syntax)", () => { - expect(() => parseLineSelector("bad-4")).toThrowError( - "Failed to parse range start !", +test("Should throw error for old dash syntax multiple selectors", () => { + expect(() => parseLineSelector("2-4,15")).toThrowError( + /BREAKING CHANGE: The dash syntax '2-4,15' inside brackets is no longer supported in v3\.0\.0\+/, ); }); -test("Should throw error on bad range end (old syntax)", () => { - expect(() => parseLineSelector("2-bad")).toThrowError( - "Failed to parse range end !", - ); -}); - -test("Should throw error on end being smaller than start (old syntax)", () => { - expect(() => parseLineSelector("4-2")).toThrowError( - "Range start is greater or equal to range end !", - ); -}); - -test("Should throw error on end being equal to start (old syntax)", () => { - expect(() => parseLineSelector("2-2")).toThrowError( - "Range start is greater or equal to range end !", - ); -}); - -test("Should throw error on start being smaller than 1 (old syntax)", () => { - expect(() => parseLineSelector("0-2")).toThrowError( - "Range start not positive !", - ); -}); - -test("Should throw error on bad single line (old syntax)", () => { - expect(() => parseLineSelector("bad")).toThrowError( - "Invalid line number: bad", +test("Should throw error for old dash syntax with exclusions", () => { + expect(() => parseLineSelector("2-4,!6-8")).toThrowError( + /BREAKING CHANGE: The dash syntax '2-4,!6-8' inside brackets is no longer supported in v3\.0\.0\+/, ); }); // ============= NEW BRACKET SYNTAX TESTS ============= -// Helper function to get ParsedLineSelector from result -function getParsedSelector( - result: number[] | ParsedLineSelector, -): ParsedLineSelector { - if (Array.isArray(result)) { - throw new Error("Expected ParsedLineSelector but got number[]"); - } - return result; -} - // Single line tests -test("Should parse single positive line with colon syntax", () => { - const result = getParsedSelector(parseLineSelector("10:10")); +test("Should parse single positive line", () => { + const result = parseLineSelector("10"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ type: "inclusion", - start: 10, - end: 10, + single: 10, isNegative: false, }); expect(result.hasNegativeIndexing).toBe(false); @@ -110,7 +38,7 @@ test("Should parse single positive line with colon syntax", () => { }); test("Should parse single negative line", () => { - const result = getParsedSelector(parseLineSelector("-5")); + const result = parseLineSelector("-5"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ type: "inclusion", @@ -123,7 +51,7 @@ test("Should parse single negative line", () => { // Range tests test("Should parse open-ended range from start", () => { - const result = getParsedSelector(parseLineSelector("5:")); + const result = parseLineSelector("5:"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ type: "inclusion", @@ -134,7 +62,7 @@ test("Should parse open-ended range from start", () => { }); test("Should parse open-ended range to end", () => { - const result = getParsedSelector(parseLineSelector(":10")); + const result = parseLineSelector(":10"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ type: "inclusion", @@ -145,7 +73,7 @@ test("Should parse open-ended range to end", () => { }); test("Should parse closed range", () => { - const result = getParsedSelector(parseLineSelector("2:8")); + const result = parseLineSelector("2:8"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ type: "inclusion", @@ -156,7 +84,7 @@ test("Should parse closed range", () => { }); test("Should parse negative range", () => { - const result = getParsedSelector(parseLineSelector("-10:-5")); + const result = parseLineSelector("-10:-5"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ type: "inclusion", @@ -168,7 +96,7 @@ test("Should parse negative range", () => { }); test("Should parse negative open-ended range", () => { - const result = getParsedSelector(parseLineSelector("-5:")); + const result = parseLineSelector("-5:"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ type: "inclusion", @@ -181,7 +109,7 @@ test("Should parse negative open-ended range", () => { // Multiple selections test("Should parse multiple selections", () => { - const result = getParsedSelector(parseLineSelector("2:5,10,15:20")); + const result = parseLineSelector("2:5,10,15:20"); expect(result.selections).toHaveLength(3); expect(result.selections[0]).toEqual({ type: "inclusion", @@ -204,7 +132,7 @@ test("Should parse multiple selections", () => { // Exclusion tests test("Should parse exclusions", () => { - const result = getParsedSelector(parseLineSelector("1:10,!5:7")); + const result = parseLineSelector("1:10,!5:7"); expect(result.selections).toHaveLength(2); expect(result.selections[0]).toEqual({ type: "inclusion", @@ -222,7 +150,7 @@ test("Should parse exclusions", () => { }); test("Should parse single line exclusion", () => { - const result = getParsedSelector(parseLineSelector("1:10,!5")); + const result = parseLineSelector(":10,!5"); expect(result.selections).toHaveLength(2); expect(result.selections[1]).toEqual({ type: "exclusion", @@ -232,232 +160,136 @@ test("Should parse single line exclusion", () => { expect(result.hasExclusions).toBe(true); }); -// Empty selector tests +test("Should parse negative exclusions", () => { + const result = parseLineSelector("1:20,!-5:-2"); + expect(result.selections).toHaveLength(2); + expect(result.selections[1]).toEqual({ + type: "exclusion", + start: 5, + end: 2, + isNegative: true, + }); + expect(result.hasNegativeIndexing).toBe(true); + expect(result.hasExclusions).toBe(true); +}); + +// Empty and special cases test("Should handle empty selector", () => { - const result = getParsedSelector(parseLineSelector("")); + const result = parseLineSelector(""); expect(result.selections).toHaveLength(0); expect(result.hasNegativeIndexing).toBe(false); expect(result.hasExclusions).toBe(false); }); test("Should handle colon-only selector", () => { - const result = getParsedSelector(parseLineSelector(":")); + const result = parseLineSelector(":"); expect(result.selections).toHaveLength(0); expect(result.hasNegativeIndexing).toBe(false); expect(result.hasExclusions).toBe(false); }); -// Error handling tests +// Error handling test("Should throw error on invalid line number", () => { - expect(() => parseLineSelector("abc")).toThrowError( - "Invalid line number: abc", + expect(() => parseLineSelector("bad")).toThrowError( + "Invalid line number: bad", ); }); test("Should throw error on invalid range start", () => { - expect(() => parseLineSelector("abc:5")).toThrowError( - "Invalid range start: abc", + expect(() => parseLineSelector("bad:10")).toThrowError( + "Invalid range start: bad", ); }); test("Should throw error on invalid range end", () => { - expect(() => parseLineSelector("5:abc")).toThrowError( - "Invalid range end: abc", + expect(() => parseLineSelector("5:bad")).toThrowError( + "Invalid range end: bad", ); }); -test("Should throw error on zero in range", () => { - expect(() => parseLineSelector("0:5")).toThrowError( +test("Should throw error on zero range start", () => { + expect(() => parseLineSelector("0:10")).toThrowError( "Range start must be positive or negative, not zero", ); }); -test("Should throw error on invalid positive range", () => { - expect(() => parseLineSelector("5:3")).toThrowError( - "Range start (5) must be less than or equal to range end (3)", +test("Should throw error on zero range end", () => { + expect(() => parseLineSelector("5:0")).toThrowError( + "Range end must be positive or negative, not zero", ); }); -// ============= ADDITIONAL EDGE CASE TESTS ============= - -test("Should parse complex mixed syntax", () => { - const result = getParsedSelector(parseLineSelector("1:5,10,15:,!3,!12:14")); - expect(result.selections).toHaveLength(5); - expect(result.hasExclusions).toBe(true); - expect(result.hasNegativeIndexing).toBe(false); -}); - -test("Should parse negative exclusions", () => { - const result = getParsedSelector(parseLineSelector(":-5,!-3")); - expect(result.selections).toHaveLength(2); - expect(result.selections[0]).toEqual({ - type: "inclusion", - start: undefined, - end: 5, - isNegative: true, - }); - expect(result.selections[1]).toEqual({ - type: "exclusion", - single: 3, - isNegative: true, - }); - expect(result.hasNegativeIndexing).toBe(true); - expect(result.hasExclusions).toBe(true); -}); - -test("Should handle whitespace in selectors", () => { - const result = getParsedSelector(parseLineSelector(" 2:5 , 10 , !7 ")); - expect(result.selections).toHaveLength(3); - expect(result.selections[0].start).toBe(2); - expect(result.selections[0].end).toBe(5); - expect(result.selections[1].single).toBe(10); - expect(result.selections[2].single).toBe(7); - expect(result.selections[2].type).toBe("exclusion"); -}); - -test("Should parse only exclusions (implicit full range)", () => { - const result = getParsedSelector(parseLineSelector("!3,!7:9")); - expect(result.selections).toHaveLength(2); - expect(result.selections[0].type).toBe("exclusion"); - expect(result.selections[1].type).toBe("exclusion"); - expect(result.hasExclusions).toBe(true); -}); - -test("Should handle single colon (empty range)", () => { - const result = getParsedSelector(parseLineSelector(":")); - expect(result.selections).toHaveLength(0); -}); - -test("Should handle bracket-like patterns in new syntax", () => { - const result = getParsedSelector(parseLineSelector("1:3,5:7")); - expect(result.selections).toHaveLength(2); - // Should not be confused with old bracket syntax since we're using colon -}); - -// ============= LINE RESOLUTION TESTS ============= - -test("Should resolve single positive line", () => { - const parsed = getParsedSelector(parseLineSelector("5:5")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([5]); -}); - -test("Should resolve single negative line", () => { - const parsed = getParsedSelector(parseLineSelector("-2")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([9]); // 10 - 2 + 1 = 9 -}); - -test("Should resolve range", () => { - const parsed = getParsedSelector(parseLineSelector("3:6")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([3, 4, 5, 6]); -}); - -test("Should resolve open-ended range from start", () => { - const parsed = getParsedSelector(parseLineSelector("8:")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([8, 9, 10]); +test("Should throw error on invalid positive range", () => { + expect(() => parseLineSelector("10:5")).toThrowError( + "Range start (10) must be less than or equal to range end (5)", + ); }); -test("Should resolve open-ended range to end", () => { - const parsed = getParsedSelector(parseLineSelector(":3")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([1, 2, 3]); -}); +// ============= RESOLUTION TESTS ============= -test("Should resolve negative range", () => { - const parsed = getParsedSelector(parseLineSelector("-5:-2")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([6, 7, 8, 9]); // Lines 6-9 (10-5+1 to 10-2+1) +test("Should resolve basic range", () => { + const parsed = parseLineSelector("2:5"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([2, 3, 4, 5]); }); -test("Should resolve multiple selections", () => { - const parsed = getParsedSelector(parseLineSelector("2:4,8,10")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([2, 3, 4, 8, 10]); +test("Should resolve negative indexing", () => { + const parsed = parseLineSelector("-3:"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([8, 9, 10]); }); test("Should resolve exclusions", () => { - const parsed = getParsedSelector(parseLineSelector("1:10,!5:7")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([1, 2, 3, 4, 8, 9, 10]); + const parsed = parseLineSelector("1:10,!5:7"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([1, 2, 3, 4, 8, 9, 10]); }); -test("Should resolve exclusions with all lines implicit", () => { - const parsed = getParsedSelector(parseLineSelector("!3,!7")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([1, 2, 4, 5, 6, 8, 9, 10]); +test("Should resolve complex mixed syntax", () => { + const parsed = parseLineSelector("2:8,15,20:25,!5:6,!22"); + const resolved = resolveLineSelections(parsed, 30); + expect(resolved).toEqual([2, 3, 4, 7, 8, 15, 20, 21, 23, 24, 25]); }); -test("Should handle out of bounds positive line", () => { - const parsed = getParsedSelector(parseLineSelector("15:15")); - expect(() => resolveLineSelections(parsed, 10)).toThrowError( - "Line 15 is out of range (file has 10 lines)", - ); -}); - -test("Should handle out of bounds negative line", () => { - const parsed = getParsedSelector(parseLineSelector("-15")); +test("Should handle out of bounds negative indexing", () => { + const parsed = parseLineSelector("-15"); expect(() => resolveLineSelections(parsed, 10)).toThrowError( "Line -15 is out of range (file has 10 lines)", ); }); test("Should handle empty file", () => { - const parsed = getParsedSelector(parseLineSelector("5:5")); - const result = resolveLineSelections(parsed, 0); - expect(result).toEqual([]); -}); - -test("Should resolve with bounds clamping for ranges", () => { - const parsed = getParsedSelector(parseLineSelector("8:15")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([8, 9, 10]); // Clamped to file bounds + const parsed = parseLineSelector("1:5"); + const resolved = resolveLineSelections(parsed, 0); + expect(resolved).toEqual([]); }); -// ============= ADDITIONAL RESOLUTION EDGE CASES ============= - -test("Should resolve negative open-ended range to end", () => { - const parsed = getParsedSelector(parseLineSelector(":-3")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); // All except last 2 lines -}); - -test("Should resolve mixed positive and negative ranges", () => { - const parsed = getParsedSelector(parseLineSelector("1:3,-3:-1")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([1, 2, 3, 8, 9, 10]); // First 3 and last 3 +test("Should handle single line file", () => { + const parsed = parseLineSelector("1:5"); + const resolved = resolveLineSelections(parsed, 1); + expect(resolved).toEqual([1]); }); -test("Should resolve complex exclusion patterns", () => { - const parsed = getParsedSelector(parseLineSelector("1:20,!5:7,!15,!-2")); - const result = resolveLineSelections(parsed, 20); - expect(result).toEqual([ - 1, 2, 3, 4, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 20, - ]); // Exclude 5-7, 15, and 19 (second to last) +test("Should resolve only exclusions (include all then exclude)", () => { + const parsed = parseLineSelector("!3:5"); + const resolved = resolveLineSelections(parsed, 7); + expect(resolved).toEqual([1, 2, 6, 7]); }); -test("Should handle single line file with negative indexing", () => { - const parsed = getParsedSelector(parseLineSelector("-1")); - const result = resolveLineSelections(parsed, 1); - expect(result).toEqual([1]); // Only line in file +test("Should resolve negative ranges correctly", () => { + const parsed = parseLineSelector("-5:-2"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([6, 7, 8, 9]); }); -test("Should handle overlapping ranges and exclusions", () => { - const parsed = getParsedSelector(parseLineSelector("1:10,5:15,!8:12")); - const result = resolveLineSelections(parsed, 20); - expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 13, 14, 15]); // Combined ranges minus exclusions +test("Should resolve mixed positive and negative", () => { + const parsed = parseLineSelector("2:5,-3:"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([2, 3, 4, 5, 8, 9, 10]); }); -test("Should resolve empty result when all lines excluded", () => { - const parsed = getParsedSelector(parseLineSelector("1:5,!1:5")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([]); // All included lines are excluded -}); - -test("Should handle negative exclusions properly", () => { - const parsed = getParsedSelector(parseLineSelector("-5:,!-2")); - const result = resolveLineSelections(parsed, 10); - expect(result).toEqual([6, 7, 8, 10]); // Last 5 lines except second to last +test("Should handle whitespace in selectors", () => { + const parsed = parseLineSelector(" 2:5 , 10 , !7 "); + const resolved = resolveLineSelections(parsed, 15); + expect(resolved).toEqual([2, 3, 4, 5, 10]); }); diff --git a/plugin/src/parseLineSelector.ts b/plugin/src/parseLineSelector.ts index 0404b9c..0d4143c 100644 --- a/plugin/src/parseLineSelector.ts +++ b/plugin/src/parseLineSelector.ts @@ -3,103 +3,54 @@ import type { ParsedLineSelector } from "./ParsedLineSelector.js"; export function parseLineSelector( lineSelectorString: string, -): number[] | ParsedLineSelector { +): ParsedLineSelector { // Handle empty or whitespace-only selectors const trimmed = lineSelectorString.trim(); if (!trimmed || trimmed === ":") { return { selections: [], hasNegativeIndexing: false, hasExclusions: false }; } - // Check if this is old syntax (backwards compatibility) - // Old syntax: single positive numbers, dash ranges, or comma-separated combinations - // But exclude single negative numbers and anything with colons or exclamations - if (isOldSyntax(trimmed)) { - return parseOldSyntax(trimmed); + // v3.0.0: Only support new bracket syntax with colons + // Old dash syntax is no longer supported + if (containsOldDashSyntax(trimmed)) { + throw new Error( + `BREAKING CHANGE: The dash syntax '${trimmed}' inside brackets is no longer supported in v3.0.0+. Please use colon syntax instead. Examples: '2-4' → '2:4', '5-7,11' → '5:7,11'. See documentation for the new bracket syntax.`, + ); } // Parse new bracket syntax return parseNewSlicingSyntax(trimmed); } -function isOldSyntax(selector: string): boolean { - // Single positive number - if (/^\d+$/.test(selector)) { - return true; - } - - // Contains new syntax features - definitely not old syntax - if (selector.includes(":") || selector.includes("!")) { - return false; - } - - // Single negative number - definitely new syntax - if (/^-\d+$/.test(selector)) { - return false; - } - - // Check if all comma-separated parts are old-style (numbers or dash ranges) - const parts = selector.split(",").map((p) => p.trim()); - return parts.every((part) => { - // Single positive number - if (/^\d+$/.test(part)) { - return true; - } - // Dash range (valid or malformed) - anything with dash that's not a single negative number - if (part.includes("-") && !/^-\d+$/.test(part)) { - return true; - } - return false; - }); -} - -function parseOldSyntax(selector: string): number[] { - const result: number[] = []; - - // Split by comma to handle multiple selectors +function containsOldDashSyntax(selector: string): boolean { + // Split by comma to check each part const parts = selector.split(",").map((p) => p.trim()); - for (const part of parts) { - if (!part) continue; - - // Handle single line number - if (!part.includes("-")) { - const line = Number.parseInt(part); - if (!Number.isFinite(line)) { - throw new Error("Failed to parse line number !"); - } - result.push(line); - continue; - } - - // Handle dash range - const lineRange: string[] = part.split("-"); - const startString: string | undefined = lineRange[0]; - const endString: string | undefined = lineRange[1]; - const start: number = Number.parseInt(startString); - const end: number = Number.parseInt(endString); - - if (!Number.isFinite(start)) { - throw new Error("Failed to parse range start !"); - } + return parts.some((part) => { + // Skip exclusions for this check + const cleanPart = part.startsWith("!") ? part.slice(1) : part; - if (!Number.isFinite(end)) { - throw new Error("Failed to parse range end !"); - } + // Check for old dash range patterns like "2-4" but not negative numbers + // Old dash syntax: number-number (like "2-4", "10-15") + // NOT old syntax: "-5" (negative number), "-5:" (negative with colon), "5:-3" (contains colon) - if (start < 1) { - throw new Error("Range start not positive !"); + // If it contains a colon, it's new syntax (even with negative numbers) + if (cleanPart.includes(":")) { + return false; } - if (start >= end) { - throw new Error("Range start is greater or equal to range end !"); - } - - for (let i = start; i <= end; i++) { - result.push(i); + // Check for dash patterns that are NOT single negative numbers + if (cleanPart.includes("-")) { + // Single negative number is new syntax + if (/^-\d+$/.test(cleanPart)) { + return false; + } + // Dash range like "2-4" is old syntax + return true; } - } - return result; + return false; + }); } function parseNewSlicingSyntax(selector: string): ParsedLineSelector { From 91e8642498483211c8c21c730974ea304109d7c3 Mon Sep 17 00:00:00 2001 From: spenpal Date: Wed, 2 Jul 2025 22:57:49 -0500 Subject: [PATCH 06/12] fix: use ApplicationType import for better type safety in load function - Change parameter type from Application to ApplicationType to avoid type/value import conflicts in generated .d.ts files - Prevents Biome linting errors on generated declaration files by separating type-only imports from value imports - Maintains runtime functionality while improving TypeScript compilation --- plugin/src/load.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/plugin/src/load.ts b/plugin/src/load.ts index bacda2d..9e9117f 100644 --- a/plugin/src/load.ts +++ b/plugin/src/load.ts @@ -1,15 +1,16 @@ +import type { Application as ApplicationType } from "typedoc"; import { Application, Converter } from "typedoc"; import { processComments } from "./processComments.js"; -export function load(application: Application) { - application.on(Application.EVENT_BOOTSTRAP_END, () => { - application.options.setValue("blockTags", [ - ...new Set([ - ...application.options.getValue("blockTags"), - "@includeExample", - ]), - ]); - }); +export function load(application: ApplicationType) { + application.on(Application.EVENT_BOOTSTRAP_END, () => { + application.options.setValue("blockTags", [ + ...new Set([ + ...application.options.getValue("blockTags"), + "@includeExample", + ]), + ]); + }); - application.converter.on(Converter.EVENT_RESOLVE_BEGIN, processComments); + application.converter.on(Converter.EVENT_RESOLVE_BEGIN, processComments); } From ba9ccdadf7848e95be6a8cca484e67fd7ae93727 Mon Sep 17 00:00:00 2001 From: spenpal Date: Wed, 2 Jul 2025 23:29:59 -0500 Subject: [PATCH 07/12] docs: update CONTRIBUTING and README for new Docker setup and line selection syntax - Added Apple Silicon compatibility options in CONTRIBUTING.md for Docker builds - Updated README to reflect new bracket syntax for line selection and improved documentation structure - Enhanced examples and troubleshooting sections for clarity and usability --- CONTRIBUTING.md | 36 +++++++-- README.md | 52 ++----------- docs.md | 193 ++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 201 insertions(+), 80 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 441ba90..8067875 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,19 +12,41 @@ We offer two methods to set up your development environment: 2. Run the following command in the project root: ``` -docker compose up -d --build +docker compose up -d --build ``` - + Run this command everytime you need to verify your modifications. - + +#### Apple Silicon (ARM64) Compatibility + +If you're using an Apple Silicon Mac and encounter Docker build errors, you have several options: + +**Option A: Local Override File (Recommended)** +Create a `docker-compose.override.yml` file in the project root: + +```yaml +services: + plugin: + platform: linux/amd64 + demo: + platform: linux/amd64 +``` + +**Option B: Environment Variable** + +```bash +export DOCKER_DEFAULT_PLATFORM=linux/amd64 +docker compose up -d --build +``` + ### Option 2: Manual Setup - + If you prefer not to use Docker, follow these steps: - + 1. Install project dependencies: ``` -npm install +npm install ``` 2. Set up Playwright: @@ -37,7 +59,7 @@ npx playwright install 3. Build the project: ``` -npm run build +npm run build ``` ## Code Coverage and Testing diff --git a/README.md b/README.md index e2c68ad..f985536 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![npm](https://img.shields.io/badge/coverage-blue)](https://ferdodo.github.io/typedoc-plugin-include-example/reports/mutation/mutation.html) [![npm](https://img.shields.io/badge/demo-green)](https://ferdodo.github.io/typedoc-plugin-include-example/) -Include code examples in your [typedoc](https://typedoc.org/) documentations with powerful Python-like slicing syntax. +Include code examples in your [typedoc](https://typedoc.org/) documentations with powerful bracket syntax for line selection. ## Installation @@ -47,58 +47,22 @@ Then generate your documentation using typedoc using this plugin. $ npx typedoc --plugin typedoc-plugin-include-example ``` -## Line Selection Syntax +## Line Selection -### Basic Usage +Control which lines to include with bracket syntax: ```typescript /** - * @includeExample greet.example.ts // Include entire file - * @includeExample greet.example.ts[5] // Include line 5 only - * @includeExample greet.example.ts[2:8] // Include lines 2-8 + * @includeExample greet.example.ts // Include entire file + * @includeExample greet.example.ts[5] // Include line 5 only + * @includeExample greet.example.ts[2:8] // Include lines 2-8 + * @includeExample greet.example.ts[2:5,10,15:20] // Multiple selections */ ``` -### Advanced Python-like Slicing - -```typescript -/** - * @includeExample greet.example.ts[5:] // From line 5 to end - * @includeExample greet.example.ts[:10] // From start to line 10 - * @includeExample greet.example.ts[-5:] // Last 5 lines - * @includeExample greet.example.ts[:-3] // All except last 3 lines - * @includeExample greet.example.ts[-5:-2] // Lines -5 to -2 (negative indexing) - */ -``` - -### Multiple Selections & Exclusions - -```typescript -/** - * @includeExample greet.example.ts[2:5,10,15:20] // Lines 2-5, 10, and 15-20 - * @includeExample greet.example.ts[1:20,!8:12] // Lines 1-20 except 8-12 - * @includeExample greet.example.ts[:10,!3,!7] // Lines 1-10 except 3 and 7 - */ -``` - -## 🚨 Breaking Changes in v3.0.0 - -The old colon syntax is no longer supported. Please migrate: - -```diff -- @includeExample path/to/file:15 -+ @includeExample path/to/file[15] - -- @includeExample path/to/file:2-4 -+ @includeExample path/to/file[2:4] - -- @includeExample path/to/file:2-4,15 -+ @includeExample path/to/file[2:4,15] -``` - ## Features -See the [Documentation](./docs.md) for full usage. +See the [Documentation](./docs.md) for full usage including advanced bracket syntax, exclusions, negative indexing, and troubleshooting. ## Links diff --git a/docs.md b/docs.md index 434ffba..5166573 100644 --- a/docs.md +++ b/docs.md @@ -1,62 +1,197 @@ - # Documentation -## Basic usage +## Quick Start -Include the whole file as an example. +The simplest way to include an example is to place the `@includeExample` tag in your JSDoc comment. The plugin will automatically look for a corresponding `.example.ts` file with the same name. -```javascript +```typescript /** + * Says hello to the world + * * @includeExample */ -function greet() { +export function greet() { + console.log("Hello, world!"); } ``` -## Specify file path +This will include the entire content of `greet.example.ts` in your documentation. + +## File Path Specification -Include a specific file as an example. +### Automatic File Discovery -```javascript +When no file path is specified, the plugin looks for a file with the same name as the current file, but with `.example.ts` extension: + +```typescript +// In Author.ts /** - * @includeExample src/special-file.example.ts + * @includeExample // Looks for Author.example.ts */ -function greet() { -} +class Author {} ``` -## Selecting specific lines +### Explicit File Paths -Include only the line 25 from the example. +You can specify a custom file path: -```javascript +```typescript /** - * @includeExample src/greet.example.ts:25 + * @includeExample src/examples/custom-example.ts + * @includeExample ../shared/common.example.ts + * @includeExample ./utils/helper.example.ts */ -function greet() { -} ``` -## Selecting a line range +## Line Selection Syntax + +### Single Line Selection -Include line 5 to 20 from the example. +Include only a specific line using positive or negative indexing: -```javascript +```typescript /** - * @includeExample src/greet.example.ts:5-20 + * @includeExample greet.example.ts[5] // Include line 5 + * @includeExample greet.example.ts[1] // Include line 1 (first line) + * @includeExample greet.example.ts[-1] // Include last line + * @includeExample greet.example.ts[-2] // Include second-to-last line + */ +``` + +### Range Selection + +Include a range of lines: + +```typescript +/** + * @includeExample greet.example.ts[2:8] // Include lines 2 through 8 + * @includeExample greet.example.ts[1:5] // Include lines 1 through 5 + * @includeExample greet.example.ts[-5:-2] // Include 5th-from-last to 2nd-from-last + */ +``` + +### Open-Ended Ranges + +Include from a line to the end, or from the beginning to a line: + +```typescript +/** + * @includeExample greet.example.ts[5:] // From line 5 to end of file + * @includeExample greet.example.ts[:10] // From beginning to line 10 + * @includeExample greet.example.ts[-5:] // Last 5 lines + * @includeExample greet.example.ts[:-3] // All lines except last 3 + */ +``` + +### Multiple Selections + +Combine multiple line selections with commas: + +```typescript +/** + * @includeExample greet.example.ts[2:5,10,15:20] // Lines 2-5, line 10, and lines 15-20 + * @includeExample greet.example.ts[1,3,5:8,12] // Lines 1, 3, 5-8, and 12 + * @includeExample greet.example.ts[10:15,20:25,-1] // Lines 10-15, 20-25, and last line */ -function greet() { -} ``` -## Multiple line selection +### Exclusion Syntax -Include line 5 to 20 then line 22 then line 40 from the example. +Use the `!` prefix to exclude specific lines or ranges: -```javascript +```typescript /** - * @includeExample src/greet.example.ts:5-20,22,40 + * @includeExample greet.example.ts[1:20,!8:12] // Lines 1-20 except lines 8-12 + * @includeExample greet.example.ts[:10,!3,!7] // Lines 1-10 except lines 3 and 7 + * @includeExample greet.example.ts[5:15,!10] // Lines 5-15 except line 10 + * @includeExample greet.example.ts[!1:3,!-2:] // Entire file except lines 1-3 and last 2 lines */ -function greet() { +``` + +## Example + +```typescript +// math.example.ts +import { Calculator } from "./calculator"; + +const calc = new Calculator(); + +// Basic operations +calc.add(5, 3); // Line 6 +calc.subtract(10, 4); // Line 7 +calc.multiply(3, 7); // Line 8 + +// Advanced operations +calc.power(2, 8); // Line 11 +calc.sqrt(16); // Line 12 + +// Error handling +try { + calc.divide(10, 0); // Line 16 +} catch (error) { + console.error(error); // Line 18 +} +``` + +```typescript +/** + * Calculator class with various mathematical operations + * + * @includeExample math.example.ts[6:8] // Show basic operations only + * @includeExample math.example.ts[11:12] // Show advanced operations only + * @includeExample math.example.ts[15:19] // Show error handling only + * @includeExample math.example.ts[6:8,11:12] // Show basic and advanced operations + * @includeExample math.example.ts[1:19,!9:10] // Show everything except empty lines + */ +class Calculator { + // ... implementation } -``` \ No newline at end of file +``` + +## Troubleshooting + +### Common Issues + +1. **File not found**: Ensure the example file exists and the path is correct +2. **Line numbers out of range**: Check that specified lines exist in the file +3. **Empty selection**: Verify that exclusions don't eliminate all lines +4. **Syntax errors**: Ensure bracket syntax is properly formatted + +### Debugging Tips + +1. **Start simple**: Begin with `@includeExample` (no brackets) to verify the file is found +2. **Check line numbers**: Use `[:]` to see all lines with their numbers +3. **Test incrementally**: Add line selections one at a time +4. **Verify exclusions**: Ensure `!` exclusions make sense with included ranges + +### Error Messages + +The plugin provides helpful error messages: + +- `Example file not found: path/to/file.example.ts` +- `Line 25 is out of range (file has 20 lines)` +- `Invalid bracket syntax: [5:3]` (end before start) +- `Empty selection after applying exclusions` + +## Migration from v2.x + +The old colon syntax is no longer supported. Here's how to migrate: + +```diff +- @includeExample path/to/file:15 ++ @includeExample path/to/file[15] + +- @includeExample path/to/file:2-4 ++ @includeExample path/to/file[2:4] + +- @includeExample path/to/file:2-4,15 ++ @includeExample path/to/file[2:4,15] + +- @includeExample path/to/file:5- ++ @includeExample path/to/file[5:] + +- @includeExample path/to/file:-10 ++ @includeExample path/to/file[:10] +``` + +The new bracket syntax is more powerful and supports negative indexing and exclusions that weren't possible with the old syntax. From 09bb5d54d931ec51f74dbe5aabc2b5d7e2e6d36c Mon Sep 17 00:00:00 2001 From: spenpal Date: Thu, 3 Jul 2025 08:04:46 -0500 Subject: [PATCH 08/12] refactor: split line selection parsing functionality - Cleaned up whitespace and formatting in load.ts for consistency - Refactored parseLineSelector.ts to enhance readability and maintainability - Introduced helper functions for range and single line parsing - Improved error handling and validation in resolveLineSelections.ts - Ensured all changes maintain compatibility with existing syntax and functionality --- plugin/src/load.ts | 18 +- plugin/src/parseLineSelector.ts | 289 +++++++++++++++------------- plugin/src/resolveLineSelections.ts | 259 +++++++++++++++---------- 3 files changed, 321 insertions(+), 245 deletions(-) diff --git a/plugin/src/load.ts b/plugin/src/load.ts index 9e9117f..6e8be63 100644 --- a/plugin/src/load.ts +++ b/plugin/src/load.ts @@ -3,14 +3,14 @@ import { Application, Converter } from "typedoc"; import { processComments } from "./processComments.js"; export function load(application: ApplicationType) { - application.on(Application.EVENT_BOOTSTRAP_END, () => { - application.options.setValue("blockTags", [ - ...new Set([ - ...application.options.getValue("blockTags"), - "@includeExample", - ]), - ]); - }); + application.on(Application.EVENT_BOOTSTRAP_END, () => { + application.options.setValue("blockTags", [ + ...new Set([ + ...application.options.getValue("blockTags"), + "@includeExample", + ]), + ]); + }); - application.converter.on(Converter.EVENT_RESOLVE_BEGIN, processComments); + application.converter.on(Converter.EVENT_RESOLVE_BEGIN, processComments); } diff --git a/plugin/src/parseLineSelector.ts b/plugin/src/parseLineSelector.ts index 0d4143c..6ab88df 100644 --- a/plugin/src/parseLineSelector.ts +++ b/plugin/src/parseLineSelector.ts @@ -2,154 +2,179 @@ import type { LineSelection } from "./LineSelection.js"; import type { ParsedLineSelector } from "./ParsedLineSelector.js"; export function parseLineSelector( - lineSelectorString: string, + lineSelectorString: string ): ParsedLineSelector { - // Handle empty or whitespace-only selectors - const trimmed = lineSelectorString.trim(); - if (!trimmed || trimmed === ":") { - return { selections: [], hasNegativeIndexing: false, hasExclusions: false }; - } - - // v3.0.0: Only support new bracket syntax with colons - // Old dash syntax is no longer supported - if (containsOldDashSyntax(trimmed)) { - throw new Error( - `BREAKING CHANGE: The dash syntax '${trimmed}' inside brackets is no longer supported in v3.0.0+. Please use colon syntax instead. Examples: '2-4' → '2:4', '5-7,11' → '5:7,11'. See documentation for the new bracket syntax.`, - ); - } - - // Parse new bracket syntax - return parseNewSlicingSyntax(trimmed); + // Handle empty or whitespace-only selectors + const trimmed = lineSelectorString.trim(); + if (!trimmed || trimmed === ":") { + return { selections: [], hasNegativeIndexing: false, hasExclusions: false }; + } + + // v3.0.0: Only support new bracket syntax with colons + // Old dash syntax is no longer supported + if (containsOldDashSyntax(trimmed)) { + throw new Error( + `BREAKING CHANGE: The dash syntax '${trimmed}' inside brackets is no longer supported in v3.0.0+. Please use colon syntax instead. Examples: '2-4' → '2:4', '5-7,11' → '5:7,11'. See documentation for the new bracket syntax.` + ); + } + + // Parse new bracket syntax + return parseNewSlicingSyntax(trimmed); } function containsOldDashSyntax(selector: string): boolean { - // Split by comma to check each part - const parts = selector.split(",").map((p) => p.trim()); - - return parts.some((part) => { - // Skip exclusions for this check - const cleanPart = part.startsWith("!") ? part.slice(1) : part; - - // Check for old dash range patterns like "2-4" but not negative numbers - // Old dash syntax: number-number (like "2-4", "10-15") - // NOT old syntax: "-5" (negative number), "-5:" (negative with colon), "5:-3" (contains colon) - - // If it contains a colon, it's new syntax (even with negative numbers) - if (cleanPart.includes(":")) { - return false; - } - - // Check for dash patterns that are NOT single negative numbers - if (cleanPart.includes("-")) { - // Single negative number is new syntax - if (/^-\d+$/.test(cleanPart)) { - return false; - } - // Dash range like "2-4" is old syntax - return true; - } - - return false; - }); + return selector + .split(",") + .map((p) => p.trim()) + .some(hasOldDashPattern); +} + +function hasOldDashPattern(part: string): boolean { + // Skip exclusions for this check + const cleanPart = part.startsWith("!") ? part.slice(1) : part; + + // If it contains a colon, it's new syntax (even with negative numbers) + if (cleanPart.includes(":")) { + return false; + } + + // Check for dash patterns that are NOT single negative numbers + if (cleanPart.includes("-")) { + // Single negative number is new syntax + if (isNegativeNumber(cleanPart)) { + return false; + } + // Dash range like "2-4" is old syntax + return true; + } + + return false; +} + +function isNegativeNumber(value: string): boolean { + return /^-\d+$/.test(value); } function parseNewSlicingSyntax(selector: string): ParsedLineSelector { - const selections: LineSelection[] = []; - let hasNegativeIndexing = false; - let hasExclusions = false; + const selections: LineSelection[] = []; + let hasNegativeIndexing = false; + let hasExclusions = false; - // Split by comma to handle multiple selections - const parts = selector.split(",").map((part) => part.trim()); + // Split by comma to handle multiple selections + const parts = selector.split(",").map((part) => part.trim()); - for (const part of parts) { - if (!part) continue; + for (const part of parts) { + if (!part) continue; - // Check for exclusion syntax - const isExclusion = part.startsWith("!"); - const cleanPart = isExclusion ? part.slice(1) : part; + // Check for exclusion syntax + const isExclusion = part.startsWith("!"); + const cleanPart = isExclusion ? part.slice(1) : part; - if (isExclusion) { - hasExclusions = true; - } + if (isExclusion) { + hasExclusions = true; + } - // Parse the individual selection - const selection = parseIndividualSelection(cleanPart); - selection.type = isExclusion ? "exclusion" : "inclusion"; + // Parse the individual selection + const selection = parseIndividualSelection(cleanPart); + selection.type = isExclusion ? "exclusion" : "inclusion"; - if (selection.isNegative) { - hasNegativeIndexing = true; - } + if (selection.isNegative) { + hasNegativeIndexing = true; + } - selections.push(selection); - } + selections.push(selection); + } - return { selections, hasNegativeIndexing, hasExclusions }; + return { selections, hasNegativeIndexing, hasExclusions }; } function parseIndividualSelection(part: string): LineSelection { - // Handle single line (positive or negative) - if (!part.includes(":")) { - const num = Number.parseInt(part); - if (!Number.isFinite(num)) { - throw new Error(`Invalid line number: ${part}`); - } - return { - type: "inclusion", - single: Math.abs(num), - isNegative: num < 0, - }; - } - - // Handle range syntax (start:end, start:, :end, :) - const colonIndex = part.indexOf(":"); - const startStr = part.slice(0, colonIndex); - const endStr = part.slice(colonIndex + 1); - - let start: number | undefined; - let end: number | undefined; - let isNegative = false; - - // Parse start - if (startStr) { - start = Number.parseInt(startStr); - if (!Number.isFinite(start)) { - throw new Error(`Invalid range start: ${startStr}`); - } - if (start < 0) { - isNegative = true; - start = Math.abs(start); - } else if (start < 1) { - throw new Error("Range start must be positive or negative, not zero"); - } - } - - // Parse end - if (endStr) { - end = Number.parseInt(endStr); - if (!Number.isFinite(end)) { - throw new Error(`Invalid range end: ${endStr}`); - } - if (end < 0) { - isNegative = true; - end = Math.abs(end); - } else if (end < 1) { - throw new Error("Range end must be positive or negative, not zero"); - } - } - - // Validate range logic for positive numbers - if (start !== undefined && end !== undefined && !isNegative) { - if (start > end) { - throw new Error( - `Range start (${start}) must be less than or equal to range end (${end})`, - ); - } - } - - return { - type: "inclusion", - start, - end, - isNegative, - }; + // Handle single line (positive or negative) + if (!part.includes(":")) { + return parseSingleLine(part); + } + + // Handle range syntax (start:end, start:, :end, :) + return parseRange(part); +} + +function parseSingleLine(part: string): LineSelection { + const num = Number.parseInt(part); + if (!Number.isFinite(num)) { + throw new Error(`Invalid line number: ${part}`); + } + return { + type: "inclusion", + single: Math.abs(num), + isNegative: num < 0, + }; +} + +function parseRange(part: string): LineSelection { + const colonIndex = part.indexOf(":"); + const startStr = part.slice(0, colonIndex); + const endStr = part.slice(colonIndex + 1); + + const { start, end, isNegative } = parseRangeNumbers(startStr, endStr); + + validateRangeLogic(start, end, isNegative); + + return { + type: "inclusion", + start, + end, + isNegative, + }; +} + +function parseRangeNumbers(startStr: string, endStr: string) { + let start: number | undefined; + let end: number | undefined; + let isNegative = false; + + // Parse start + if (startStr) { + start = parseRangeNumber(startStr, "start"); + if (start < 0) { + isNegative = true; + start = Math.abs(start); + } + } + + // Parse end + if (endStr) { + end = parseRangeNumber(endStr, "end"); + if (end < 0) { + isNegative = true; + end = Math.abs(end); + } + } + + return { start, end, isNegative }; +} + +function parseRangeNumber(value: string, type: "start" | "end"): number { + const num = Number.parseInt(value); + if (!Number.isFinite(num)) { + throw new Error(`Invalid range ${type}: ${value}`); + } + if (num === 0) { + throw new Error(`Range ${type} must be positive or negative, not zero`); + } + return num; +} + +function validateRangeLogic( + start: number | undefined, + end: number | undefined, + isNegative: boolean +): void { + // Validate range logic for positive numbers + if (start !== undefined && end !== undefined && !isNegative) { + if (start > end) { + throw new Error( + `Range start (${start}) must be less than or equal to range end (${end})` + ); + } + } } diff --git a/plugin/src/resolveLineSelections.ts b/plugin/src/resolveLineSelections.ts index 77a1e0d..e788b79 100644 --- a/plugin/src/resolveLineSelections.ts +++ b/plugin/src/resolveLineSelections.ts @@ -2,113 +2,164 @@ import type { LineSelection } from "./LineSelection.js"; import type { ParsedLineSelector } from "./ParsedLineSelector.js"; export function resolveLineSelections( - parsed: ParsedLineSelector, - totalLines: number, + parsed: ParsedLineSelector, + totalLines: number ): number[] { - if (totalLines <= 0) { - return []; - } - - const includedLines = new Set(); - const excludedLines = new Set(); - - // Process all selections - for (const selection of parsed.selections) { - const lines = resolveSelection(selection, totalLines); - - if (selection.type === "inclusion") { - for (const line of lines) { - includedLines.add(line); - } - } else { - for (const line of lines) { - excludedLines.add(line); - } - } - } - - // If no inclusions specified, include all lines - if ( - parsed.selections.length === 0 || - parsed.selections.every((s) => s.type === "exclusion") - ) { - for (let i = 1; i <= totalLines; i++) { - includedLines.add(i); - } - } - - // Apply exclusions - for (const excludedLine of excludedLines) { - includedLines.delete(excludedLine); - } - - // Return sorted array - return Array.from(includedLines).sort((a, b) => a - b); + if (totalLines <= 0) { + return []; + } + + const includedLines = new Set(); + const excludedLines = new Set(); + + // Process all selections + for (const selection of parsed.selections) { + const lines = resolveSelection(selection, totalLines); + addLinesToSet( + lines, + selection.type === "inclusion" ? includedLines : excludedLines + ); + } + + // If no inclusions specified, include all lines + if (shouldIncludeAllLines(parsed)) { + addAllLinesToSet(includedLines, totalLines); + } + + // Apply exclusions + applyExclusions(includedLines, excludedLines); + + // Return sorted array + return Array.from(includedLines).sort((a, b) => a - b); +} + +function addLinesToSet(lines: number[], targetSet: Set): void { + for (const line of lines) { + targetSet.add(line); + } +} + +function shouldIncludeAllLines(parsed: ParsedLineSelector): boolean { + return ( + parsed.selections.length === 0 || + parsed.selections.every((s) => s.type === "exclusion") + ); +} + +function addAllLinesToSet( + includedLines: Set, + totalLines: number +): void { + for (let i = 1; i <= totalLines; i++) { + includedLines.add(i); + } +} + +function applyExclusions( + includedLines: Set, + excludedLines: Set +): void { + for (const excludedLine of excludedLines) { + includedLines.delete(excludedLine); + } } function resolveSelection( - selection: LineSelection, - totalLines: number, + selection: LineSelection, + totalLines: number +): number[] { + // Handle single line + if (selection.single !== undefined) { + return resolveSingleLine(selection, totalLines); + } + + // Handle range + return resolveRange(selection, totalLines); +} + +function resolveSingleLine( + selection: LineSelection, + totalLines: number ): number[] { - // Handle single line - if (selection.single !== undefined) { - const line = selection.isNegative - ? totalLines - selection.single + 1 - : selection.single; - - if (line < 1 || line > totalLines) { - throw new Error( - `Line ${ - selection.isNegative ? -selection.single : selection.single - } is out of range (file has ${totalLines} lines)`, - ); - } - - return [line]; - } - - // Handle range - let start = selection.start; - let end = selection.end; - - // Resolve negative indexing - if (selection.isNegative) { - if (start !== undefined) { - start = totalLines - start + 1; - } - if (end !== undefined) { - end = totalLines - end + 1; - } - // For negative ranges, swap start and end if needed - if (start !== undefined && end !== undefined && start > end) { - [start, end] = [end, start]; - } - } - - // Default to full range if not specified - if (start === undefined) start = 1; - if (end === undefined) end = totalLines; - - // Check for completely out-of-bounds ranges - if (start > totalLines) { - throw new Error( - `Line ${start} is out of range (file has ${totalLines} lines)`, - ); - } - - // Validate bounds (clamp to valid range) - start = Math.max(1, Math.min(start, totalLines)); - end = Math.max(1, Math.min(end, totalLines)); - - if (start > end) { - return []; - } - - // Generate range - const result: number[] = []; - for (let i = start; i <= end; i++) { - result.push(i); - } - - return result; + const singleValue = selection.single; + if (singleValue === undefined) { + throw new Error("Single line value is undefined"); + } + + const line = selection.isNegative + ? totalLines - singleValue + 1 + : singleValue; + + if (line < 1 || line > totalLines) { + throw new Error( + `Line ${ + selection.isNegative ? -singleValue : singleValue + } is out of range (file has ${totalLines} lines)` + ); + } + + return [line]; +} + +function resolveRange(selection: LineSelection, totalLines: number): number[] { + let start = selection.start; + let end = selection.end; + + // Resolve negative indexing + if (selection.isNegative) { + ({ start, end } = resolveNegativeRange(start, end, totalLines)); + } + + // Default to full range if not specified + if (start === undefined) start = 1; + if (end === undefined) end = totalLines; + + // Validate and clamp bounds + validateRangeBounds(start, totalLines); + start = Math.max(1, Math.min(start, totalLines)); + end = Math.max(1, Math.min(end, totalLines)); + + if (start > end) { + return []; + } + + // Generate range + return generateRange(start, end); +} + +function resolveNegativeRange( + start: number | undefined, + end: number | undefined, + totalLines: number +): { start: number | undefined; end: number | undefined } { + let newStart = start; + let newEnd = end; + + if (newStart !== undefined) { + newStart = totalLines - newStart + 1; + } + if (newEnd !== undefined) { + newEnd = totalLines - newEnd + 1; + } + // For negative ranges, swap start and end if needed + if (newStart !== undefined && newEnd !== undefined && newStart > newEnd) { + [newStart, newEnd] = [newEnd, newStart]; + } + return { start: newStart, end: newEnd }; +} + +function validateRangeBounds(start: number, totalLines: number): void { + if (start > totalLines) { + throw new Error( + `Line ${start} is out of range (file has ${totalLines} lines)` + ); + } +} + +function generateRange(start: number, end: number): number[] { + const result: number[] = []; + for (let i = start; i <= end; i++) { + result.push(i); + } + return result; } From eb2d6531da94d709e2e3d88276d9c286e9c6d6c2 Mon Sep 17 00:00:00 2001 From: spenpal Date: Thu, 3 Jul 2025 08:10:07 -0500 Subject: [PATCH 09/12] chore: remove CHANGELOG.md --- CHANGELOG.md | 60 ---------------------------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 30be0e0..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,60 +0,0 @@ -# Changelog - -## [3.0.0] - 2024-12-19 - -### 🚀 Features - -- **Python-like slicing syntax**: New bracket syntax for line selection with advanced features - - Single line: `path/to/file[10]` - - Open-ended ranges: `path/to/file[2:]` or `path/to/file[:5]` - - Closed range: `path/to/file[1:5]` - - Multiple selections: `path/to/file[2:5,10]` - - Exclusions: `path/to/file[1:10,!5:7]` or `path/to/file[:10,!3,!7]` - - Negative indexing: `path/to/file[-5]`, `path/to/file[-5:]`, `path/to/file[:-5]`, `path/to/file[-5:-2]` - -### 💥 BREAKING CHANGES - -- **Colon syntax deprecated**: The old colon-based syntax (`path/to/file:2-4`) is no longer supported -- **Migration required**: All existing colon syntax must be migrated to bracket syntax -- **Version requirement**: Requires TypeDoc 0.26.x, 0.27.x, or 0.28.x - -### 🔄 Migration Guide - -#### Old Syntax → New Syntax - -```diff -- @includeExample path/to/file:15 -+ @includeExample path/to/file[15] - -- @includeExample path/to/file:2-4 -+ @includeExample path/to/file[2:4] - -- @includeExample path/to/file:2-4,15 -+ @includeExample path/to/file[2:4,15] -``` - -#### New Advanced Features - -```typescript -// Negative indexing (last 5 lines) -@includeExample path/to/file[-5:] - -// Exclusions (lines 1-10 except 5-7) -@includeExample path/to/file[1:10,!5:7] - -// Open-ended ranges (from line 5 to end) -@includeExample path/to/file[5:] -``` - -### 🛠️ Technical Changes - -- Complete rewrite of line selector parsing system -- New `ParsedLineSelector` interface for complex syntax support -- Enhanced error handling with descriptive migration messages -- Full backwards compatibility detection -- Comprehensive test coverage (52 tests) - -### 📦 Dependencies - -- No new dependencies added -- Maintains compatibility with existing TypeDoc versions From 531c408a2289f8fa5286c410b23b1c7b1b869a80 Mon Sep 17 00:00:00 2001 From: spenpal Date: Sun, 6 Jul 2025 19:24:52 -0500 Subject: [PATCH 10/12] test: enhance line selection tests and refactor parsing logic - Updated tests for line selection to cover new mixed positive/negative range scenarios and exclusions. - Refactored parsing logic to improve readability and maintainability, including the introduction of utility functions. - Enhanced error handling for invalid line numbers and range logic. - Ensured compatibility with new bracket syntax and improved test coverage across various edge cases. --- plugin/src/IncludeExampleTag.ts | 4 +- plugin/src/LineSelection.ts | 10 +- plugin/src/ParsedLineSelector.ts | 4 +- plugin/src/applyLineSelection.test.ts | 249 ++++++++-------- plugin/src/parseIncludeExampleTag.test.ts | 254 +++++++++------- plugin/src/parseIncludeExampleTag.ts | 22 +- plugin/src/parseLineSelector.test.ts | 345 +++++++++++++--------- plugin/src/parseLineSelector.ts | 283 ++++++++---------- plugin/src/resolveLineSelections.ts | 245 ++++++--------- plugin/src/utils.ts | 15 + 10 files changed, 735 insertions(+), 696 deletions(-) create mode 100644 plugin/src/utils.ts diff --git a/plugin/src/IncludeExampleTag.ts b/plugin/src/IncludeExampleTag.ts index 7347025..77bd91a 100644 --- a/plugin/src/IncludeExampleTag.ts +++ b/plugin/src/IncludeExampleTag.ts @@ -1,6 +1,6 @@ import type { ParsedLineSelector } from "./ParsedLineSelector.js"; -export interface IncludeExampleTag { +export type IncludeExampleTag = { path: string; parsedSelector?: ParsedLineSelector; -} +}; diff --git a/plugin/src/LineSelection.ts b/plugin/src/LineSelection.ts index 79aab1d..497cb83 100644 --- a/plugin/src/LineSelection.ts +++ b/plugin/src/LineSelection.ts @@ -1,7 +1,3 @@ -export interface LineSelection { - type: "inclusion" | "exclusion"; - start?: number; - end?: number; - single?: number; - isNegative?: boolean; -} +export type LineSelection = + | { type: "single"; isExclusion: boolean; line: number } + | { type: "range"; isExclusion: boolean; start?: number; end?: number }; diff --git a/plugin/src/ParsedLineSelector.ts b/plugin/src/ParsedLineSelector.ts index 2294254..59e04a7 100644 --- a/plugin/src/ParsedLineSelector.ts +++ b/plugin/src/ParsedLineSelector.ts @@ -1,7 +1,7 @@ import type { LineSelection } from "./LineSelection.js"; -export interface ParsedLineSelector { +export type ParsedLineSelector = { selections: LineSelection[]; hasNegativeIndexing: boolean; hasExclusions: boolean; -} +}; diff --git a/plugin/src/applyLineSelection.test.ts b/plugin/src/applyLineSelection.test.ts index 6681e45..fb6d072 100644 --- a/plugin/src/applyLineSelection.test.ts +++ b/plugin/src/applyLineSelection.test.ts @@ -8,14 +8,7 @@ function createParsedSelector(selector: string): ParsedLineSelector { return parseLineSelector(selector); } -// ============= BASIC FUNCTIONALITY TESTS ============= - -test("It should return same content when no line selector is supplied", () => { - const file = "hello\nthis\nis\na\nmultiline\nfile"; - const includeExampleFile = { path: "fake/file" }; - const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual(file); -}); +// ============= BASIC TESTS ============= test("It should apply single line selection", () => { const file = "line1\nline2\nline3\nline4\nline5"; @@ -28,6 +21,17 @@ test("It should apply single line selection", () => { expect(result).toEqual("line3"); }); +test("It should apply negative single line selection", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-2"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line4"); // Second to last line +}); + test("It should apply range selection", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { @@ -39,30 +43,26 @@ test("It should apply range selection", () => { expect(result).toEqual("line2\nline3\nline4"); }); -test("It should throw when line is out of range", () => { - const file = "hello\nthis\nis\na\nmultiline\nfile"; - +test("It should apply open-ended range from start", () => { + const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { - path: "fake/file", - parsedSelector: createParsedSelector("8"), + path: "test/file", + parsedSelector: createParsedSelector("3:"), }; - expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( - "Line 8 is out of range (file has 6 lines)", - ); + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line3\nline4\nline5"); }); -// ============= NEGATIVE INDEXING TESTS ============= - -test("It should apply negative indexing", () => { +test("It should apply open-ended range to end", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("-2"), + parsedSelector: createParsedSelector(":3"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line4"); // Second to last line + expect(result).toEqual("line1\nline2\nline3"); }); test("It should apply negative range", () => { @@ -76,39 +76,74 @@ test("It should apply negative range", () => { expect(result).toEqual("line3\nline4\nline5"); // Last 3 lines }); -// ============= OPEN-ENDED RANGE TESTS ============= +test("It should apply negative open-ended range", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-3:"), + }; -test("It should apply open-ended range from start", () => { + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line3\nline4\nline5"); // Last 3 lines +}); + +test("It should apply open-ended range to negative end", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("3:"), + parsedSelector: createParsedSelector(":-2"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line3\nline4\nline5"); + expect(result).toEqual("line1\nline2\nline3\nline4"); // All except last line }); -test("It should apply open-ended range to end", () => { +// ============= NEW: MIXED POSITIVE/NEGATIVE TESTS ============= + +test("It should apply mixed positive to negative range", () => { + const file = + "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("3:-3"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line3\nline4\nline5\nline6\nline7\nline8"); // Line 3 to line 8 (10 - 3 + 1 = 8) +}); + +test("It should apply mixed negative to positive range", () => { + const file = + "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-7:4"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line4"); // Line 4 (10 - 7 + 1 = 4) to line 4, so just line 4 +}); + +test("It should handle mixed range that results in empty selection", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector(":3"), + parsedSelector: createParsedSelector("4:-1"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line1\nline2\nline3"); + expect(result).toEqual("line4\nline5"); // Line 4 to line 5 (5 - 1 + 1 = 5) }); -test("It should apply negative open-ended range", () => { +test("It should handle mixed range with reverse order", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("-3:"), + parsedSelector: createParsedSelector("-2:3"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line3\nline4\nline5"); // Last 3 lines + expect(result).toEqual(""); // Line 4 (5 - 2 + 1 = 4) to line 3, but 4 > 3, so empty }); // ============= MULTIPLE SELECTIONS TESTS ============= @@ -135,9 +170,21 @@ test("It should apply complex multiple selections", () => { expect(result).toEqual("line1\nline2\nline3\nline5\nline6\nline8"); }); +test("It should apply mixed positive and negative selections", () => { + const file = + "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:3,-3:"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline3\nline8\nline9\nline10"); +}); + // ============= EXCLUSION TESTS ============= -test("It should apply exclusions", () => { +test("It should apply single line exclusions", () => { const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { path: "test/file", @@ -160,42 +207,42 @@ test("It should apply range exclusions", () => { }); test("It should apply negative exclusions", () => { - const file = "line1\nline2\nline3\nline4\nline5"; + const file = "line1\nline2\nline3\nline4\nline5\nline6\nline7"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("1:5,!-2"), + parsedSelector: createParsedSelector("1:7,!-3:-1"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line1\nline2\nline3\nline5"); // All lines except second to last + expect(result).toEqual("line1\nline2\nline3\nline4"); // Exclude last 3 lines }); -test("It should handle implicit full range with exclusions", () => { - const file = "line1\nline2\nline3\nline4\nline5"; +test("It should apply mixed positive/negative exclusions", () => { + const file = + "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("!2,!4"), + parsedSelector: createParsedSelector("1:10,!3:-3"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line1\nline3\nline5"); + expect(result).toEqual("line1\nline2\nline9\nline10"); // Exclude lines 3-8 }); -test("It should apply complex exclusion patterns", () => { - const file = - "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10"; +test("It should handle only exclusions (include all then exclude)", () => { + const file = "line1\nline2\nline3\nline4\nline5"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("1:10,!3:5,!8,!-1"), + parsedSelector: createParsedSelector("!2:4"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line1\nline2\nline6\nline7\nline9"); + expect(result).toEqual("line1\nline5"); }); // ============= EDGE CASES ============= -test("It should handle empty files", () => { +test("It should handle empty file", () => { const file = ""; const includeExampleFile = { path: "test/file", @@ -206,48 +253,26 @@ test("It should handle empty files", () => { expect(result).toEqual(""); }); -test("It should handle single line files", () => { - const file = "only line"; +test("It should handle single line file", () => { + const file = "onlyline"; const includeExampleFile = { path: "test/file", parsedSelector: createParsedSelector("1"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("only line"); -}); - -test("It should handle single line with negative indexing", () => { - const file = "only line"; - const includeExampleFile = { - path: "test/file", - parsedSelector: createParsedSelector("-1"), - }; - - const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("only line"); -}); - -test("It should handle files with empty lines", () => { - const file = "line1\n\nline3\n\nline5"; - const includeExampleFile = { - path: "test/file", - parsedSelector: createParsedSelector("2,4"), - }; - - const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("\n"); + expect(result).toEqual("onlyline"); }); -test("It should handle out of bounds scenarios gracefully", () => { +test("It should handle file with no parsedSelector", () => { const file = "line1\nline2\nline3"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("1:10"), // Range beyond file + parsedSelector: undefined, }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line1\nline2\nline3"); // Should clamp to available lines + expect(result).toEqual("line1\nline2\nline3"); }); test("It should handle negative ranges that resolve to empty", () => { @@ -274,59 +299,34 @@ test("It should handle complex mixed positive and negative selections", () => { expect(result).toEqual("line1\nline3\nline5\nline6\nline7\nline8\nline10"); }); -// ============= WHITESPACE AND FORMATTING TESTS ============= - -test("It should preserve line content exactly", () => { - const file = " indented line \n\ttab line\t\n \n normal line"; - const includeExampleFile = { - path: "test/file", - parsedSelector: createParsedSelector("1:4"), - }; - - const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual(" indented line \n\ttab line\t\n \n normal line"); -}); - -test("It should handle files ending with newlines", () => { - const file = "line1\nline2\nline3\n"; - const includeExampleFile = { - path: "test/file", - parsedSelector: createParsedSelector("2:3"), - }; - - const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("line2\nline3"); -}); +// ============= ERROR CASES ============= -test("It should handle files with multiple consecutive newlines", () => { - const file = "line1\n\n\nline4"; +test("It should throw error on out of range line", () => { + const file = "line1\nline2\nline3"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("2:3"), + parsedSelector: createParsedSelector("5"), }; - const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("\n"); + expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( + "Line 5 is out of range (file has 3 lines)", + ); }); -// ============= PERFORMANCE AND LARGE FILE TESTS ============= - -test("It should handle large line numbers efficiently", () => { - // Create a file with 1000 lines - const lines = Array.from({ length: 1000 }, (_, i) => `line${i + 1}`); - const file = lines.join("\n"); - +test("It should throw error on out of range negative line", () => { + const file = "line1\nline2\nline3"; const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector("995:1000"), + parsedSelector: createParsedSelector("-5"), }; - const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual( - "line995\nline996\nline997\nline998\nline999\nline1000", + expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( + "Line -5 is out of range (file has 3 lines)", ); }); +// ============= PERFORMANCE TESTS ============= + test("It should handle complex selections on large files", () => { // Create a file with 100 lines const lines = Array.from({ length: 100 }, (_, i) => `line${i + 1}`); @@ -366,29 +366,24 @@ test("It should handle negative indexing on large files", () => { expect(resultLines[9]).toBe("line500"); }); -test("It should handle many exclusions efficiently", () => { - // Create a file with 50 lines - const lines = Array.from({ length: 50 }, (_, i) => `line${i + 1}`); +test("It should handle mixed ranges on large files", () => { + // Create a file with 1000 lines + const lines = Array.from({ length: 1000 }, (_, i) => `line${i + 1}`); const file = lines.join("\n"); - // Exclude every 5th line - const exclusions = Array.from( - { length: 10 }, - (_, i) => `!${(i + 1) * 5}`, - ).join(","); - const selector = `1:50,${exclusions}`; - const includeExampleFile = { path: "test/file", - parsedSelector: createParsedSelector(selector), + parsedSelector: createParsedSelector("1:10,500:510,!5,!505"), }; const result = applyLineSelection(file, includeExampleFile); const resultLines = result.split("\n"); - // Should have 50 - 10 = 40 lines - expect(resultLines).toHaveLength(40); + // Should have lines 1-10 and 500-510, minus line 5 and line 505 + expect(resultLines).toHaveLength(19); // 10 + 11 - 2 = 19 + expect(resultLines[0]).toBe("line1"); expect(resultLines).not.toContain("line5"); - expect(resultLines).not.toContain("line10"); - expect(resultLines).not.toContain("line50"); + expect(resultLines).not.toContain("line505"); + expect(resultLines).toContain("line500"); + expect(resultLines).toContain("line510"); }); diff --git a/plugin/src/parseIncludeExampleTag.test.ts b/plugin/src/parseIncludeExampleTag.test.ts index 3569dd5..deba4ab 100644 --- a/plugin/src/parseIncludeExampleTag.test.ts +++ b/plugin/src/parseIncludeExampleTag.test.ts @@ -1,60 +1,39 @@ import { expect, test } from "vitest"; import { parseIncludeExampleTag } from "./parseIncludeExampleTag.js"; -test("it should parse include example tag", () => { - const result = parseIncludeExampleTag("path/to/file"); - expect(result).toEqual({ path: "path/to/file" }); -}); - -test("it should throw error for old dash syntax in brackets", () => { - expect(() => parseIncludeExampleTag("path/to/file[2-4]")).toThrowError( - /BREAKING CHANGE: The dash syntax '2-4' inside brackets is no longer supported in v3\.0\.0\+/, - ); +test("it should parse tag without file path", () => { + const result = parseIncludeExampleTag(""); + expect(result.path).toBe(""); + expect(result.parsedSelector).toBeUndefined(); }); -test("it should throw error for old dash syntax with multiple selectors", () => { - expect(() => parseIncludeExampleTag("path/to/file[2-4,15]")).toThrowError( - /BREAKING CHANGE: The dash syntax '2-4,15' inside brackets is no longer supported in v3\.0\.0\+/, - ); -}); - -test("it should throw on empty path", () => { - expect(() => parseIncludeExampleTag("")).toThrowError("Path not found !"); -}); - -test("it should throw migration error for old colon syntax", () => { - expect(() => parseIncludeExampleTag("path/to/file:2-4")).toThrowError( - /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4' is no longer supported in v3\.0\.0\+/, - ); -}); - -test("it should throw migration error for old colon syntax with multiple selectors", () => { - expect(() => parseIncludeExampleTag("path/to/file:2-4,15")).toThrowError( - /BREAKING CHANGE: The colon syntax 'path\/to\/file:2-4,15' is no longer supported in v3\.0\.0\+/, - ); -}); - -test("it should allow colons in file paths without selectors", () => { - const result = parseIncludeExampleTag("path:with:colons/file.ts"); - expect(result).toEqual({ path: "path:with:colons/file.ts" }); +test("it should parse tag with file path only", () => { + const result = parseIncludeExampleTag("path/to/file"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector).toBeUndefined(); }); -// ============= BRACKET SYNTAX TESTS ============= - -test("it should handle empty brackets (select all lines)", () => { - const result = parseIncludeExampleTag("path/to/file[]"); - expect(result).toEqual({ path: "path/to/file[]" }); +test("it should parse tag with file path and single line", () => { + const result = parseIncludeExampleTag("path/to/file[5]"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector).toBeDefined(); + expect(result.parsedSelector?.selections).toHaveLength(1); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "single", + isExclusion: false, + line: 5, + }); }); -test("it should handle colon-only brackets (select all lines)", () => { - const result = parseIncludeExampleTag("path/to/file[:]"); - expect(result).toEqual({ - path: "path/to/file", - parsedSelector: { - selections: [], - hasNegativeIndexing: false, - hasExclusions: false, - }, +test("it should parse tag with file path and negative single line", () => { + const result = parseIncludeExampleTag("path/to/file[-3]"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector).toBeDefined(); + expect(result.parsedSelector?.selections).toHaveLength(1); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "single", + isExclusion: false, + line: -3, }); }); @@ -64,20 +43,21 @@ test("it should parse bracket syntax with colon ranges", () => { expect(result.parsedSelector).toBeDefined(); expect(result.parsedSelector?.selections).toHaveLength(1); expect(result.parsedSelector?.selections[0]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 2, end: 8, - isNegative: false, }); }); -test("it should parse single line selection", () => { - const result = parseIncludeExampleTag("path/to/file[10]"); +test("it should parse open-ended ranges", () => { + const result = parseIncludeExampleTag("path/to/file[5:]"); expect(result.path).toBe("path/to/file"); expect(result.parsedSelector?.selections[0]).toEqual({ - type: "inclusion", - single: 10, - isNegative: false, + type: "range", + isExclusion: false, + start: 5, + end: undefined, }); }); @@ -86,87 +66,78 @@ test("it should parse negative indexing in brackets", () => { expect(result.path).toBe("path/to/file"); expect(result.parsedSelector?.hasNegativeIndexing).toBe(true); expect(result.parsedSelector?.selections[0]).toEqual({ - type: "inclusion", - start: 5, + type: "range", + isExclusion: false, + start: -5, end: undefined, - isNegative: true, }); }); -test("it should parse exclusions in brackets", () => { +test("it should parse mixed positive/negative ranges", () => { + const result = parseIncludeExampleTag("path/to/file[2:-5]"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector?.hasNegativeIndexing).toBe(true); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: 2, + end: -5, + }); +}); + +test("it should parse exclusions", () => { const result = parseIncludeExampleTag("path/to/file[1:10,!5:7]"); expect(result.path).toBe("path/to/file"); expect(result.parsedSelector?.hasExclusions).toBe(true); expect(result.parsedSelector?.selections).toHaveLength(2); - expect(result.parsedSelector?.selections[1].type).toBe("exclusion"); -}); - -test("it should handle file paths with brackets in the path itself", () => { - const result = parseIncludeExampleTag("path/with[brackets]/file.ts"); - expect(result).toEqual({ path: "path/with[brackets]/file.ts" }); -}); - -test("it should handle Windows-style paths with brackets", () => { - const result = parseIncludeExampleTag("C:\\path\\to\\file.ts[10]"); - expect(result.path).toBe("C:\\path\\to\\file.ts"); - expect(result.parsedSelector?.selections[0]).toEqual({ - type: "inclusion", - single: 10, - isNegative: false, + expect(result.parsedSelector?.selections[1]).toEqual({ + type: "range", + isExclusion: true, + start: 5, + end: 7, }); }); -test("it should handle URLs with brackets", () => { - const result = parseIncludeExampleTag("https://example.com/file.ts[1:5]"); - expect(result.path).toBe("https://example.com/file.ts"); +test("it should handle file paths with spaces", () => { + const result = parseIncludeExampleTag("path with spaces/file.ts[5]"); + expect(result.path).toBe("path with spaces/file.ts"); expect(result.parsedSelector?.selections[0]).toEqual({ - type: "inclusion", - start: 1, - end: 5, - isNegative: false, + type: "single", + isExclusion: false, + line: 5, }); }); -test("it should handle paths with spaces and brackets", () => { - const result = parseIncludeExampleTag("path with spaces/file.ts[2:4]"); - expect(result.path).toBe("path with spaces/file.ts"); +test("it should handle file paths with dots", () => { + const result = parseIncludeExampleTag("../parent/file.example.ts[2:4]"); + expect(result.path).toBe("../parent/file.example.ts"); expect(result.parsedSelector?.selections[0]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 2, end: 4, - isNegative: false, }); }); -test("it should detect old syntax even with complex selectors", () => { - expect(() => parseIncludeExampleTag("path/to/file:1,3,5-7,10")).toThrowError( - /BREAKING CHANGE: The colon syntax 'path\/to\/file:1,3,5-7,10' is no longer supported in v3\.0\.0\+/, - ); -}); - -test("it should not confuse colons in paths with old syntax", () => { - const result = parseIncludeExampleTag("http://example.com:8080/file.ts"); - expect(result).toEqual({ path: "http://example.com:8080/file.ts" }); -}); - test("it should handle relative paths with brackets", () => { const result = parseIncludeExampleTag("../parent/file.ts[5:]"); expect(result.path).toBe("../parent/file.ts"); expect(result.parsedSelector?.selections[0]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 5, end: undefined, - isNegative: false, }); }); -test("it should handle single character file names", () => { - const result = parseIncludeExampleTag("a[1]"); - expect(result.path).toBe("a"); +test("it should handle absolute paths with brackets", () => { + const result = parseIncludeExampleTag("/absolute/path/file.ts[:10]"); + expect(result.path).toBe("/absolute/path/file.ts"); expect(result.parsedSelector?.selections[0]).toEqual({ - type: "inclusion", - single: 1, - isNegative: false, + type: "range", + isExclusion: false, + start: undefined, + end: 10, }); }); @@ -175,20 +146,79 @@ test("it should handle multiple selections with colon syntax", () => { expect(result.path).toBe("path/to/file"); expect(result.parsedSelector?.selections).toHaveLength(3); expect(result.parsedSelector?.selections[0]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 2, end: 4, - isNegative: false, }); expect(result.parsedSelector?.selections[1]).toEqual({ - type: "inclusion", - single: 10, - isNegative: false, + type: "single", + isExclusion: false, + line: 10, }); expect(result.parsedSelector?.selections[2]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 15, end: 20, - isNegative: false, }); }); + +test("it should handle complex mixed selections", () => { + const result = parseIncludeExampleTag("path/to/file[1:5,-3:,!2,!-1]"); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector?.selections).toHaveLength(4); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: 1, + end: 5, + }); + expect(result.parsedSelector?.selections[1]).toEqual({ + type: "range", + isExclusion: false, + start: -3, + end: undefined, + }); + expect(result.parsedSelector?.selections[2]).toEqual({ + type: "single", + isExclusion: true, + line: 2, + }); + expect(result.parsedSelector?.selections[3]).toEqual({ + type: "single", + isExclusion: true, + line: -1, + }); +}); + +// Error cases +test("it should throw error on old dash syntax", () => { + expect(() => parseIncludeExampleTag("path/to/file[2-4]")).toThrowError( + /BREAKING CHANGE: The dash syntax/, + ); +}); + +test("it should throw error on invalid bracket syntax", () => { + expect(() => parseIncludeExampleTag("path/to/file[invalid]")).toThrowError( + "Invalid line number: invalid", + ); +}); + +test("it should throw error on zero line number", () => { + expect(() => parseIncludeExampleTag("path/to/file[0]")).toThrowError( + "Line number must be positive or negative, not zero", + ); +}); + +test("it should throw error on malformed brackets", () => { + expect(() => parseIncludeExampleTag("path/to/file[")).toThrowError( + "Malformed bracket syntax", + ); +}); + +test("it should throw error on empty brackets", () => { + expect(() => parseIncludeExampleTag("path/to/file[]")).toThrowError( + "Empty bracket syntax", + ); +}); diff --git a/plugin/src/parseIncludeExampleTag.ts b/plugin/src/parseIncludeExampleTag.ts index dd05547..4ebaee0 100644 --- a/plugin/src/parseIncludeExampleTag.ts +++ b/plugin/src/parseIncludeExampleTag.ts @@ -5,12 +5,23 @@ export function parseIncludeExampleTag( tag: string, filePath?: string, ): IncludeExampleTag { - // Check for new bracket syntax: path/to/file[selector] - const bracketMatch = tag.match(/^(.+?)\[(.+)\]$/); + // Handle empty tag + if (!tag && !filePath) { + return { path: "" }; + } + + // Check for new bracket syntax: path/to/file[selector] or path/to/file[] + const bracketMatch = tag.match(/^(.+?)\[(.*)?\]$/); if (bracketMatch) { // New bracket syntax const [, path, selectorString] = bracketMatch; + + // Check for empty brackets + if (selectorString === "" || selectorString === undefined) { + throw new Error("Empty bracket syntax"); + } + const includeExampleTag: IncludeExampleTag = { path }; // Parse the selector string using new bracket syntax @@ -22,6 +33,11 @@ export function parseIncludeExampleTag( return includeExampleTag; } + // If tag contains brackets but doesn't match valid bracket syntax, it's malformed + if (tag.includes("[") || tag.includes("]")) { + throw new Error("Malformed bracket syntax"); + } + // Check for old colon syntax: path/to/file:selector // Only treat as selector if it looks like line numbers/ranges const colonIndex = tag.lastIndexOf(":"); @@ -45,7 +61,7 @@ export function parseIncludeExampleTag( const path: string | undefined = tag || filePath; if (!path) { - throw new Error("Path not found !"); + return { path: "" }; } return { path }; diff --git a/plugin/src/parseLineSelector.test.ts b/plugin/src/parseLineSelector.test.ts index e506e56..8490aee 100644 --- a/plugin/src/parseLineSelector.test.ts +++ b/plugin/src/parseLineSelector.test.ts @@ -2,51 +2,51 @@ import { expect, test } from "vitest"; import { parseLineSelector } from "./parseLineSelector.js"; import { resolveLineSelections } from "./resolveLineSelections.js"; -// ============= BREAKING CHANGE TESTS (Old Dash Syntax) ============= +// ============= PARSING TESTS ============= -test("Should throw error for old dash syntax single range", () => { - expect(() => parseLineSelector("2-4")).toThrowError( - /BREAKING CHANGE: The dash syntax '2-4' inside brackets is no longer supported in v3\.0\.0\+/, - ); -}); - -test("Should throw error for old dash syntax multiple selectors", () => { - expect(() => parseLineSelector("2-4,15")).toThrowError( - /BREAKING CHANGE: The dash syntax '2-4,15' inside brackets is no longer supported in v3\.0\.0\+/, - ); +// Basic single line tests +test("Should parse single line", () => { + const result = parseLineSelector("5"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "single", + isExclusion: false, + line: 5, + }); }); -test("Should throw error for old dash syntax with exclusions", () => { - expect(() => parseLineSelector("2-4,!6-8")).toThrowError( - /BREAKING CHANGE: The dash syntax '2-4,!6-8' inside brackets is no longer supported in v3\.0\.0\+/, - ); +test("Should parse negative single line", () => { + const result = parseLineSelector("-3"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "single", + isExclusion: false, + line: -3, + }); + expect(result.hasNegativeIndexing).toBe(true); }); -// ============= NEW BRACKET SYNTAX TESTS ============= - -// Single line tests -test("Should parse single positive line", () => { - const result = parseLineSelector("10"); +test("Should parse single line exclusion", () => { + const result = parseLineSelector("!5"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ - type: "inclusion", - single: 10, - isNegative: false, + type: "single", + isExclusion: true, + line: 5, }); - expect(result.hasNegativeIndexing).toBe(false); - expect(result.hasExclusions).toBe(false); + expect(result.hasExclusions).toBe(true); }); -test("Should parse single negative line", () => { - const result = parseLineSelector("-5"); +test("Should parse negative single line exclusion", () => { + const result = parseLineSelector("!-3"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ - type: "inclusion", - single: 5, - isNegative: true, + type: "single", + isExclusion: true, + line: -3, }); expect(result.hasNegativeIndexing).toBe(true); - expect(result.hasExclusions).toBe(false); + expect(result.hasExclusions).toBe(true); }); // Range tests @@ -54,10 +54,10 @@ test("Should parse open-ended range from start", () => { const result = parseLineSelector("5:"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 5, end: undefined, - isNegative: false, }); }); @@ -65,10 +65,10 @@ test("Should parse open-ended range to end", () => { const result = parseLineSelector(":10"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: undefined, end: 10, - isNegative: false, }); }); @@ -76,10 +76,10 @@ test("Should parse closed range", () => { const result = parseLineSelector("2:8"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 2, end: 8, - isNegative: false, }); }); @@ -87,10 +87,10 @@ test("Should parse negative range", () => { const result = parseLineSelector("-10:-5"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ - type: "inclusion", - start: 10, - end: 5, - isNegative: true, + type: "range", + isExclusion: false, + start: -10, + end: -5, }); expect(result.hasNegativeIndexing).toBe(true); }); @@ -99,10 +99,47 @@ test("Should parse negative open-ended range", () => { const result = parseLineSelector("-5:"); expect(result.selections).toHaveLength(1); expect(result.selections[0]).toEqual({ - type: "inclusion", - start: 5, + type: "range", + isExclusion: false, + start: -5, end: undefined, - isNegative: true, + }); + expect(result.hasNegativeIndexing).toBe(true); +}); + +test("Should parse open-ended range to negative end", () => { + const result = parseLineSelector(":-3"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: undefined, + end: -3, + }); + expect(result.hasNegativeIndexing).toBe(true); +}); + +// NEW: Mixed positive/negative range tests +test("Should parse mixed positive start to negative end", () => { + const result = parseLineSelector("2:-5"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: 2, + end: -5, + }); + expect(result.hasNegativeIndexing).toBe(true); +}); + +test("Should parse mixed negative start to positive end", () => { + const result = parseLineSelector("-8:5"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: -8, + end: 5, }); expect(result.hasNegativeIndexing).toBe(true); }); @@ -112,98 +149,80 @@ test("Should parse multiple selections", () => { const result = parseLineSelector("2:5,10,15:20"); expect(result.selections).toHaveLength(3); expect(result.selections[0]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 2, end: 5, - isNegative: false, }); expect(result.selections[1]).toEqual({ - type: "inclusion", - single: 10, - isNegative: false, + type: "single", + isExclusion: false, + line: 10, }); expect(result.selections[2]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 15, end: 20, - isNegative: false, }); }); -// Exclusion tests -test("Should parse exclusions", () => { - const result = parseLineSelector("1:10,!5:7"); +// Range exclusions +test("Should parse range exclusions", () => { + const result = parseLineSelector("1:20,!8:12"); expect(result.selections).toHaveLength(2); expect(result.selections[0]).toEqual({ - type: "inclusion", + type: "range", + isExclusion: false, start: 1, - end: 10, - isNegative: false, + end: 20, }); expect(result.selections[1]).toEqual({ - type: "exclusion", - start: 5, - end: 7, - isNegative: false, + type: "range", + isExclusion: true, + start: 8, + end: 12, }); expect(result.hasExclusions).toBe(true); }); -test("Should parse single line exclusion", () => { - const result = parseLineSelector(":10,!5"); +test("Should parse negative exclusions", () => { + const result = parseLineSelector("1:20,!-5:-2"); expect(result.selections).toHaveLength(2); expect(result.selections[1]).toEqual({ - type: "exclusion", - single: 5, - isNegative: false, + type: "range", + isExclusion: true, + start: -5, + end: -2, }); + expect(result.hasNegativeIndexing).toBe(true); expect(result.hasExclusions).toBe(true); }); -test("Should parse negative exclusions", () => { - const result = parseLineSelector("1:20,!-5:-2"); +// NEW: Mixed exclusion tests +test("Should parse mixed positive/negative exclusions", () => { + const result = parseLineSelector("1:20,!2:-3"); expect(result.selections).toHaveLength(2); expect(result.selections[1]).toEqual({ - type: "exclusion", - start: 5, - end: 2, - isNegative: true, + type: "range", + isExclusion: true, + start: 2, + end: -3, }); expect(result.hasNegativeIndexing).toBe(true); expect(result.hasExclusions).toBe(true); }); -// Empty and special cases -test("Should handle empty selector", () => { - const result = parseLineSelector(""); - expect(result.selections).toHaveLength(0); - expect(result.hasNegativeIndexing).toBe(false); - expect(result.hasExclusions).toBe(false); -}); - -test("Should handle colon-only selector", () => { - const result = parseLineSelector(":"); - expect(result.selections).toHaveLength(0); - expect(result.hasNegativeIndexing).toBe(false); - expect(result.hasExclusions).toBe(false); -}); - -// Error handling +// Error cases test("Should throw error on invalid line number", () => { - expect(() => parseLineSelector("bad")).toThrowError( - "Invalid line number: bad", - ); -}); - -test("Should throw error on invalid range start", () => { - expect(() => parseLineSelector("bad:10")).toThrowError( - "Invalid range start: bad", + expect(() => parseLineSelector("abc")).toThrowError( + "Invalid line number: abc", ); }); -test("Should throw error on invalid range end", () => { - expect(() => parseLineSelector("5:bad")).toThrowError( - "Invalid range end: bad", +test("Should throw error on zero line number", () => { + expect(() => parseLineSelector("0")).toThrowError( + "Line number must be positive or negative, not zero", ); }); @@ -219,12 +238,54 @@ test("Should throw error on zero range end", () => { ); }); -test("Should throw error on invalid positive range", () => { +test("Should throw error on invalid range start", () => { + expect(() => parseLineSelector("abc:10")).toThrowError( + "Invalid range start: abc", + ); +}); + +test("Should throw error on invalid range end", () => { + expect(() => parseLineSelector("5:xyz")).toThrowError( + "Invalid range end: xyz", + ); +}); + +test("Should throw error on positive range with start > end", () => { expect(() => parseLineSelector("10:5")).toThrowError( "Range start (10) must be less than or equal to range end (5)", ); }); +// Old dash syntax errors +test("Should throw error on old dash syntax", () => { + expect(() => parseLineSelector("2-4")).toThrowError( + "BREAKING CHANGE: The dash syntax '2-4' inside brackets is no longer supported", + ); +}); + +test("Should throw error on old dash syntax with multiple selections", () => { + expect(() => parseLineSelector("2-4,10")).toThrowError( + "BREAKING CHANGE: The dash syntax '2-4,10' inside brackets is no longer supported", + ); +}); + +test("Should allow negative numbers (not old dash syntax)", () => { + expect(() => parseLineSelector("-5")).not.toThrow(); +}); + +// Empty/whitespace +test("Should handle empty selector", () => { + const result = parseLineSelector(""); + expect(result.selections).toHaveLength(0); + expect(result.hasNegativeIndexing).toBe(false); + expect(result.hasExclusions).toBe(false); +}); + +test("Should handle colon-only selector", () => { + const result = parseLineSelector(":"); + expect(result.selections).toHaveLength(0); +}); + // ============= RESOLUTION TESTS ============= test("Should resolve basic range", () => { @@ -233,63 +294,73 @@ test("Should resolve basic range", () => { expect(resolved).toEqual([2, 3, 4, 5]); }); -test("Should resolve negative indexing", () => { - const parsed = parseLineSelector("-3:"); +test("Should resolve single line", () => { + const parsed = parseLineSelector("7"); const resolved = resolveLineSelections(parsed, 10); - expect(resolved).toEqual([8, 9, 10]); + expect(resolved).toEqual([7]); }); -test("Should resolve exclusions", () => { - const parsed = parseLineSelector("1:10,!5:7"); +test("Should resolve negative single line", () => { + const parsed = parseLineSelector("-2"); const resolved = resolveLineSelections(parsed, 10); - expect(resolved).toEqual([1, 2, 3, 4, 8, 9, 10]); + expect(resolved).toEqual([9]); // 10 - 2 + 1 = 9 }); -test("Should resolve complex mixed syntax", () => { - const parsed = parseLineSelector("2:8,15,20:25,!5:6,!22"); - const resolved = resolveLineSelections(parsed, 30); - expect(resolved).toEqual([2, 3, 4, 7, 8, 15, 20, 21, 23, 24, 25]); +test("Should resolve negative ranges correctly", () => { + const parsed = parseLineSelector("-5:-2"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([6, 7, 8, 9]); // Lines 6-9 (last 4 lines excluding last line) }); -test("Should handle out of bounds negative indexing", () => { - const parsed = parseLineSelector("-15"); - expect(() => resolveLineSelections(parsed, 10)).toThrowError( - "Line -15 is out of range (file has 10 lines)", - ); +// NEW: Mixed positive/negative resolution tests +test("Should resolve mixed positive to negative range", () => { + const parsed = parseLineSelector("2:-5"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([2, 3, 4, 5, 6]); // Line 2 to line 6 (10 - 5 + 1 = 6) }); -test("Should handle empty file", () => { - const parsed = parseLineSelector("1:5"); - const resolved = resolveLineSelections(parsed, 0); - expect(resolved).toEqual([]); +test("Should resolve mixed negative to positive range", () => { + const parsed = parseLineSelector("-8:5"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([3, 4, 5]); // Line 3 (10 - 8 + 1 = 3) to line 5 }); -test("Should handle single line file", () => { - const parsed = parseLineSelector("1:5"); - const resolved = resolveLineSelections(parsed, 1); - expect(resolved).toEqual([1]); +test("Should resolve complex mixed selections", () => { + const parsed = parseLineSelector("1:3,5,-2:,!7"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([1, 2, 3, 5, 9, 10]); // 1-3, 5, 9-10 (last 2), excluding 7 }); -test("Should resolve only exclusions (include all then exclude)", () => { - const parsed = parseLineSelector("!3:5"); - const resolved = resolveLineSelections(parsed, 7); - expect(resolved).toEqual([1, 2, 6, 7]); +test("Should resolve mixed positive and negative", () => { + const parsed = parseLineSelector("2:5,-3:"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([2, 3, 4, 5, 8, 9, 10]); }); -test("Should resolve negative ranges correctly", () => { - const parsed = parseLineSelector("-5:-2"); +// NEW: Edge cases for mixed ranges +test("Should handle mixed range that results in valid selection", () => { + const parsed = parseLineSelector("8:-2"); const resolved = resolveLineSelections(parsed, 10); - expect(resolved).toEqual([6, 7, 8, 9]); + expect(resolved).toEqual([8, 9]); // Line 8 to line 9 (10 - 2 + 1 = 9) }); -test("Should resolve mixed positive and negative", () => { - const parsed = parseLineSelector("2:5,-3:"); +test("Should handle mixed range with exclusions", () => { + const parsed = parseLineSelector("1:-1,!3:-3"); const resolved = resolveLineSelections(parsed, 10); - expect(resolved).toEqual([2, 3, 4, 5, 8, 9, 10]); + expect(resolved).toEqual([1, 2, 9, 10]); // Lines 1-10, excluding lines 3-8 (since -3 = line 8) }); -test("Should handle whitespace in selectors", () => { - const parsed = parseLineSelector(" 2:5 , 10 , !7 "); - const resolved = resolveLineSelections(parsed, 15); - expect(resolved).toEqual([2, 3, 4, 5, 10]); +// Error cases for resolution +test("Should throw error on out of range single line", () => { + const parsed = parseLineSelector("15"); + expect(() => resolveLineSelections(parsed, 10)).toThrowError( + "Line 15 is out of range (file has 10 lines)", + ); +}); + +test("Should throw error on out of range negative single line", () => { + const parsed = parseLineSelector("-15"); + expect(() => resolveLineSelections(parsed, 10)).toThrowError( + "Line -15 is out of range (file has 10 lines)", + ); }); diff --git a/plugin/src/parseLineSelector.ts b/plugin/src/parseLineSelector.ts index 6ab88df..b6edbe4 100644 --- a/plugin/src/parseLineSelector.ts +++ b/plugin/src/parseLineSelector.ts @@ -1,180 +1,147 @@ import type { LineSelection } from "./LineSelection.js"; import type { ParsedLineSelector } from "./ParsedLineSelector.js"; +import utils from "./utils.js"; export function parseLineSelector( - lineSelectorString: string + lineSelectorString: string, ): ParsedLineSelector { - // Handle empty or whitespace-only selectors - const trimmed = lineSelectorString.trim(); - if (!trimmed || trimmed === ":") { - return { selections: [], hasNegativeIndexing: false, hasExclusions: false }; - } - - // v3.0.0: Only support new bracket syntax with colons - // Old dash syntax is no longer supported - if (containsOldDashSyntax(trimmed)) { - throw new Error( - `BREAKING CHANGE: The dash syntax '${trimmed}' inside brackets is no longer supported in v3.0.0+. Please use colon syntax instead. Examples: '2-4' → '2:4', '5-7,11' → '5:7,11'. See documentation for the new bracket syntax.` - ); - } - - // Parse new bracket syntax - return parseNewSlicingSyntax(trimmed); + // Handle empty or whitespace-only selectors + const trimmed = lineSelectorString.trim(); + if (!trimmed || trimmed === ":") { + return { selections: [], hasNegativeIndexing: false, hasExclusions: false }; + } + + // v3.0.0: Only support new bracket syntax with colons + // Old dash syntax is no longer supported + if (hasOldDashSyntax(trimmed)) { + throw new Error( + `BREAKING CHANGE: The dash syntax '${trimmed}' inside brackets is no longer supported in v3.0.0+. Please use colon syntax instead. Examples: '2-4' → '2:4', '5-7,11' → '5:7,11'. See documentation for the new bracket syntax.`, + ); + } + + return parseSelections(trimmed); } -function containsOldDashSyntax(selector: string): boolean { - return selector - .split(",") - .map((p) => p.trim()) - .some(hasOldDashPattern); +function hasOldDashSyntax(selector: string): boolean { + return selector + .split(",") + .map((part) => part.trim()) + .some((part) => { + // Skip exclusions for this check + const cleanPart = part.startsWith("!") ? part.slice(1) : part; + + // If it contains a colon, it's new syntax + if (cleanPart.includes(":")) return false; + + // Check for dash patterns that are NOT single negative numbers + if (cleanPart.includes("-")) { + // Single negative number is valid: -5 + if (/^-\d+$/.test(cleanPart)) return false; + // Dash range like "2-4" is old syntax + return true; + } + + return false; + }); } -function hasOldDashPattern(part: string): boolean { - // Skip exclusions for this check - const cleanPart = part.startsWith("!") ? part.slice(1) : part; - - // If it contains a colon, it's new syntax (even with negative numbers) - if (cleanPart.includes(":")) { - return false; - } - - // Check for dash patterns that are NOT single negative numbers - if (cleanPart.includes("-")) { - // Single negative number is new syntax - if (isNegativeNumber(cleanPart)) { - return false; - } - // Dash range like "2-4" is old syntax - return true; - } - - return false; -} - -function isNegativeNumber(value: string): boolean { - return /^-\d+$/.test(value); -} - -function parseNewSlicingSyntax(selector: string): ParsedLineSelector { - const selections: LineSelection[] = []; - let hasNegativeIndexing = false; - let hasExclusions = false; - - // Split by comma to handle multiple selections - const parts = selector.split(",").map((part) => part.trim()); - - for (const part of parts) { - if (!part) continue; - - // Check for exclusion syntax - const isExclusion = part.startsWith("!"); - const cleanPart = isExclusion ? part.slice(1) : part; - - if (isExclusion) { - hasExclusions = true; - } +function parseSelections(selector: string): ParsedLineSelector { + const selections: LineSelection[] = []; + let hasNegativeIndexing = false; + let hasExclusions = false; - // Parse the individual selection - const selection = parseIndividualSelection(cleanPart); - selection.type = isExclusion ? "exclusion" : "inclusion"; + // Split by comma and parse each part + const parts = selector.split(",").map((part) => part.trim()); - if (selection.isNegative) { - hasNegativeIndexing = true; - } + for (const part of parts) { + if (!part) continue; - selections.push(selection); - } + // Handle exclusion syntax + const isExclusion = part.startsWith("!"); + const cleanPart = isExclusion ? part.slice(1) : part; - return { selections, hasNegativeIndexing, hasExclusions }; -} - -function parseIndividualSelection(part: string): LineSelection { - // Handle single line (positive or negative) - if (!part.includes(":")) { - return parseSingleLine(part); - } - - // Handle range syntax (start:end, start:, :end, :) - return parseRange(part); -} + if (isExclusion) { + hasExclusions = true; + } -function parseSingleLine(part: string): LineSelection { - const num = Number.parseInt(part); - if (!Number.isFinite(num)) { - throw new Error(`Invalid line number: ${part}`); - } - return { - type: "inclusion", - single: Math.abs(num), - isNegative: num < 0, - }; -} - -function parseRange(part: string): LineSelection { - const colonIndex = part.indexOf(":"); - const startStr = part.slice(0, colonIndex); - const endStr = part.slice(colonIndex + 1); + // Parse the selection + const selection = parseSelection(cleanPart, isExclusion); - const { start, end, isNegative } = parseRangeNumbers(startStr, endStr); + // Check if this selection uses negative indexing + if (selection.type === "single" && selection.line < 0) { + hasNegativeIndexing = true; + } else if ( + selection.type === "range" && + ((selection.start !== undefined && selection.start < 0) || + (selection.end !== undefined && selection.end < 0)) + ) { + hasNegativeIndexing = true; + } - validateRangeLogic(start, end, isNegative); - - return { - type: "inclusion", - start, - end, - isNegative, - }; -} + selections.push(selection); + } -function parseRangeNumbers(startStr: string, endStr: string) { - let start: number | undefined; - let end: number | undefined; - let isNegative = false; - - // Parse start - if (startStr) { - start = parseRangeNumber(startStr, "start"); - if (start < 0) { - isNegative = true; - start = Math.abs(start); - } - } - - // Parse end - if (endStr) { - end = parseRangeNumber(endStr, "end"); - if (end < 0) { - isNegative = true; - end = Math.abs(end); - } - } - - return { start, end, isNegative }; + return { selections, hasNegativeIndexing, hasExclusions }; } -function parseRangeNumber(value: string, type: "start" | "end"): number { - const num = Number.parseInt(value); - if (!Number.isFinite(num)) { - throw new Error(`Invalid range ${type}: ${value}`); - } - if (num === 0) { - throw new Error(`Range ${type} must be positive or negative, not zero`); - } - return num; +function parseLineNumber(value: string, context: string): number { + const num = Number.parseInt(value); + if (!Number.isFinite(num)) { + throw new Error(`Invalid ${context}: ${value}`); + } + if (num === 0) { + throw new Error( + `${utils.capitalize(context)} must be positive or negative, not zero`, + ); + } + return num; } -function validateRangeLogic( - start: number | undefined, - end: number | undefined, - isNegative: boolean -): void { - // Validate range logic for positive numbers - if (start !== undefined && end !== undefined && !isNegative) { - if (start > end) { - throw new Error( - `Range start (${start}) must be less than or equal to range end (${end})` - ); - } - } +function parseSelection(part: string, isExclusion: boolean): LineSelection { + // Handle single line (positive or negative) + if (!part.includes(":")) { + const num = parseLineNumber(part, "line number"); + return { + type: "single", + isExclusion, + line: num, + }; + } + + // Handle range syntax (start:end, start:, :end, :) + const colonIndex = part.indexOf(":"); + const startStr = part.slice(0, colonIndex); + const endStr = part.slice(colonIndex + 1); + + let start: number | undefined; + let end: number | undefined; + + // Parse start + if (startStr) { + start = parseLineNumber(startStr, "range start"); + } + + // Parse end + if (endStr) { + end = parseLineNumber(endStr, "range end"); + } + + // Validate forward range logic for same-sign ranges only + if ( + start !== undefined && + end !== undefined && + ((start > 0 && end > 0) || (start < 0 && end < 0)) && + start > end + ) { + throw new Error( + `Range start (${start}) must be less than or equal to range end (${end})`, + ); + } + + return { + type: "range", + isExclusion, + start, + end, + }; } diff --git a/plugin/src/resolveLineSelections.ts b/plugin/src/resolveLineSelections.ts index e788b79..47d95a2 100644 --- a/plugin/src/resolveLineSelections.ts +++ b/plugin/src/resolveLineSelections.ts @@ -2,164 +2,113 @@ import type { LineSelection } from "./LineSelection.js"; import type { ParsedLineSelector } from "./ParsedLineSelector.js"; export function resolveLineSelections( - parsed: ParsedLineSelector, - totalLines: number + parsed: ParsedLineSelector, + totalLines: number, ): number[] { - if (totalLines <= 0) { - return []; - } - - const includedLines = new Set(); - const excludedLines = new Set(); - - // Process all selections - for (const selection of parsed.selections) { - const lines = resolveSelection(selection, totalLines); - addLinesToSet( - lines, - selection.type === "inclusion" ? includedLines : excludedLines - ); - } - - // If no inclusions specified, include all lines - if (shouldIncludeAllLines(parsed)) { - addAllLinesToSet(includedLines, totalLines); - } - - // Apply exclusions - applyExclusions(includedLines, excludedLines); - - // Return sorted array - return Array.from(includedLines).sort((a, b) => a - b); -} - -function addLinesToSet(lines: number[], targetSet: Set): void { - for (const line of lines) { - targetSet.add(line); - } -} - -function shouldIncludeAllLines(parsed: ParsedLineSelector): boolean { - return ( - parsed.selections.length === 0 || - parsed.selections.every((s) => s.type === "exclusion") - ); -} - -function addAllLinesToSet( - includedLines: Set, - totalLines: number -): void { - for (let i = 1; i <= totalLines; i++) { - includedLines.add(i); - } -} - -function applyExclusions( - includedLines: Set, - excludedLines: Set -): void { - for (const excludedLine of excludedLines) { - includedLines.delete(excludedLine); - } + if (totalLines <= 0) { + return []; + } + + const includedLines = new Set(); + const excludedLines = new Set(); + + // Process all selections + for (const selection of parsed.selections) { + const lines = resolveSelection(selection, totalLines); + const targetSet = selection.isExclusion ? excludedLines : includedLines; + for (const line of lines) { + targetSet.add(line); + } + } + + // If no inclusions specified, include all lines + if (shouldIncludeAllLines(parsed)) { + for (let i = 1; i <= totalLines; i++) { + includedLines.add(i); + } + } + + // Apply exclusions + for (const line of excludedLines) { + includedLines.delete(line); + } + + // Return sorted array + return Array.from(includedLines).sort((a, b) => a - b); } function resolveSelection( - selection: LineSelection, - totalLines: number + selection: LineSelection, + totalLines: number, ): number[] { - // Handle single line - if (selection.single !== undefined) { - return resolveSingleLine(selection, totalLines); - } - - // Handle range - return resolveRange(selection, totalLines); + switch (selection.type) { + case "single": + return resolveLine(selection, totalLines); + case "range": + return resolveRange(selection, totalLines); + } } -function resolveSingleLine( - selection: LineSelection, - totalLines: number +function resolveLine( + selection: LineSelection & { type: "single" }, + totalLines: number, ): number[] { - const singleValue = selection.single; - if (singleValue === undefined) { - throw new Error("Single line value is undefined"); - } - - const line = selection.isNegative - ? totalLines - singleValue + 1 - : singleValue; - - if (line < 1 || line > totalLines) { - throw new Error( - `Line ${ - selection.isNegative ? -singleValue : singleValue - } is out of range (file has ${totalLines} lines)` - ); - } - - return [line]; + const line = + selection.line < 0 + ? totalLines + selection.line + 1 // Convert negative index to positive index + : selection.line; + + if (line < 1 || line > totalLines) { + throw new Error( + `Line ${selection.line} is out of range (file has ${totalLines} lines)`, + ); + } + + return [line]; } -function resolveRange(selection: LineSelection, totalLines: number): number[] { - let start = selection.start; - let end = selection.end; - - // Resolve negative indexing - if (selection.isNegative) { - ({ start, end } = resolveNegativeRange(start, end, totalLines)); - } - - // Default to full range if not specified - if (start === undefined) start = 1; - if (end === undefined) end = totalLines; - - // Validate and clamp bounds - validateRangeBounds(start, totalLines); - start = Math.max(1, Math.min(start, totalLines)); - end = Math.max(1, Math.min(end, totalLines)); - - if (start > end) { - return []; - } - - // Generate range - return generateRange(start, end); -} - -function resolveNegativeRange( - start: number | undefined, - end: number | undefined, - totalLines: number -): { start: number | undefined; end: number | undefined } { - let newStart = start; - let newEnd = end; - - if (newStart !== undefined) { - newStart = totalLines - newStart + 1; - } - if (newEnd !== undefined) { - newEnd = totalLines - newEnd + 1; - } - // For negative ranges, swap start and end if needed - if (newStart !== undefined && newEnd !== undefined && newStart > newEnd) { - [newStart, newEnd] = [newEnd, newStart]; - } - return { start: newStart, end: newEnd }; -} - -function validateRangeBounds(start: number, totalLines: number): void { - if (start > totalLines) { - throw new Error( - `Line ${start} is out of range (file has ${totalLines} lines)` - ); - } +function resolveRange( + selection: LineSelection & { type: "range" }, + totalLines: number, +): number[] { + let start = selection.start; + let end = selection.end; + + // Convert negative index to positive index + if (start !== undefined && start < 0) { + start = totalLines + start + 1; + } + if (end !== undefined && end < 0) { + end = totalLines + end + 1; + } + + // Default to full range if not specified + if (start === undefined) start = 1; + if (end === undefined) end = totalLines; + + // Validate bounds - be more lenient for empty files + if (totalLines === 0) { + return []; + } + + // Clamp to valid range + start = Math.max(1, Math.min(start, totalLines)); + end = Math.max(1, Math.min(end, totalLines)); + + if (start > end) { + return []; + } + + const result: number[] = []; + for (let i = start; i <= end; i++) { + result.push(i); + } + return result; } -function generateRange(start: number, end: number): number[] { - const result: number[] = []; - for (let i = start; i <= end; i++) { - result.push(i); - } - return result; +function shouldIncludeAllLines(parsed: ParsedLineSelector): boolean { + return ( + parsed.selections.length === 0 || + parsed.selections.every((s) => s.isExclusion) + ); } diff --git a/plugin/src/utils.ts b/plugin/src/utils.ts new file mode 100644 index 0000000..055b6c6 --- /dev/null +++ b/plugin/src/utils.ts @@ -0,0 +1,15 @@ +/** + * Capitalizes the first letter of a string + * @param str - The string to capitalize + * @returns The string with the first letter capitalized + */ +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +// Export utils object as default to satisfy exportcase naming convention +const utils = { + capitalize, +}; + +export default utils; From 48b2eefad00e45149a23a9bbccf2a756e45b3687 Mon Sep 17 00:00:00 2001 From: spenpal Date: Sun, 6 Jul 2025 19:25:02 -0500 Subject: [PATCH 11/12] docs: enhance CONTRIBUTING.md and update package.json scripts --- CONTRIBUTING.md | 34 +++++++++++++++++++ plugin/package.json | 79 +++++++++++++++++++++++---------------------- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8067875..1a7956f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,40 @@ npx playwright install npm run build ``` +## Available Scripts + +The project provides several npm scripts for development: + +```bash +# Format code with Biome +npm run format + +# Run Docker build process +npm run docker + +# Format code and run Docker build (combines both above) +npm run build:docker + +# Run full build pipeline (TypeScript, tests, linting, mutation testing) +npm run build +``` + +## Code Formatting + +To format the entire codebase, you can use either: + +```bash +npm run format +``` + +Or directly: + +```bash +npx biome format --write . +``` + +This will automatically format all files according to the project's style guidelines. + ## Code Coverage and Testing We use Stryker for code coverage verification. If you add new code, you'll likely need to add corresponding tests with Vitest. Pull requests with insufficient code coverage will not pass the build process. diff --git a/plugin/package.json b/plugin/package.json index 7cf9321..6fdcc6e 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,40 +1,43 @@ { - "name": "typedoc-plugin-include-example", - "version": "3.0.0", - "license": "MIT", - "type": "module", - "description": "Typedoc plugin to include files as example", - "main": "dist/index.js", - "repository": { - "type": "git", - "url": "git+https://github.com/ferdodo/typedoc-plugin-include-example.git" - }, - "keywords": [ - "typedoc", - "plugin", - "example", - "typedocplugin", - "typedoc-plugin" - ], - "author": "Thomas Riffard (https://github.com/ferdodo)", - "contributors": [ - "Jeremy Cherer (https://github.com/JavaLavaMT)", - "Rocky Warren (https://github.com/therockstorm)", - "Gerrit Birkeland (https://github.com/Gerrit0)" - ], - "scripts": { - "build": "tsc --noEmit && vitest run && biome ci && exportcase check src && stryker run && tsc" - }, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@stryker-mutator/core": "^8.6.0", - "@stryker-mutator/vitest-runner": "^8.6.0", - "@types/node": "^22.10.1", - "exportcase": "^0.11.0", - "typescript": "^5.6.3", - "vitest": "^3.1.1" - }, - "peerDependencies": { - "typedoc": "0.26.x || 0.27.x || 0.28.x" - } + "name": "typedoc-plugin-include-example", + "version": "3.0.0", + "license": "MIT", + "type": "module", + "description": "Typedoc plugin to include files as example", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/ferdodo/typedoc-plugin-include-example.git" + }, + "keywords": [ + "typedoc", + "plugin", + "example", + "typedocplugin", + "typedoc-plugin" + ], + "author": "Thomas Riffard (https://github.com/ferdodo)", + "contributors": [ + "Jeremy Cherer (https://github.com/JavaLavaMT)", + "Rocky Warren (https://github.com/therockstorm)", + "Gerrit Birkeland (https://github.com/Gerrit0)" + ], + "scripts": { + "build": "tsc --noEmit && vitest run && biome ci && exportcase check src && stryker run && tsc", + "format": "biome format --write .", + "docker": "docker compose up -d --build", + "build:docker": "npm run format && npm run docker" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@stryker-mutator/core": "^8.6.0", + "@stryker-mutator/vitest-runner": "^8.6.0", + "@types/node": "^22.10.1", + "exportcase": "^0.11.0", + "typescript": "^5.6.3", + "vitest": "^3.1.1" + }, + "peerDependencies": { + "typedoc": "0.26.x || 0.27.x || 0.28.x" + } } From 8438d3cd9069c3cdbbecba6677d3803d57f7fa2b Mon Sep 17 00:00:00 2001 From: spenpal Date: Sun, 6 Jul 2025 22:42:08 -0500 Subject: [PATCH 12/12] chore: format package.json from spaces to tabs --- plugin/package.json | 82 ++++++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/plugin/package.json b/plugin/package.json index 6fdcc6e..f300a5c 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,43 +1,43 @@ { - "name": "typedoc-plugin-include-example", - "version": "3.0.0", - "license": "MIT", - "type": "module", - "description": "Typedoc plugin to include files as example", - "main": "dist/index.js", - "repository": { - "type": "git", - "url": "git+https://github.com/ferdodo/typedoc-plugin-include-example.git" - }, - "keywords": [ - "typedoc", - "plugin", - "example", - "typedocplugin", - "typedoc-plugin" - ], - "author": "Thomas Riffard (https://github.com/ferdodo)", - "contributors": [ - "Jeremy Cherer (https://github.com/JavaLavaMT)", - "Rocky Warren (https://github.com/therockstorm)", - "Gerrit Birkeland (https://github.com/Gerrit0)" - ], - "scripts": { - "build": "tsc --noEmit && vitest run && biome ci && exportcase check src && stryker run && tsc", - "format": "biome format --write .", - "docker": "docker compose up -d --build", - "build:docker": "npm run format && npm run docker" - }, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@stryker-mutator/core": "^8.6.0", - "@stryker-mutator/vitest-runner": "^8.6.0", - "@types/node": "^22.10.1", - "exportcase": "^0.11.0", - "typescript": "^5.6.3", - "vitest": "^3.1.1" - }, - "peerDependencies": { - "typedoc": "0.26.x || 0.27.x || 0.28.x" - } + "name": "typedoc-plugin-include-example", + "version": "3.0.0", + "license": "MIT", + "type": "module", + "description": "Typedoc plugin to include files as example", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/ferdodo/typedoc-plugin-include-example.git" + }, + "keywords": [ + "typedoc", + "plugin", + "example", + "typedocplugin", + "typedoc-plugin" + ], + "author": "Thomas Riffard (https://github.com/ferdodo)", + "contributors": [ + "Jeremy Cherer (https://github.com/JavaLavaMT)", + "Rocky Warren (https://github.com/therockstorm)", + "Gerrit Birkeland (https://github.com/Gerrit0)" + ], + "scripts": { + "build": "tsc --noEmit && vitest run && biome ci && exportcase check src && stryker run && tsc", + "format": "biome format --write .", + "docker": "docker compose up -d --build", + "build:docker": "npm run format && npm run docker" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@stryker-mutator/core": "^8.6.0", + "@stryker-mutator/vitest-runner": "^8.6.0", + "@types/node": "^22.10.1", + "exportcase": "^0.11.0", + "typescript": "^5.6.3", + "vitest": "^3.1.1" + }, + "peerDependencies": { + "typedoc": "0.26.x || 0.27.x || 0.28.x" + } }