diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9bb1608..aca08e1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,8 +32,8 @@ jobs: exit 1 fi - - name: Compile - run: npm run compile + - name: Run tests + run: npm test - name: Publish to Marketplace run: npx vsce publish -p "${{ secrets.VSCE_PAT }}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9e6ada2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test diff --git a/.vscodeignore b/.vscodeignore index 213b907..44e6be5 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,5 +1,7 @@ .vscode/** +.github/** src/** +dist/test/** .gitignore tsconfig.json **/*.map diff --git a/package.json b/package.json index 03115c8..50e3680 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", + "test": "npm run compile && node --test \"dist/test/**/*.test.js\"", "package": "vsce package" }, "devDependencies": { diff --git a/src/docsPattern.ts b/src/docsPattern.ts new file mode 100644 index 0000000..9c0e24c --- /dev/null +++ b/src/docsPattern.ts @@ -0,0 +1,105 @@ +import * as path from 'path'; + +export const DEFAULT_DOCS_SETTING = '**/docs'; +export const DEFAULT_FOLDER_NAME = 'docs'; + +export type DocsPattern = { recursive: boolean; tailSegments: string[] }; + +export type PartitionResult = + | { kind: 'grouped'; rootFiles: string[]; groups: Array<{ name: string; files: string[] }> } + | { kind: 'flat'; files: string[] }; + +export const isPrivateName = (name: string): boolean => name.startsWith('.'); + +export const byBasename = (a: string, b: string): number => path.basename(a).localeCompare(path.basename(b)); + +/** + * Parses one configured path entry into a search mode: + * - "**\/docs" -> recursive: matches a folder named "docs" anywhere in the workspace. + * - "docs" or "/docs" -> anchored: only the "docs" folder at the workspace root. + * A tail with multiple segments (e.g. "api/docs" or "**\/packages/docs") is matched as a whole suffix. + */ +export function parseDocsPattern(raw: string): DocsPattern { + const trimmed = (raw || DEFAULT_FOLDER_NAME).trim(); + const recursive = trimmed.startsWith('**/'); + const rest = recursive ? trimmed.slice(3) : trimmed; + const tailSegments = rest.split('/').map((s) => s.trim()).filter(Boolean); + return { recursive, tailSegments: tailSegments.length ? tailSegments : [DEFAULT_FOLDER_NAME] }; +} + +/** Splits the configured, comma-separated setting into individual patterns. */ +export function parseDocsPatterns(raw: string): DocsPattern[] { + const entries = (raw || DEFAULT_DOCS_SETTING) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return (entries.length ? entries : [DEFAULT_DOCS_SETTING]).map(parseDocsPattern); +} + +/** True if `dir`'s trailing path segments match `segments`, in order. */ +export function endsWithSegments(dir: string, segments: string[]): boolean { + let current = dir; + for (let i = segments.length - 1; i >= 0; i--) { + if (path.basename(current) !== segments[i]) { + return false; + } + current = path.dirname(current); + } + return true; +} + +/** Walks up from a file to the nearest ancestor directory whose trailing segments match `tailSegments`. */ +export function findNearestDocsRoot(fileFsPath: string, tailSegments: string[]): string | undefined { + let dir = path.dirname(fileFsPath); + for (;;) { + if (endsWithSegments(dir, tailSegments)) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +/** True if any path segment strictly below `rootPath` starts with a dot. */ +export function hasPrivateSegmentBelow(rootPath: string, fsPath: string): boolean { + return path + .relative(rootPath, fsPath) + .split(path.sep) + .some(isPrivateName); +} + +/** Splits one docs root's files into direct files and parent-folder-name groups (only when more than one group exists). */ +export function partitionPaths(rootPath: string, filePaths: string[]): PartitionResult { + const rootFiles: string[] = []; + const groups = new Map(); + + for (const fsPath of filePaths) { + const parentDir = path.dirname(fsPath); + if (parentDir === rootPath) { + rootFiles.push(fsPath); + } else { + const groupName = path.basename(parentDir); + const bucket = groups.get(groupName); + if (bucket) { + bucket.push(fsPath); + } else { + groups.set(groupName, [fsPath]); + } + } + } + + const sortedRootFiles = [...rootFiles].sort(byBasename); + + if (groups.size > 1) { + const groupEntries = [...groups.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, files]) => ({ name, files: [...files].sort(byBasename) })); + return { kind: 'grouped', rootFiles: sortedRootFiles, groups: groupEntries }; + } + + const flatFiles = [...rootFiles, ...[...groups.values()].flat()].sort(byBasename); + return { kind: 'flat', files: flatFiles }; +} diff --git a/src/extension.ts b/src/extension.ts index 8b7d45f..a65b04e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,11 +1,21 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { + byBasename, + DEFAULT_DOCS_SETTING, + DocsPattern, + findNearestDocsRoot, + hasPrivateSegmentBelow, + isPrivateName, + parseDocsPatterns, + partitionPaths, +} from './docsPattern'; + +const byUriBasename = (a: vscode.Uri, b: vscode.Uri) => byBasename(a.fsPath, b.fsPath); const CONFIG_SECTION = 'docsPanel'; const CONFIG_KEY_DOCS_PATH = 'docsPath'; const CONFIG_KEY_SKIP_PRIVATE = 'skipPrivate'; -const DEFAULT_DOCS_SETTING = '**/docs'; -const DEFAULT_FOLDER_NAME = 'docs'; const EXCLUDE_GLOB = '**/{node_modules,.git}/**'; type DocEntry = @@ -13,11 +23,6 @@ type DocEntry = | { kind: 'group'; rootPath: string; name: string; files: vscode.Uri[] } | { kind: 'file'; uri: vscode.Uri }; -type DocsPattern = { recursive: boolean; tailSegments: string[] }; - -const byBasename = (a: vscode.Uri, b: vscode.Uri) => - path.basename(a.fsPath).localeCompare(path.basename(b.fsPath)); - function getConfiguredDocsPath(): string { return vscode.workspace.getConfiguration(CONFIG_SECTION).get(CONFIG_KEY_DOCS_PATH) ?? DEFAULT_DOCS_SETTING; } @@ -26,58 +31,6 @@ function getConfiguredSkipPrivate(): boolean { return vscode.workspace.getConfiguration(CONFIG_SECTION).get(CONFIG_KEY_SKIP_PRIVATE) ?? true; } -const isPrivateName = (name: string) => name.startsWith('.'); - -/** - * Parses one configured path entry into a search mode: - * - "**\/docs" -> recursive: matches a folder named "docs" anywhere in the workspace. - * - "docs" or "/docs" -> anchored: only the "docs" folder at the workspace root. - * A tail with multiple segments (e.g. "api/docs" or "**\/packages/docs") is matched as a whole suffix. - */ -function parseDocsPattern(raw: string): DocsPattern { - const trimmed = (raw || DEFAULT_FOLDER_NAME).trim(); - const recursive = trimmed.startsWith('**/'); - const rest = recursive ? trimmed.slice(3) : trimmed; - const tailSegments = rest.split('/').map((s) => s.trim()).filter(Boolean); - return { recursive, tailSegments: tailSegments.length ? tailSegments : [DEFAULT_FOLDER_NAME] }; -} - -/** Splits the configured, comma-separated setting into individual patterns. */ -function parseDocsPatterns(raw: string): DocsPattern[] { - const entries = (raw || DEFAULT_DOCS_SETTING) - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - return (entries.length ? entries : [DEFAULT_DOCS_SETTING]).map(parseDocsPattern); -} - -/** True if `dir`'s trailing path segments match `segments`, in order. */ -function endsWithSegments(dir: string, segments: string[]): boolean { - let current = dir; - for (let i = segments.length - 1; i >= 0; i--) { - if (path.basename(current) !== segments[i]) { - return false; - } - current = path.dirname(current); - } - return true; -} - -/** Walks up from a file to the nearest ancestor directory whose trailing segments match `tailSegments`. */ -function findNearestDocsRoot(fileFsPath: string, tailSegments: string[]): string | undefined { - let dir = path.dirname(fileFsPath); - for (;;) { - if (endsWithSegments(dir, tailSegments)) { - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) { - return undefined; - } - dir = parent; - } -} - /** Recursively collects every markdown file under a directory, optionally skipping dot-prefixed entries. */ async function collectMarkdownFiles(dirUri: vscode.Uri, skipPrivate: boolean): Promise { let entries: [string, vscode.FileType][]; @@ -102,14 +55,6 @@ async function collectMarkdownFiles(dirUri: vscode.Uri, skipPrivate: boolean): P return results; } -/** True if any path segment strictly below `rootPath` starts with a dot. */ -function hasPrivateSegmentBelow(rootPath: string, fsPath: string): boolean { - return path - .relative(rootPath, fsPath) - .split(path.sep) - .some(isPrivateName); -} - /** Finds markdown files for a single parsed pattern; see `discoverDocs` for the two modes. */ async function discoverForPattern( folder: vscode.WorkspaceFolder, @@ -182,37 +127,23 @@ async function discoverDocs(patterns: DocsPattern[], skipPrivate: boolean): Prom return merged; } -/** Splits one docs root's files into direct files and parent-folder-name groups (only when more than one group exists). */ +/** Splits one docs root's files into direct files and parent-folder-name groups, using the shared partitioning rule. */ function partition(rootPath: string, files: vscode.Uri[]): DocEntry[] { - const rootFiles: vscode.Uri[] = []; - const groups = new Map(); - - for (const uri of files) { - const parentDir = path.dirname(uri.fsPath); - if (parentDir === rootPath) { - rootFiles.push(uri); - } else { - const groupName = path.basename(parentDir); - const bucket = groups.get(groupName); - if (bucket) { - bucket.push(uri); - } else { - groups.set(groupName, [uri]); - } - } - } - - const rootFileEntries: DocEntry[] = rootFiles.sort(byBasename).map((uri) => ({ kind: 'file', uri })); + const byPath = new Map(files.map((uri) => [uri.fsPath, uri] as const)); + const result = partitionPaths(rootPath, files.map((uri) => uri.fsPath)); - if (groups.size > 1) { - const groupEntries: DocEntry[] = [...groups.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([name, groupFiles]) => ({ kind: 'group', rootPath, name, files: groupFiles })); - return [...rootFileEntries, ...groupEntries]; + if (result.kind === 'flat') { + return result.files.map((fsPath): DocEntry => ({ kind: 'file', uri: byPath.get(fsPath)! })); } - const flatFiles = [...rootFiles, ...[...groups.values()].flat()]; - return flatFiles.sort(byBasename).map((uri) => ({ kind: 'file', uri })); + const rootFileEntries: DocEntry[] = result.rootFiles.map((fsPath) => ({ kind: 'file', uri: byPath.get(fsPath)! })); + const groupEntries: DocEntry[] = result.groups.map((group) => ({ + kind: 'group', + rootPath, + name: group.name, + files: group.files.map((fsPath) => byPath.get(fsPath)!), + })); + return [...rootFileEntries, ...groupEntries]; } class DocsTreeProvider implements vscode.TreeDataProvider { @@ -260,7 +191,7 @@ class DocsTreeProvider implements vscode.TreeDataProvider { return []; } if (element?.kind === 'group') { - return [...element.files].sort(byBasename).map((uri) => ({ kind: 'file', uri })); + return [...element.files].sort(byUriBasename).map((uri) => ({ kind: 'file', uri })); } if (element?.kind === 'root') { return partition(element.path, this.roots.get(element.path) ?? []); diff --git a/src/test/docsPattern.test.ts b/src/test/docsPattern.test.ts new file mode 100644 index 0000000..79be146 --- /dev/null +++ b/src/test/docsPattern.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as path from 'path'; +import { + endsWithSegments, + findNearestDocsRoot, + hasPrivateSegmentBelow, + isPrivateName, + parseDocsPattern, + parseDocsPatterns, + partitionPaths, +} from '../docsPattern'; + +const p = (...segments: string[]) => path.join('/workspace', ...segments); + +describe('parseDocsPattern', () => { + it('treats a bare name as anchored', () => { + assert.deepEqual(parseDocsPattern('docs'), { recursive: false, tailSegments: ['docs'] }); + }); + + it('strips a leading slash but stays anchored', () => { + assert.deepEqual(parseDocsPattern('/docs'), { recursive: false, tailSegments: ['docs'] }); + }); + + it('treats a "**/" prefix as recursive', () => { + assert.deepEqual(parseDocsPattern('**/docs'), { recursive: true, tailSegments: ['docs'] }); + }); + + it('keeps multi-segment tails in recursive mode', () => { + assert.deepEqual(parseDocsPattern('**/api/docs'), { recursive: true, tailSegments: ['api', 'docs'] }); + }); + + it('keeps multi-segment tails in anchored mode', () => { + assert.deepEqual(parseDocsPattern('packages/api/docs'), { + recursive: false, + tailSegments: ['packages', 'api', 'docs'], + }); + }); + + it('trims surrounding whitespace', () => { + assert.deepEqual(parseDocsPattern(' docs '), { recursive: false, tailSegments: ['docs'] }); + }); + + it('falls back to the default folder name when the tail is empty', () => { + assert.deepEqual(parseDocsPattern('**/'), { recursive: true, tailSegments: ['docs'] }); + }); + + it('falls back to the default folder name for an empty string', () => { + assert.deepEqual(parseDocsPattern(''), { recursive: false, tailSegments: ['docs'] }); + }); +}); + +describe('parseDocsPatterns', () => { + it('parses a single entry', () => { + assert.deepEqual(parseDocsPatterns('docs'), [{ recursive: false, tailSegments: ['docs'] }]); + }); + + it('splits on commas and trims each entry', () => { + assert.deepEqual(parseDocsPatterns('docs, **/guides , legacy/docs'), [ + { recursive: false, tailSegments: ['docs'] }, + { recursive: true, tailSegments: ['guides'] }, + { recursive: false, tailSegments: ['legacy', 'docs'] }, + ]); + }); + + it('falls back to the recursive default when empty or blank', () => { + assert.deepEqual(parseDocsPatterns(''), [{ recursive: true, tailSegments: ['docs'] }]); + assert.deepEqual(parseDocsPatterns(' , , '), [{ recursive: true, tailSegments: ['docs'] }]); + }); +}); + +describe('endsWithSegments', () => { + it('matches a single trailing segment', () => { + assert.equal(endsWithSegments(p('a', 'b', 'docs'), ['docs']), true); + }); + + it('matches multiple trailing segments in order', () => { + assert.equal(endsWithSegments(p('a', 'b', 'docs'), ['b', 'docs']), true); + }); + + it('rejects a mismatched segment', () => { + assert.equal(endsWithSegments(p('a', 'b', 'docs'), ['x', 'docs']), false); + }); + + it('rejects a tail longer than the path', () => { + assert.equal(endsWithSegments(p('docs'), ['a', 'b', 'docs']), false); + }); +}); + +describe('findNearestDocsRoot', () => { + it('finds a root-level file directly under the docs folder', () => { + assert.equal(findNearestDocsRoot(p('docs', 'intro.md'), ['docs']), p('docs')); + }); + + it('walks up past nested subfolders to find the docs root', () => { + assert.equal(findNearestDocsRoot(p('docs', 'guides', 'setup.md'), ['docs']), p('docs')); + }); + + it('matches a multi-segment tail', () => { + assert.equal( + findNearestDocsRoot(p('packages', 'api', 'docs', 'reference.md'), ['api', 'docs']), + p('packages', 'api', 'docs') + ); + }); + + it('returns undefined when nothing matches', () => { + assert.equal(findNearestDocsRoot(p('src', 'index.md'), ['docs']), undefined); + }); + + it('picks the nearest ancestor when a name repeats', () => { + assert.equal( + findNearestDocsRoot(p('docs', 'nested', 'docs', 'inner.md'), ['docs']), + p('docs', 'nested', 'docs') + ); + }); +}); + +describe('isPrivateName', () => { + it('flags dot-prefixed names', () => { + assert.equal(isPrivateName('.drafts'), true); + assert.equal(isPrivateName('.hidden.md'), true); + }); + + it('does not flag regular names', () => { + assert.equal(isPrivateName('docs'), false); + assert.equal(isPrivateName('intro.md'), false); + }); +}); + +describe('hasPrivateSegmentBelow', () => { + it('flags a dot-prefixed folder below the root', () => { + assert.equal(hasPrivateSegmentBelow(p('docs'), p('docs', '.drafts', 'secret.md')), true); + }); + + it('flags a dot-prefixed file below the root', () => { + assert.equal(hasPrivateSegmentBelow(p('docs'), p('docs', '.hidden.md')), true); + }); + + it('ignores a normal file below the root', () => { + assert.equal(hasPrivateSegmentBelow(p('docs'), p('docs', 'guides', 'setup.md')), false); + }); + + it('does not flag the root folder itself, even if dot-prefixed', () => { + assert.equal(hasPrivateSegmentBelow(p('.docs'), p('.docs', 'intro.md')), false); + }); +}); + +describe('partitionPaths', () => { + it('returns a flat result when there is at most one group', () => { + const root = p('docs'); + const result = partitionPaths(root, [p('docs', 'intro.md'), p('docs', 'guides', 'setup.md')]); + assert.deepEqual(result, { kind: 'flat', files: [p('docs', 'intro.md'), p('docs', 'guides', 'setup.md')] }); + }); + + it('groups by parent folder name when more than one group exists', () => { + const root = p('docs'); + const result = partitionPaths(root, [ + p('docs', 'intro.md'), + p('docs', 'guides', 'setup.md'), + p('docs', 'api', 'reference.md'), + p('docs', 'api', 'v2', 'other.md'), + ]); + + assert.deepEqual(result, { + kind: 'grouped', + rootFiles: [p('docs', 'intro.md')], + groups: [ + { name: 'api', files: [p('docs', 'api', 'reference.md')] }, + { name: 'guides', files: [p('docs', 'guides', 'setup.md')] }, + { name: 'v2', files: [p('docs', 'api', 'v2', 'other.md')] }, + ], + }); + }); + + it('merges files from differently-nested folders that share a parent name', () => { + const root = p('docs'); + const result = partitionPaths(root, [ + p('docs', 'guides', 'a.md'), + p('docs', 'api', 'b.md'), + p('docs', 'deep', 'nested', 'guides', 'c.md'), + ]); + + assert.deepEqual(result, { + kind: 'grouped', + rootFiles: [], + groups: [ + { name: 'api', files: [p('docs', 'api', 'b.md')] }, + { name: 'guides', files: [p('docs', 'guides', 'a.md'), p('docs', 'deep', 'nested', 'guides', 'c.md')] }, + ], + }); + }); +});