diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 441ba90..1a7956f 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,9 +59,43 @@ npx playwright install 3. Build the project: ``` -npm run build +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/README.md b/README.md index ab043e1..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. +Include code examples in your [typedoc](https://typedoc.org/) documentations with powerful bracket syntax for line selection. ## 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,9 +47,22 @@ Then generate your documentation using typedoc using this plugin. $ npx typedoc --plugin typedoc-plugin-include-example ``` +## Line Selection + +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[2:5,10,15:20] // Multiple selections + */ +``` + ## 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/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 5a80afd..9fbfbfa 100644 --- a/demo/src/Author.ts +++ b/demo/src/Author.ts @@ -3,7 +3,7 @@ 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[] = []; 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/Chapter.ts b/demo/src/Chapter.ts index 7cba35d..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 29139c4..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/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/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. diff --git a/plugin/package.json b/plugin/package.json index 5ea5faa..f300a5c 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", @@ -23,7 +23,10 @@ "Gerrit Birkeland (https://github.com/Gerrit0)" ], "scripts": { - "build": "tsc --noEmit && vitest run && biome ci && exportcase check src && stryker run && tsc" + "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", diff --git a/plugin/src/IncludeExampleTag.ts b/plugin/src/IncludeExampleTag.ts index f35d8a4..77bd91a 100644 --- a/plugin/src/IncludeExampleTag.ts +++ b/plugin/src/IncludeExampleTag.ts @@ -1,4 +1,6 @@ -export interface IncludeExampleTag { +import type { ParsedLineSelector } from "./ParsedLineSelector.js"; + +export type 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..497cb83 --- /dev/null +++ b/plugin/src/LineSelection.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..59e04a7 --- /dev/null +++ b/plugin/src/ParsedLineSelector.ts @@ -0,0 +1,7 @@ +import type { LineSelection } from "./LineSelection.js"; + +export type ParsedLineSelector = { + selections: LineSelection[]; + hasNegativeIndexing: boolean; + hasExclusions: boolean; +}; diff --git a/plugin/src/applyLineSelection.test.ts b/plugin/src/applyLineSelection.test.ts index a5ed106..fb6d072 100644 --- a/plugin/src/applyLineSelection.test.ts +++ b/plugin/src/applyLineSelection.test.ts @@ -1,34 +1,389 @@ import { expect, test } from "vitest"; +import type { ParsedLineSelector } from "./ParsedLineSelector.js"; import { applyLineSelection } from "./applyLineSelection.js"; +import { parseLineSelector } from "./parseLineSelector.js"; -test("It should select lines from file", () => { - const file = "hello\nthis\nis\na\nmultiline\nfile"; +// Helper function to create parsed selectors for testing +function createParsedSelector(selector: string): ParsedLineSelector { + return parseLineSelector(selector); +} +// ============= BASIC TESTS ============= + +test("It should apply single line selection", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("3"), + }; + + const result = applyLineSelection(file, includeExampleFile); + 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 = { + path: "test/file", + parsedSelector: createParsedSelector("2:4"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line2\nline3\nline4"); +}); + +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 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 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 +}); + +test("It should apply open-ended range to negative end", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector(":-2"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline3\nline4"); // All except last line +}); + +// ============= 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: "fake/file", - lines: [2, 4, 6], + path: "test/file", + parsedSelector: createParsedSelector("3:-3"), }; const result = applyLineSelection(file, includeExampleFile); - expect(result).toEqual("this\na\nfile"); + expect(result).toEqual("line3\nline4\nline5\nline6\nline7\nline8"); // Line 3 to line 8 (10 - 3 + 1 = 8) }); -test("It return same content when no line selector is supplied", () => { - const file = "hello\nthis\nis\na\nmultiline\nfile"; - const includeExampleFile = { path: "fake/file" }; +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(file); + expect(result).toEqual("line4"); // Line 4 (10 - 7 + 1 = 4) to line 4, so just line 4 }); -test("It throw when line is out of range", () => { - const file = "hello\nthis\nis\na\nmultiline\nfile"; +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("4:-1"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line4\nline5"); // Line 4 to line 5 (5 - 1 + 1 = 5) +}); + +test("It should handle mixed range with reverse order", () => { + const file = "line1\nline2\nline3\nline4\nline5"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-2:3"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual(""); // Line 4 (5 - 2 + 1 = 4) to line 3, but 4 > 3, so empty +}); + +// ============= 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"), + }; + + 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"); +}); + +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 single line 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: "fake/file", - lines: [2, 4, 6, 8], + 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\nline6\nline7"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:7,!-3:-1"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline3\nline4"); // Exclude last 3 lines +}); + +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("1:10,!3:-3"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline9\nline10"); // Exclude lines 3-8 +}); + +test("It should handle only exclusions (include all then exclude)", () => { + 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\nline5"); +}); + +// ============= EDGE CASES ============= + +test("It should handle empty file", () => { + 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 file", () => { + const file = "onlyline"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("onlyline"); +}); + +test("It should handle file with no parsedSelector", () => { + const file = "line1\nline2\nline3"; + const includeExampleFile = { + path: "test/file", + parsedSelector: undefined, + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline2\nline3"); +}); + +test("It should handle negative ranges that resolve to empty", () => { + const file = "line1\nline2"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-10"), // Single negative line beyond bounds }; expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( - "Line number 8 is out of range for file fake/file", + "Line -10 is out of range (file has 2 lines)", ); }); + +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("1:3,-3:,-6:-4,!2,!-2"), + }; + + const result = applyLineSelection(file, includeExampleFile); + expect(result).toEqual("line1\nline3\nline5\nline6\nline7\nline8\nline10"); +}); + +// ============= ERROR CASES ============= + +test("It should throw error on out of range line", () => { + const file = "line1\nline2\nline3"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("5"), + }; + + expect(() => applyLineSelection(file, includeExampleFile)).toThrowError( + "Line 5 is out of range (file has 3 lines)", + ); +}); + +test("It should throw error on out of range negative line", () => { + const file = "line1\nline2\nline3"; + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("-5"), + }; + + 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}`); + 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"); +}); + +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 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"); + + const includeExampleFile = { + path: "test/file", + parsedSelector: createParsedSelector("1:10,500:510,!5,!505"), + }; + + const result = applyLineSelection(file, includeExampleFile); + const resultLines = result.split("\n"); + + // 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("line505"); + expect(resultLines).toContain("line500"); + expect(resultLines).toContain("line510"); +}); diff --git a/plugin/src/applyLineSelection.ts b/plugin/src/applyLineSelection.ts index aed5b70..de4a773 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,21 +7,28 @@ export function applyLineSelection( ): string { const lines = content.split("\n"); - if (includeExampleTag.lines === undefined) { - return content; - } + // Handle parsed selector syntax + if (includeExampleTag.parsedSelector) { + const resolvedLines = resolveLineSelections( + includeExampleTag.parsedSelector, + lines.length, + ); + + return resolvedLines + .map((lineNumber: number) => { + const line = lines[lineNumber - 1]; - 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}`, + ); + } - if (line === undefined) { - throw new Error( - `Line number ${lineNumber} is out of range for file ${includeExampleTag.path}`, - ); - } + return line; + }) + .join("\n"); + } - return line; - }) - .join("\n"); + // No selector - use entire file + return content; } 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/load.ts b/plugin/src/load.ts index bacda2d..6e8be63 100644 --- a/plugin/src/load.ts +++ b/plugin/src/load.ts @@ -1,7 +1,8 @@ +import type { Application as ApplicationType } from "typedoc"; import { Application, Converter } from "typedoc"; import { processComments } from "./processComments.js"; -export function load(application: Application) { +export function load(application: ApplicationType) { application.on(Application.EVENT_BOOTSTRAP_END, () => { application.options.setValue("blockTags", [ ...new Set([ diff --git a/plugin/src/parseIncludeExampleTag.test.ts b/plugin/src/parseIncludeExampleTag.test.ts index 5ae3f61..deba4ab 100644 --- a/plugin/src/parseIncludeExampleTag.test.ts +++ b/plugin/src/parseIncludeExampleTag.test.ts @@ -1,21 +1,224 @@ import { expect, test } from "vitest"; import { parseIncludeExampleTag } from "./parseIncludeExampleTag.js"; -test("it should parse include example tag", () => { +test("it should parse tag without file path", () => { + const result = parseIncludeExampleTag(""); + expect(result.path).toBe(""); + expect(result.parsedSelector).toBeUndefined(); +}); + +test("it should parse tag with file path only", () => { const result = parseIncludeExampleTag("path/to/file"); - expect(result).toEqual({ path: "path/to/file" }); + expect(result.path).toBe("path/to/file"); + expect(result.parsedSelector).toBeUndefined(); +}); + +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 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, + }); +}); + +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(); + expect(result.parsedSelector?.selections).toHaveLength(1); + expect(result.parsedSelector?.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: 2, + end: 8, + }); +}); + +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: "range", + isExclusion: false, + start: 5, + end: undefined, + }); +}); + +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: "range", + isExclusion: false, + start: -5, + end: undefined, + }); +}); + +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]).toEqual({ + type: "range", + isExclusion: true, + start: 5, + end: 7, + }); +}); + +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: "single", + isExclusion: false, + line: 5, + }); +}); + +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: "range", + isExclusion: false, + start: 2, + end: 4, + }); +}); + +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: "range", + isExclusion: false, + start: 5, + end: undefined, + }); +}); + +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: "range", + isExclusion: false, + start: undefined, + end: 10, + }); +}); + +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: "range", + isExclusion: false, + start: 2, + end: 4, + }); + expect(result.parsedSelector?.selections[1]).toEqual({ + type: "single", + isExclusion: false, + line: 10, + }); + expect(result.parsedSelector?.selections[2]).toEqual({ + type: "range", + isExclusion: false, + start: 15, + end: 20, + }); +}); + +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 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 on zero line number", () => { + expect(() => parseIncludeExampleTag("path/to/file[0]")).toThrowError( + "Line number must be positive or negative, not zero", + ); }); -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 on malformed brackets", () => { + expect(() => parseIncludeExampleTag("path/to/file[")).toThrowError( + "Malformed bracket syntax", + ); }); -test("it should throw on empty path", () => { - expect(() => parseIncludeExampleTag("")).toThrowError("Path not found !"); +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 303d2b6..4ebaee0 100644 --- a/plugin/src/parseIncludeExampleTag.ts +++ b/plugin/src/parseIncludeExampleTag.ts @@ -5,23 +5,64 @@ export function parseIncludeExampleTag( tag: string, filePath?: string, ): IncludeExampleTag { - const splittedTag = tag.split(":")[Symbol.iterator](); - const path: string | undefined = splittedTag.next().value || filePath; - - if (!path) { - throw new Error("Path not found !"); + // Handle empty tag + if (!tag && !filePath) { + return { path: "" }; } - const includeExampleTag: IncludeExampleTag = { path }; - const lineNumbersString: string | undefined = splittedTag.next().value; + // 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 + const parsed = parseLineSelector(selectorString); + + // Store the parsed selector for later resolution + includeExampleTag.parsedSelector = parsed; - if (lineNumbersString === undefined) { return includeExampleTag; } - includeExampleTag.lines = lineNumbersString - .split(",") - .flatMap(parseLineSelector); + // 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(":"); + 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 in v3.0.0+. Please migrate to the new bracket syntax: '${potentialPath}[${potentialSelector.replace( + /-/g, + ":", + )}]'. See documentation for the new bracket syntax.`, + ); + } + } + + // No selector syntax - use entire file + const path: string | undefined = tag || filePath; + + if (!path) { + return { path: "" }; + } - return includeExampleTag; + return { path }; } diff --git a/plugin/src/parseLineSelector.test.ts b/plugin/src/parseLineSelector.test.ts index a91d561..8490aee 100644 --- a/plugin/src/parseLineSelector.test.ts +++ b/plugin/src/parseLineSelector.test.ts @@ -1,70 +1,366 @@ import { expect, test } from "vitest"; import { parseLineSelector } from "./parseLineSelector.js"; +import { resolveLineSelections } from "./resolveLineSelections.js"; -test("Should parse a line", () => { - const result = parseLineSelector("15"); - expect(result).toEqual([15]); +// ============= PARSING TESTS ============= + +// 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 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); +}); + +test("Should parse single line exclusion", () => { + const result = parseLineSelector("!5"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "single", + isExclusion: true, + line: 5, + }); + expect(result.hasExclusions).toBe(true); +}); + +test("Should parse negative single line exclusion", () => { + const result = parseLineSelector("!-3"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "single", + isExclusion: true, + line: -3, + }); + expect(result.hasNegativeIndexing).toBe(true); + expect(result.hasExclusions).toBe(true); +}); + +// Range tests +test("Should parse open-ended range from start", () => { + const result = parseLineSelector("5:"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: 5, + end: undefined, + }); +}); + +test("Should parse open-ended range to end", () => { + const result = parseLineSelector(":10"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: undefined, + end: 10, + }); +}); + +test("Should parse closed range", () => { + const result = parseLineSelector("2:8"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: 2, + end: 8, + }); +}); + +test("Should parse negative range", () => { + const result = parseLineSelector("-10:-5"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: -10, + end: -5, + }); + expect(result.hasNegativeIndexing).toBe(true); }); -test("Should parse a when equal to 1", () => { - const result = parseLineSelector("1"); - expect(result).toEqual([1]); +test("Should parse negative open-ended range", () => { + const result = parseLineSelector("-5:"); + expect(result.selections).toHaveLength(1); + expect(result.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: -5, + end: undefined, + }); + expect(result.hasNegativeIndexing).toBe(true); }); -test("Should parse a range of lines", () => { - const result = parseLineSelector("2-4"); - expect(result).toEqual([2, 3, 4]); +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); }); -test("Should parse a range of lines starting with 1", () => { - const result = parseLineSelector("1-4"); - expect(result).toEqual([1, 2, 3, 4]); +// 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); +}); + +// Multiple selections +test("Should parse multiple selections", () => { + const result = parseLineSelector("2:5,10,15:20"); + expect(result.selections).toHaveLength(3); + expect(result.selections[0]).toEqual({ + type: "range", + isExclusion: false, + start: 2, + end: 5, + }); + expect(result.selections[1]).toEqual({ + type: "single", + isExclusion: false, + line: 10, + }); + expect(result.selections[2]).toEqual({ + type: "range", + isExclusion: false, + start: 15, + end: 20, + }); +}); + +// 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: "range", + isExclusion: false, + start: 1, + end: 20, + }); + expect(result.selections[1]).toEqual({ + type: "range", + isExclusion: true, + start: 8, + end: 12, + }); + expect(result.hasExclusions).toBe(true); +}); + +test("Should parse negative exclusions", () => { + const result = parseLineSelector("1:20,!-5:-2"); + expect(result.selections).toHaveLength(2); + expect(result.selections[1]).toEqual({ + type: "range", + isExclusion: true, + start: -5, + end: -2, + }); + expect(result.hasNegativeIndexing).toBe(true); + expect(result.hasExclusions).toBe(true); +}); + +// 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: "range", + isExclusion: true, + start: 2, + end: -3, + }); + expect(result.hasNegativeIndexing).toBe(true); + expect(result.hasExclusions).toBe(true); +}); + +// Error cases +test("Should throw error on invalid line number", () => { + expect(() => parseLineSelector("abc")).toThrowError( + "Invalid line number: abc", + ); +}); + +test("Should throw error on zero line number", () => { + expect(() => parseLineSelector("0")).toThrowError( + "Line number must be positive or negative, not zero", + ); }); -test("Should throw error on missing range start", () => { - expect(() => parseLineSelector("-4")).toThrowError( - "Failed to parse range start !", +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 missing range end", () => { - expect(() => parseLineSelector("2-")).toThrowError( - "Failed to parse range end !", +test("Should throw error on zero range end", () => { + expect(() => parseLineSelector("5:0")).toThrowError( + "Range end must be positive or negative, not zero", ); }); -test("Should throw error on bad range start", () => { - expect(() => parseLineSelector("bad-4")).toThrowError( - "Failed to parse range start !", +test("Should throw error on invalid range start", () => { + expect(() => parseLineSelector("abc:10")).toThrowError( + "Invalid range start: abc", ); }); -test("Should throw error on bad range end", () => { - expect(() => parseLineSelector("2-bad")).toThrowError( - "Failed to parse range end !", +test("Should throw error on invalid range end", () => { + expect(() => parseLineSelector("5:xyz")).toThrowError( + "Invalid range end: xyz", ); }); -test("Should throw error on end being smaller than start", () => { - expect(() => parseLineSelector("4-2")).toThrowError( - "Range start is greater or equal to range end !", +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)", ); }); -test("Should throw error on end being equal to start", () => { - expect(() => parseLineSelector("2-2")).toThrowError( - "Range start is greater or equal to range end !", +// 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 start being smaller than 1", () => { - expect(() => parseLineSelector("0-2")).toThrowError( - "Range start not positive !", +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", () => { + const parsed = parseLineSelector("2:5"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([2, 3, 4, 5]); +}); + +test("Should resolve single line", () => { + const parsed = parseLineSelector("7"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([7]); +}); + +test("Should resolve negative single line", () => { + const parsed = parseLineSelector("-2"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([9]); // 10 - 2 + 1 = 9 +}); + +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) +}); + +// 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 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 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 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]); +}); + +// 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([8, 9]); // Line 8 to line 9 (10 - 2 + 1 = 9) +}); + +test("Should handle mixed range with exclusions", () => { + const parsed = parseLineSelector("1:-1,!3:-3"); + const resolved = resolveLineSelections(parsed, 10); + expect(resolved).toEqual([1, 2, 9, 10]); // Lines 1-10, excluding lines 3-8 (since -3 = line 8) +}); + +// 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 bad single line", () => { - expect(() => parseLineSelector("bad")).toThrowError( - "Failed to parse line number !", +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 8a4b153..b6edbe4 100644 --- a/plugin/src/parseLineSelector.ts +++ b/plugin/src/parseLineSelector.ts @@ -1,41 +1,147 @@ -export function parseLineSelector(lineSelectorString: string): number[] { - if (lineSelectorString.includes("-")) { - const lineRange: string[] = lineSelectorString.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 !"); - } +import type { LineSelection } from "./LineSelection.js"; +import type { ParsedLineSelector } from "./ParsedLineSelector.js"; +import utils from "./utils.js"; - if (!Number.isFinite(end)) { - throw new Error("Failed to parse range end !"); - } +export function parseLineSelector( + lineSelectorString: string, +): ParsedLineSelector { + // Handle empty or whitespace-only selectors + const trimmed = lineSelectorString.trim(); + if (!trimmed || trimmed === ":") { + return { selections: [], hasNegativeIndexing: false, hasExclusions: false }; + } - if (start < 1) { - throw new Error("Range start not positive !"); - } + // 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 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 parseSelections(selector: string): ParsedLineSelector { + const selections: LineSelection[] = []; + let hasNegativeIndexing = false; + let hasExclusions = false; + + // Split by comma and parse each part + const parts = selector.split(",").map((part) => part.trim()); + + for (const part of parts) { + if (!part) continue; - if (start >= end) { - throw new Error("Range start is greater or equal to range end !"); + // Handle exclusion syntax + const isExclusion = part.startsWith("!"); + const cleanPart = isExclusion ? part.slice(1) : part; + + if (isExclusion) { + hasExclusions = true; } - const range: number[] = []; + // Parse the selection + const selection = parseSelection(cleanPart, isExclusion); - for (let i = start; i <= end; i++) { - range.push(i); + // 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; } - return range; + selections.push(selection); + } + + return { selections, hasNegativeIndexing, hasExclusions }; +} + +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 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, + }; } - const line = Number.parseInt(lineSelectorString); + // 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"); + } - if (!Number.isFinite(line)) { - throw new Error("Failed to parse line number !"); + // 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 [line]; + return { + type: "range", + isExclusion, + start, + end, + }; } diff --git a/plugin/src/resolveLineSelections.ts b/plugin/src/resolveLineSelections.ts new file mode 100644 index 0000000..47d95a2 --- /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); + 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, +): number[] { + switch (selection.type) { + case "single": + return resolveLine(selection, totalLines); + case "range": + return resolveRange(selection, totalLines); + } +} + +function resolveLine( + selection: LineSelection & { type: "single" }, + totalLines: number, +): number[] { + 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 & { 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 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;