diff --git a/docs/user/features/commands.md b/docs/user/features/commands.md index e55c6cea..2f13e424 100644 --- a/docs/user/features/commands.md +++ b/docs/user/features/commands.md @@ -71,3 +71,25 @@ Examples: } } ``` + +## Link Conversion Commands + +Foam provides commands to convert between wikilink and markdown link formats. + +### foam-vscode.convert-wikilink-to-mdlink + +Converts a wikilink at the cursor position to markdown link format with a relative path. + +Example: `[[my-note]]` → `[My Note](../path/to/my-note.md)` + +### foam-vscode.convert-mdlink-to-wikilink + +Converts a markdown link at the cursor position to wikilink format. + +Example: `[My Note](../path/to/my-note.md)` → `[[my-note]]` + +**Usage:** + +1. Place your cursor inside a wikilink or markdown link +2. Open the command palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) +3. Type "Foam: Convert" and select the desired conversion command diff --git a/packages/foam-vscode/package.json b/packages/foam-vscode/package.json index a6b7630d..833768b0 100644 --- a/packages/foam-vscode/package.json +++ b/packages/foam-vscode/package.json @@ -367,12 +367,12 @@ "title": "Foam: Open Resource" }, { - "command": "foam-vscode.convert-link-style-inplace", - "title": "Foam: Convert Link Style in Place" + "command": "foam-vscode.convert-wikilink-to-mdlink", + "title": "Foam: Convert Wikilink to Markdown Link" }, { - "command": "foam-vscode.convert-link-style-incopy", - "title": "Foam: Convert Link Format in Copy" + "command": "foam-vscode.convert-mdlink-to-wikilink", + "title": "Foam: Convert Markdown Link to Wikilink" }, { "command": "foam-vscode.search-tag", @@ -526,6 +526,19 @@ "Use alias if resource path is different from title" ] }, + "foam.completion.linkFormat": { + "type": "string", + "default": "wikilink", + "description": "Controls the format of completed links", + "enum": [ + "wikilink", + "link" + ], + "enumDescriptions": [ + "Complete as wikilinks (e.g., [[note-name]])", + "Complete as markdown links (e.g., [Note Name](note-name.md))" + ] + }, "foam.files.ignore": { "type": [ "array" diff --git a/packages/foam-vscode/src/core/janitor/convert-links-format.ts b/packages/foam-vscode/src/core/janitor/convert-links-format.ts index c85e0adc..9340e7ad 100644 --- a/packages/foam-vscode/src/core/janitor/convert-links-format.ts +++ b/packages/foam-vscode/src/core/janitor/convert-links-format.ts @@ -1,14 +1,9 @@ import { Resource, ResourceLink } from '../model/note'; import { URI } from '../model/uri'; -import { Range } from '../model/range'; import { FoamWorkspace } from '../model/workspace'; import { isNone } from '../utils'; import { MarkdownLink } from '../services/markdown-link'; - -export interface LinkReplace { - newText: string; - range: Range /* old range */; -} +import { TextEdit } from '../services/text-edit'; /** * convert a link based on its workspace and the note containing it. @@ -27,7 +22,7 @@ export function convertLinkFormat( targetFormat: 'wikilink' | 'link', workspace: FoamWorkspace, note: Resource | URI -): LinkReplace { +): TextEdit { const resource = note instanceof URI ? workspace.find(note) : note; const targetUri = workspace.resolveLink(resource, link); /* If it's already the target format or a placeholder, no transformation happens */ diff --git a/packages/foam-vscode/src/features/commands/convert-links-format-in-note.ts b/packages/foam-vscode/src/features/commands/convert-links-format-in-note.ts deleted file mode 100644 index 26613deb..00000000 --- a/packages/foam-vscode/src/features/commands/convert-links-format-in-note.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { commands, ExtensionContext, window, workspace, Uri } from 'vscode'; -import { Foam } from '../../core/model/foam'; -import { FoamWorkspace } from '../../core/model/workspace'; -import { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils'; -import { ResourceParser } from '../../core/model/note'; -import { IMatcher } from '../../core/services/datastore'; -import { convertLinkFormat } from '../../core/janitor'; -import { isMdEditor } from '../../services/editor'; - -type LinkFormat = 'wikilink' | 'link'; - -enum ConvertOption { - Wikilink2MDlink, - MDlink2Wikilink, -} - -interface IConfig { - from: string; - to: string; -} - -const Config: { [key in ConvertOption]: IConfig } = { - [ConvertOption.Wikilink2MDlink]: { - from: 'wikilink', - to: 'link', - }, - [ConvertOption.MDlink2Wikilink]: { - from: 'link', - to: 'wikilink', - }, -}; - -export default async function activate( - context: ExtensionContext, - foamPromise: Promise -) { - const foam = await foamPromise; - - /* - commands: - foam-vscode.convert-link-style-inplace - foam-vscode.convert-link-style-incopy - */ - context.subscriptions.push( - commands.registerCommand('foam-vscode.convert-link-style-inplace', () => { - return convertLinkAdapter( - foam.workspace, - foam.services.parser, - foam.services.matcher, - true - ); - }), - commands.registerCommand('foam-vscode.convert-link-style-incopy', () => { - return convertLinkAdapter( - foam.workspace, - foam.services.parser, - foam.services.matcher, - false - ); - }) - ); -} - -async function convertLinkAdapter( - fWorkspace: FoamWorkspace, - fParser: ResourceParser, - fMatcher: IMatcher, - isInPlace: boolean -) { - const convertOption = await pickConvertStrategy(); - if (!convertOption) { - window.showInformationMessage('Convert canceled'); - return; - } - - if (isInPlace) { - await convertLinkInPlace(fWorkspace, fParser, fMatcher, convertOption); - } else { - await convertLinkInCopy(fWorkspace, fParser, fMatcher, convertOption); - } -} - -async function pickConvertStrategy(): Promise { - const options = { - 'to wikilink': ConvertOption.MDlink2Wikilink, - 'to markdown link': ConvertOption.Wikilink2MDlink, - }; - return window.showQuickPick(Object.keys(options)).then(name => { - if (name) { - return Config[options[name]]; - } else { - return undefined; - } - }); -} - -/** - * convert links based on its workspace and the note containing it. - * Changes happen in-place - * @param fWorkspace - * @param fParser - * @param fMatcher - * @param convertOption - * @returns void - */ -async function convertLinkInPlace( - fWorkspace: FoamWorkspace, - fParser: ResourceParser, - fMatcher: IMatcher, - convertOption: IConfig -) { - const editor = window.activeTextEditor; - const doc = editor.document; - - if (!isMdEditor(editor) || !fMatcher.isMatch(fromVsCodeUri(doc.uri))) { - return; - } - // const eol = getEditorEOL(); - let text = doc.getText(); - - const resource = fParser.parse(fromVsCodeUri(doc.uri), text); - - const textReplaceArr = resource.links - .filter(link => link.type === convertOption.from) - .map(link => - convertLinkFormat( - link, - convertOption.to as LinkFormat, - fWorkspace, - resource - ) - ) - /* transform .range property into vscode range */ - .map(linkReplace => ({ - ...linkReplace, - range: toVsCodeRange(linkReplace.range), - })); - - /* reorder the array such that the later range comes first */ - textReplaceArr.sort((a, b) => b.range.start.compareTo(a.range.start)); - - await editor.edit(editorBuilder => { - textReplaceArr.forEach(edit => { - editorBuilder.replace(edit.range, edit.newText); - }); - }); -} - -/** - * convert links based on its workspace and the note containing it. - * Changes happen in a copy - * 1. prepare a copy file, and makt it the activeTextEditor - * 2. call to convertLinkInPlace - * @param fWorkspace - * @param fParser - * @param fMatcher - * @param convertOption - * @returns void - */ -async function convertLinkInCopy( - fWorkspace: FoamWorkspace, - fParser: ResourceParser, - fMatcher: IMatcher, - convertOption: IConfig -) { - const editor = window.activeTextEditor; - const doc = editor.document; - - if (!isMdEditor(editor) || !fMatcher.isMatch(fromVsCodeUri(doc.uri))) { - return; - } - // const eol = getEditorEOL(); - let text = doc.getText(); - - const resource = fParser.parse(fromVsCodeUri(doc.uri), text); - const basePath = doc.uri.path.split('/').slice(0, -1).join('/'); - - const fileUri = doc.uri.with({ - path: `${ - basePath ? basePath + '/' : '' - }${resource.uri.getName()}.copy${resource.uri.getExtension()}`, - }); - const encoder = new TextEncoder(); - await workspace.fs.writeFile(fileUri, encoder.encode(text)); - await window.showTextDocument(fileUri); - - await convertLinkInPlace(fWorkspace, fParser, fMatcher, convertOption); -} diff --git a/packages/foam-vscode/src/features/commands/convert-links.spec.ts b/packages/foam-vscode/src/features/commands/convert-links.spec.ts new file mode 100644 index 00000000..2e27c79b --- /dev/null +++ b/packages/foam-vscode/src/features/commands/convert-links.spec.ts @@ -0,0 +1,184 @@ +/* @unit-ready */ + +import * as vscode from 'vscode'; +import { + cleanWorkspace, + closeEditors, + createFile, + showInEditor, + waitForNoteInFoamWorkspace, +} from '../../test/test-utils-vscode'; +import { deleteFile } from '../../services/editor'; +import { Logger } from '../../core/utils/log'; +import { + CONVERT_WIKILINK_TO_MDLINK, + CONVERT_MDLINK_TO_WIKILINK, +} from './convert-links'; + +Logger.setLevel('error'); + +describe('Link Conversion Commands', () => { + beforeEach(async () => { + await cleanWorkspace(); + await closeEditors(); + }); + + afterEach(async () => { + await cleanWorkspace(); + await closeEditors(); + }); + + describe('foam-vscode.convert-wikilink-to-markdown', () => { + it('should convert wikilink to markdown link', async () => { + const noteA = await createFile('# Note A', ['note-a.md']); + const { uri } = await createFile('Text before [[note-a]] text after'); + const { editor } = await showInEditor(uri); + await waitForNoteInFoamWorkspace(noteA.uri); + + editor.selection = new vscode.Selection(0, 15, 0, 15); + + await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command); + + const result = editor.document.getText(); + expect(result).toBe('Text before [Note A](note-a.md) text after'); + + await deleteFile(noteA.uri); + await deleteFile(uri); + }); + + it('should position cursor at end of converted text', async () => { + const noteA = await createFile('# Note A', ['note-a.md']); + const { uri } = await createFile('Text before [[note-a]] text after'); + const { editor } = await showInEditor(uri); + await waitForNoteInFoamWorkspace(noteA.uri); + + editor.selection = new vscode.Selection(0, 15, 0, 15); + + await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command); + + // Cursor should be at the end of the converted markdown link + const expectedPosition = 'Text before [Note A](note-a.md)'.length; + expect(editor.selection.active).toEqual( + new vscode.Position(0, expectedPosition) + ); + + await deleteFile(noteA.uri); + await deleteFile(uri); + }); + + it('should show info message when no wikilink at cursor', async () => { + const { uri } = await createFile('Text with no wikilinks'); + const { editor } = await showInEditor(uri); + + editor.selection = new vscode.Selection(0, 5, 0, 5); + + const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage'); + + await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command); + + expect(showInfoSpy).toHaveBeenCalledWith( + 'No wikilink found at cursor position' + ); + + showInfoSpy.mockRestore(); + await deleteFile(uri); + }); + + it('should show error when resource not found', async () => { + const { uri } = await createFile( + 'Text before [[nonexistent-file]] text after' + ); + const { editor } = await showInEditor(uri); + + editor.selection = new vscode.Selection(0, 20, 0, 20); + + const showErrorSpy = jest + .spyOn(vscode.window, 'showErrorMessage') + .mockResolvedValue(undefined); + + Logger.setLevel('off'); + await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command); + Logger.setLevel('error'); + + expect(showErrorSpy).toHaveBeenCalled(); + + showErrorSpy.mockRestore(); + await deleteFile(uri); + }); + }); + + describe('foam-vscode.convert-markdown-to-wikilink', () => { + it('should convert markdown link to wikilink', async () => { + const noteA = await createFile('# Note A', ['note-a.md']); + const { uri } = await createFile( + 'Text before [Note A](note-a.md) text after' + ); + const { editor } = await showInEditor(uri); + await waitForNoteInFoamWorkspace(noteA.uri); + + editor.selection = new vscode.Selection(0, 15, 0, 15); + + await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command); + + const result = editor.document.getText(); + expect(result).toBe('Text before [[note-a]] text after'); + + await deleteFile(uri); + await deleteFile(noteA.uri); + }); + + it('should position cursor at end of converted text', async () => { + const noteA = await createFile('# Note A', ['note-a.md']); + const { uri } = await createFile( + 'Text before [Note A](note-a.md) text after' + ); + const { editor } = await showInEditor(uri); + + editor.selection = new vscode.Selection(0, 15, 0, 15); + await waitForNoteInFoamWorkspace(noteA.uri); + + await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command); + + // Cursor should be at the end of the converted wikilink + const expectedPosition = 'Text before [[note-a]]'.length; + expect(editor.document.getText()).toBe( + 'Text before [[note-a]] text after' + ); + expect(editor.selection.active).toEqual( + new vscode.Position(0, expectedPosition) + ); + + await deleteFile(uri); + await deleteFile(noteA.uri); + }); + + it('should show info message when no markdown link at cursor', async () => { + const { uri } = await createFile('Text with no markdown links'); + const { editor } = await showInEditor(uri); + + editor.selection = new vscode.Selection(0, 5, 0, 5); + + const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage'); + + await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command); + + expect(showInfoSpy).toHaveBeenCalledWith( + 'No markdown link found at cursor position' + ); + + showInfoSpy.mockRestore(); + await deleteFile(uri); + }); + }); + + describe('Command registration', () => { + it('should handle no active editor gracefully', async () => { + await closeEditors(); + + await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command); + await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command); + + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/foam-vscode/src/features/commands/convert-links.test.ts b/packages/foam-vscode/src/features/commands/convert-links.test.ts new file mode 100644 index 00000000..009954dc --- /dev/null +++ b/packages/foam-vscode/src/features/commands/convert-links.test.ts @@ -0,0 +1,271 @@ +import { + convertWikilinkToMarkdownAtPosition, + convertMarkdownToWikilinkAtPosition, +} from './convert-links'; +import { URI } from '../../core/model/uri'; +import { Position } from '../../core/model/position'; +import { Range } from '../../core/model/range'; +import { TextEdit } from '../../core/services/text-edit'; +import { createTestNote, createTestWorkspace } from '../../test/test-utils'; +import { createMarkdownParser } from '../../core/services/markdown-parser'; + +describe('Link Conversion Functions', () => { + describe('convertWikilinkToMarkdownAtPosition', () => { + it('should convert simple wikilink to markdown link', () => { + const documentText = 'Text before [[note-a]] text after'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 15 }; // Inside [[note-a]] + + const workspace = createTestWorkspace().set( + createTestNote({ uri: '/test/note-a.md', title: 'Note A' }) + ); + const parser = createMarkdownParser(); + + const result = convertWikilinkToMarkdownAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + + expect(result).not.toBeNull(); + expect(result!.newText).toBe('[Note A](note-a.md)'); + expect(result!.range).toEqual(Range.create(0, 12, 0, 22)); + + // Check the final result after applying the edit + const finalText = TextEdit.apply(documentText, result!); + expect(finalText).toBe('Text before [Note A](note-a.md) text after'); + }); + + it('should convert wikilink with alias to markdown link', () => { + const documentText = 'Text before [[note-a|Custom Title]] text after'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 15 }; + + const workspace = createTestWorkspace().set( + createTestNote({ uri: '/test/note-a.md', title: 'Note A' }) + ); + const parser = createMarkdownParser(); + + const result = convertWikilinkToMarkdownAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + + expect(result).not.toBeNull(); + expect(result!.newText).toBe('[Custom Title](note-a.md)'); + + // Check the final result after applying the edit + const finalText = TextEdit.apply(documentText, result!); + expect(finalText).toBe( + 'Text before [Custom Title](note-a.md) text after' + ); + }); + + it('should handle subfolders paths correctly', () => { + const documentText = 'Text before [[path/to/note-b]] text after'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 20 }; + + const workspace = createTestWorkspace().set( + createTestNote({ uri: '/test/path/to/note-b.md', title: 'Note B' }) + ); + const parser = createMarkdownParser(); + + const result = convertWikilinkToMarkdownAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + + expect(result).not.toBeNull(); + expect(result!.newText).toBe('[Note B](path/to/note-b.md)'); + + // Check the final result after applying the edit + const finalText = TextEdit.apply(documentText, result!); + expect(finalText).toBe( + 'Text before [Note B](path/to/note-b.md) text after' + ); + }); + + it('should handle relative paths correctly', () => { + const documentText = 'Text before [[note-b]] text after'; + const documentUri = URI.file('/test/sub1/current.md'); + const linkPosition: Position = { line: 0, character: 20 }; + + const workspace = createTestWorkspace().set( + createTestNote({ uri: '/test/sub2/note-b.md', title: 'Note B' }) + ); + const parser = createMarkdownParser(); + + const result = convertWikilinkToMarkdownAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + + expect(result).not.toBeNull(); + expect(result!.newText).toBe('[Note B](../sub2/note-b.md)'); + + // Check the final result after applying the edit + const finalText = TextEdit.apply(documentText, result!); + expect(finalText).toBe( + 'Text before [Note B](../sub2/note-b.md) text after' + ); + }); + + it('should return null when no wikilink at cursor position', () => { + const documentText = 'Text with no wikilink at cursor'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 5 }; + + const workspace = createTestWorkspace(); + const parser = createMarkdownParser(); + + const result = convertWikilinkToMarkdownAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + + expect(result).toBeNull(); + }); + + it('should throw error when target resource not found', () => { + const documentText = 'Text before [[nonexistent]] text after'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 15 }; + + const workspace = createTestWorkspace(); // Empty workspace + const parser = createMarkdownParser(); + + expect(() => { + convertWikilinkToMarkdownAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + }).toThrow('Resource "nonexistent" not found'); + }); + }); + + describe('convertMarkdownToWikilinkAtPosition', () => { + it('should convert simple markdown link to wikilink', () => { + const documentText = 'Text before [Note A](note-a.md) text after'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 15 }; + + const workspace = createTestWorkspace().set( + createTestNote({ uri: '/test/note-a.md', title: 'Note A' }) + ); + const parser = createMarkdownParser(); + + const result = convertMarkdownToWikilinkAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + + expect(result).not.toBeNull(); + expect(result!.newText).toBe('[[note-a]]'); + expect(result!.range).toEqual(Range.create(0, 12, 0, 31)); + }); + + it('should convert simple markdown link to other folder to wikilink', () => { + const documentText = 'Text before [Note A](docs/note-a.md) text after'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 15 }; + + const workspace = createTestWorkspace().set( + createTestNote({ uri: '/test/docs/note-a.md', title: 'Note A' }) + ); + const parser = createMarkdownParser(); + + const result = convertMarkdownToWikilinkAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + + expect(result).not.toBeNull(); + expect(result!.newText).toBe('[[note-a]]'); + expect(result!.range).toEqual(Range.create(0, 12, 0, 36)); + }); + + it('should preserve alias when different from title', () => { + const documentText = 'Text before [Custom Title](note-a.md) text after'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 15 }; + + const workspace = createTestWorkspace().set( + createTestNote({ uri: '/test/note-a.md', title: 'Note A' }) + ); + const parser = createMarkdownParser(); + + const result = convertMarkdownToWikilinkAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + + expect(result).not.toBeNull(); + expect(result!.newText).toBe('[[note-a|Custom Title]]'); + }); + + it('should return null when no markdown link at cursor position', () => { + const documentText = 'Text with no markdown link at cursor'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 5 }; + + const workspace = createTestWorkspace(); + const parser = createMarkdownParser(); + + const result = convertMarkdownToWikilinkAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + + expect(result).toBeNull(); + }); + + it('should throw error when target resource not found', () => { + const documentText = 'Text before [Link](nonexistent.md) text after'; + const documentUri = URI.file('/test/current.md'); + const linkPosition: Position = { line: 0, character: 15 }; + + const workspace = createTestWorkspace(); + const parser = createMarkdownParser(); + + expect(() => { + convertMarkdownToWikilinkAtPosition( + documentText, + documentUri, + linkPosition, + workspace, + parser + ); + }).toThrow('Resource not found: /test/nonexistent.md'); + }); + }); +}); diff --git a/packages/foam-vscode/src/features/commands/convert-links.ts b/packages/foam-vscode/src/features/commands/convert-links.ts new file mode 100644 index 00000000..5a1b4607 --- /dev/null +++ b/packages/foam-vscode/src/features/commands/convert-links.ts @@ -0,0 +1,247 @@ +import * as vscode from 'vscode'; +import { Foam } from '../../core/model/foam'; +import { Resource, ResourceLink } from '../../core/model/note'; +import { MarkdownLink } from '../../core/services/markdown-link'; +import { Range } from '../../core/model/range'; +import { Position } from '../../core/model/position'; +import { URI } from '../../core/model/uri'; +import { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils'; +import { Logger } from '../../core/utils/log'; +import { TextEdit } from '../../core/services/text-edit'; + +export const CONVERT_WIKILINK_TO_MDLINK = { + command: 'foam-vscode.convert-wikilink-to-mdlink', + title: 'Foam: Convert Wikilink to Markdown Link', +}; + +export const CONVERT_MDLINK_TO_WIKILINK = { + command: 'foam-vscode.convert-mdlink-to-wikilink', + title: 'Foam: Convert Markdown Link to Wikilink', +}; + +/** + * Pure function to convert a wikilink to markdown link at a specific position + * Returns the TextEdit to apply, or null if no conversion is possible + */ +export function convertWikilinkToMarkdownAtPosition( + documentText: string, + documentUri: URI, + linkPosition: Position, + foamWorkspace: { find: (identifier: string) => Resource | null }, + foamParser: { parse: (uri: URI, text: string) => Resource } +): TextEdit | null { + // Parse the document to get all links using Foam's parser + const resource = foamParser.parse(documentUri, documentText); + + // Find the link at cursor position + const targetLink: ResourceLink | undefined = resource.links.find( + link => + link.type === 'wikilink' && + Range.containsPosition(link.range, linkPosition) + ); + + if (!targetLink) { + return null; + } + + // Parse the link to get target and alias information + const linkInfo = MarkdownLink.analyzeLink(targetLink); + + // Find the target resource in the workspace + const targetResource = foamWorkspace.find(linkInfo.target); + if (!targetResource) { + throw new Error(`Resource "${linkInfo.target}" not found`); + } + + // Compute relative path from current file to target file + const currentDirectory = documentUri.getDirectory(); + const relativePath = targetResource.uri.relativeTo(currentDirectory).path; + + const alias = linkInfo.alias ? linkInfo.alias : targetResource.title; + return MarkdownLink.createUpdateLinkEdit(targetLink, { + type: 'link', + target: relativePath, + alias: alias, + }); +} + +/** + * Pure function to convert a markdown link to wikilink at a specific position + * Returns the TextEdit to apply, or null if no conversion is possible + */ +export function convertMarkdownToWikilinkAtPosition( + documentText: string, + documentUri: URI, + cursorPosition: Position, + foamWorkspace: { + resolveLink: (resource: Resource, link: ResourceLink) => URI; + get: (uri: URI) => Resource | null; + getIdentifier: (uri: URI) => string; + }, + foamParser: { parse: (uri: URI, text: string) => Resource } +): TextEdit | null { + // Parse the document to get all links using Foam's parser + const resource = foamParser.parse(documentUri, documentText); + + // Find the link at cursor position + const targetLink: ResourceLink | undefined = resource.links.find( + link => + link.type === 'link' && Range.containsPosition(link.range, cursorPosition) + ); + + if (!targetLink) { + return null; + } + + // Parse the link to get target and alias information + const linkInfo = MarkdownLink.analyzeLink(targetLink); + + // Try to resolve the target resource from the link + const targetUri = foamWorkspace.resolveLink(resource, targetLink); + const targetResource = foamWorkspace.get(targetUri); + + if (!targetResource) { + throw new Error(`Resource not found: ${targetUri.path}`); + } + + // Get the workspace identifier for the target resource + const identifier = foamWorkspace.getIdentifier(targetResource.uri); + + return MarkdownLink.createUpdateLinkEdit(targetLink, { + type: 'wikilink', + target: identifier, + alias: + linkInfo.alias && linkInfo.alias !== targetResource.title + ? linkInfo.alias + : '', + }); +} + +export default async function activate( + context: vscode.ExtensionContext, + foamPromise: Promise +) { + const foam = await foamPromise; + + context.subscriptions.push( + vscode.commands.registerCommand(CONVERT_WIKILINK_TO_MDLINK.command, () => + convertWikilinkToMarkdown(foam) + ), + + vscode.commands.registerCommand(CONVERT_MDLINK_TO_WIKILINK.command, () => + convertMarkdownToWikilink(foam) + ) + ); +} + +/** + * Convert wikilink at cursor position to markdown link format + */ +export async function convertWikilinkToMarkdown(foam: Foam): Promise { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + return; + } + + const document = activeEditor.document; + const position = activeEditor.selection.active; + + try { + const edit = convertWikilinkToMarkdownAtPosition( + document.getText(), + fromVsCodeUri(document.uri), + { + line: position.line, + character: position.character, + }, + foam.workspace, + foam.services.parser + ); + + if (!edit) { + vscode.window.showInformationMessage( + 'No wikilink found at cursor position' + ); + return; + } + + // Apply the edit to the document + const range = toVsCodeRange(edit.range); + const success = await activeEditor.edit(editBuilder => { + editBuilder.replace(range, edit.newText); + }); + + // Position cursor at the end of the updated text + if (success) { + const newEndPosition = new vscode.Position( + range.start.line, + range.start.character + edit.newText.length + ); + activeEditor.selection = new vscode.Selection( + newEndPosition, + newEndPosition + ); + } + } catch (error) { + Logger.error('Failed to convert wikilink to markdown link', error); + vscode.window.showErrorMessage( + `Failed to convert wikilink to markdown link: ${error.message}` + ); + } +} + +/** + * Convert markdown link at cursor position to wikilink format + */ +export async function convertMarkdownToWikilink(foam: Foam): Promise { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + return; + } + + const document = activeEditor.document; + const position = activeEditor.selection.active; + + try { + const edit = convertMarkdownToWikilinkAtPosition( + document.getText(), + fromVsCodeUri(document.uri), + { + line: position.line, + character: position.character, + }, + foam.workspace, + foam.services.parser + ); + + if (!edit) { + vscode.window.showInformationMessage( + 'No markdown link found at cursor position' + ); + return; + } + + // Apply the edit to the document + const range = toVsCodeRange(edit.range); + const success = await activeEditor.edit(editBuilder => { + editBuilder.replace(range, edit.newText); + }); + + // Position cursor at the end of the updated text + if (success) { + const newEndPosition = new vscode.Position( + range.start.line, + range.start.character + edit.newText.length + ); + activeEditor.selection = new vscode.Selection( + newEndPosition, + newEndPosition + ); + } + } catch (error) { + Logger.error('Failed to convert markdown link to wikilink', error); + vscode.window.showErrorMessage( + `Failed to convert markdown link to wikilink: ${error.message}` + ); + } +} diff --git a/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts b/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts index 303ea21d..14c41c72 100644 --- a/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts +++ b/packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts @@ -1,9 +1,13 @@ /* @unit-ready */ import { commands, window, workspace } from 'vscode'; import { toVsCodeUri } from '../../utils/vsc-utils'; -import { createFile } from '../../test/test-utils-vscode'; +import { cleanWorkspace, createFile } from '../../test/test-utils-vscode'; describe('create-note-from-template command', () => { + beforeAll(async () => { + await cleanWorkspace(); + }); + afterEach(() => { jest.clearAllMocks(); }); diff --git a/packages/foam-vscode/src/features/commands/index.ts b/packages/foam-vscode/src/features/commands/index.ts index be69f53c..4c916338 100644 --- a/packages/foam-vscode/src/features/commands/index.ts +++ b/packages/foam-vscode/src/features/commands/index.ts @@ -10,6 +10,6 @@ export { default as openResource } from './open-resource'; export { default as updateGraphCommand } from './update-graph'; export { default as updateWikilinksCommand } from './update-wikilinks'; export { default as createNote } from './create-note'; -export { default as generateStandaloneNote } from './convert-links-format-in-note'; export { default as searchTagCommand } from './search-tag'; export { default as renameTagCommand } from './rename-tag'; +export { default as convertLinksCommand } from './convert-links'; diff --git a/packages/foam-vscode/src/features/link-completion.spec.ts b/packages/foam-vscode/src/features/link-completion.spec.ts index 8447ef81..5e12c76d 100644 --- a/packages/foam-vscode/src/features/link-completion.spec.ts +++ b/packages/foam-vscode/src/features/link-completion.spec.ts @@ -1,3 +1,5 @@ +/* @unit-ready */ + import * as vscode from 'vscode'; import { createMarkdownParser } from '../core/services/markdown-parser'; import { FoamGraph } from '../core/model/graph'; @@ -14,6 +16,7 @@ import { WikilinkCompletionProvider, SectionCompletionProvider, } from './link-completion'; +import { CONVERT_WIKILINK_TO_MDLINK } from './commands/convert-links'; describe('Link Completion', () => { const parser = createMarkdownParser([]); @@ -281,4 +284,220 @@ alias: alias-a expect(aliasCompletionItem.label).toBe('alias-a'); expect(aliasCompletionItem.insertText).toBe('new-note-with-alias|alias-a'); }); + + it('should support linkFormat setting - wikilink format (default)', async () => { + const { uri: noteUri, content } = await createFile(`# My Note Title`); + const workspace = createTestWorkspace(); + workspace.set(parser.parse(noteUri, content)); + const provider = new WikilinkCompletionProvider( + workspace, + FoamGraph.fromWorkspace(workspace) + ); + + const { uri } = await createFile('[['); + const { doc } = await showInEditor(uri); + + await withModifiedFoamConfiguration( + 'completion.linkFormat', + 'wikilink', + async () => { + await withModifiedFoamConfiguration( + 'completion.useAlias', + 'never', + async () => { + const links = await provider.provideCompletionItems( + doc, + new vscode.Position(0, 2) + ); + + expect(links.items.length).toBe(1); + expect(links.items[0].insertText).toBe( + workspace.getIdentifier(noteUri) + ); + } + ); + } + ); + }); + + it('should support linkFormat setting - markdown link format', async () => { + const { uri: noteUri, content } = await createFile(`# My Note Title`, [ + 'my', + 'path', + 'to', + 'test-note.md', + ]); + const workspace = createTestWorkspace(); + workspace.set(parser.parse(noteUri, content)); + const provider = new WikilinkCompletionProvider( + workspace, + FoamGraph.fromWorkspace(workspace) + ); + + const { uri } = await createFile('[['); + const { doc } = await showInEditor(uri); + + await withModifiedFoamConfiguration( + 'completion.linkFormat', + 'link', + async () => { + const links = await provider.provideCompletionItems( + doc, + new vscode.Position(0, 2) + ); + + expect(links.items.length).toBe(1); + const insertText = String(links.items[0].insertText); + + // In test environment, the command converts wikilink to markdown after insertion + // The insertText is the wikilink format, conversion happens via command + // So we expect just the identifier (no alias because linkFormat === 'link') + expect(insertText).toBe(workspace.getIdentifier(noteUri)); + + // Commit characters should be empty when using conversion command + expect(links.items[0].commitCharacters).toEqual([]); + + // Verify command is attached for conversion + expect(links.items[0].command).toBeDefined(); + expect(links.items[0].command.command).toBe( + CONVERT_WIKILINK_TO_MDLINK.command + ); + } + ); + }); + + it('should support linkFormat setting with aliases - markdown format', async () => { + const { uri: noteUri, content } = await createFile(`# My Different Title`, [ + 'another-note.md', + ]); + const workspace = createTestWorkspace(); + workspace.set(parser.parse(noteUri, content)); + const provider = new WikilinkCompletionProvider( + workspace, + FoamGraph.fromWorkspace(workspace) + ); + + const { uri } = await createFile('[['); + const { doc } = await showInEditor(uri); + + await withModifiedFoamConfiguration( + 'completion.linkFormat', + 'link', + async () => { + await withModifiedFoamConfiguration( + 'completion.useAlias', + 'whenPathDiffersFromTitle', + async () => { + const links = await provider.provideCompletionItems( + doc, + new vscode.Position(0, 2) + ); + + expect(links.items.length).toBe(1); + const insertText = links.items[0].insertText; + + // When linkFormat is 'link', we don't use alias in insertText + // The conversion command handles the title mapping + expect(insertText).toBe(workspace.getIdentifier(noteUri)); + expect(links.items[0].commitCharacters).toEqual([]); + + // Verify command is attached for conversion + expect(links.items[0].command).toBeDefined(); + expect(links.items[0].command.command).toBe( + CONVERT_WIKILINK_TO_MDLINK.command + ); + } + ); + } + ); + }); + + it('should handle alias completion with markdown link format', async () => { + const { uri, content } = await createFile( + ` +--- +alias: test-alias +--- +[[ +`, + ['note-with-alias.md'] + ); + ws.set(parser.parse(uri, content)); + + const { doc } = await showInEditor(uri); + const provider = new WikilinkCompletionProvider(ws, graph); + + await withModifiedFoamConfiguration( + 'completion.linkFormat', + 'link', + async () => { + const links = await provider.provideCompletionItems( + doc, + new vscode.Position(4, 2) + ); + + const aliasCompletionItem = links.items.find( + i => i.label === 'test-alias' + ); + expect(aliasCompletionItem).not.toBeNull(); + expect(aliasCompletionItem.label).toBe('test-alias'); + + // Alias completions always use pipe syntax in insertText + // The conversion command will convert it to markdown format + expect(aliasCompletionItem.insertText).toBe( + 'note-with-alias|test-alias' + ); + expect(aliasCompletionItem.commitCharacters).toEqual([]); + + // Verify command is attached for conversion + expect(aliasCompletionItem.command).toBeDefined(); + expect(aliasCompletionItem.command.command).toBe( + CONVERT_WIKILINK_TO_MDLINK.command + ); + } + ); + }); + + it('should ignore linkFormat setting for placeholder completions', async () => { + const { uri } = await createFile('[['); + const { doc } = await showInEditor(uri); + const provider = new WikilinkCompletionProvider(ws, graph); + + // Test with wikilink format - should return plain text + await withModifiedFoamConfiguration( + 'completion.linkFormat', + 'wikilink', + async () => { + const links = await provider.provideCompletionItems( + doc, + new vscode.Position(0, 2) + ); + + const placeholderItem = links.items.find( + i => i.label === 'placeholder text' + ); + expect(placeholderItem).not.toBeNull(); + expect(placeholderItem.insertText).toBe('placeholder text'); + } + ); + + // Test with markdown link format - should also return plain text (ignore format conversion) + await withModifiedFoamConfiguration( + 'completion.linkFormat', + 'link', + async () => { + const links = await provider.provideCompletionItems( + doc, + new vscode.Position(0, 2) + ); + + const placeholderItem = links.items.find( + i => i.label === 'placeholder text' + ); + expect(placeholderItem).not.toBeNull(); + // Placeholders should remain as plain text, not converted to wikilink format + expect(placeholderItem.insertText).toBe('placeholder text'); + } + ); + }); }); diff --git a/packages/foam-vscode/src/features/link-completion.ts b/packages/foam-vscode/src/features/link-completion.ts index f0dda23c..d520fe50 100644 --- a/packages/foam-vscode/src/features/link-completion.ts +++ b/packages/foam-vscode/src/features/link-completion.ts @@ -7,6 +7,7 @@ import { FoamWorkspace } from '../core/model/workspace'; import { getFoamVsCodeConfig } from '../services/config'; import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils'; import { getNoteTooltip, getFoamDocSelectors } from '../services/editor'; +import { CONVERT_WIKILINK_TO_MDLINK } from './commands/convert-links'; export const aliasCommitCharacters = ['#']; export const linkCommitCharacters = ['#', '|']; @@ -169,15 +170,17 @@ export class WikilinkCompletionProvider } const text = requiresAutocomplete[0]; + const labelStyle = getCompletionLabelSetting(); + const aliasSetting = getCompletionAliasSetting(); + const linkFormat = getCompletionLinkFormatSetting(); + // Use safe range that VS Code accepts - replace content inside brackets only const replacementRange = new vscode.Range( position.line, position.character - (text.length - 2), position.line, position.character ); - const labelStyle = getCompletionLabelSetting(); - const aliasSetting = getCompletionAliasSetting(); const resources = this.ws.list().map(resource => { const resourceIsDocument = @@ -206,15 +209,22 @@ export class WikilinkCompletionProvider const useAlias = resourceIsDocument && + linkFormat !== 'link' && aliasSetting !== 'never' && - wikilinkRequiresAlias(resource); + wikilinkRequiresAlias(resource, this.ws.defaultExtension); item.insertText = useAlias ? `${identifier}|${resource.title}` : identifier; - item.commitCharacters = useAlias ? [] : linkCommitCharacters; + // When using aliases or markdown link format, don't allow commit characters + // since we either have the full text or will convert it + item.commitCharacters = + useAlias || linkFormat === 'link' ? [] : linkCommitCharacters; item.range = replacementRange; - item.command = COMPLETION_CURSOR_MOVE; + item.command = + linkFormat === 'link' + ? CONVERT_WIKILINK_TO_MDLINK + : COMPLETION_CURSOR_MOVE; return item; }); const aliases = this.ws.list().flatMap(resource => @@ -224,13 +234,27 @@ export class WikilinkCompletionProvider vscode.CompletionItemKind.Reference, resource.uri ); - item.insertText = this.ws.getIdentifier(resource.uri) + '|' + a.title; + + const identifier = this.ws.getIdentifier(resource.uri); + + item.insertText = `${identifier}|${a.title}`; + // When using markdown link format, don't allow commit characters + item.commitCharacters = + linkFormat === 'link' ? [] : aliasCommitCharacters; + item.range = replacementRange; + + // If link format is enabled, convert after completion + item.command = + linkFormat === 'link' + ? { + command: CONVERT_WIKILINK_TO_MDLINK.command, + title: CONVERT_WIKILINK_TO_MDLINK.title, + } + : COMPLETION_CURSOR_MOVE; + item.detail = `Alias of ${vscode.workspace.asRelativePath( toVsCodeUri(resource.uri) )}`; - item.range = replacementRange; - item.command = COMPLETION_CURSOR_MOVE; - item.commitCharacters = aliasCommitCharacters; return item; }) ); @@ -293,7 +317,19 @@ function getCompletionAliasSetting() { return aliasStyle; } -const normalize = (text: string) => text.toLocaleLowerCase().trim(); -function wikilinkRequiresAlias(resource: Resource) { - return normalize(resource.uri.getName()) !== normalize(resource.title); +function getCompletionLinkFormatSetting() { + const linkFormat: 'wikilink' | 'link' = getFoamVsCodeConfig( + 'completion.linkFormat' + ); + return linkFormat; +} + +const normalize = (text: string) => text.toLocaleLowerCase().trim(); +function wikilinkRequiresAlias(resource: Resource, defaultExtension: string) { + // Compare filename (without extension) to title + const nameWithoutExt = resource.uri.getName(); + const titleWithoutExt = resource.title.endsWith(defaultExtension) + ? resource.title.slice(0, -defaultExtension.length) + : resource.title; + return normalize(nameWithoutExt) !== normalize(titleWithoutExt); } diff --git a/packages/foam-vscode/src/test/suite-unit.ts b/packages/foam-vscode/src/test/suite-unit.ts index 2a396f9d..852c9dfe 100644 --- a/packages/foam-vscode/src/test/suite-unit.ts +++ b/packages/foam-vscode/src/test/suite-unit.ts @@ -24,6 +24,43 @@ import * as glob from 'glob'; const rootDir = path.join(__dirname, '..', '..'); +function parseJestArgs(args: string[]): any { + const config: any = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--testNamePattern' && i + 1 < args.length) { + config.testNamePattern = args[i + 1]; + i++; // Skip next arg as it's the value + } else if (arg === '--testPathPattern' && i + 1 < args.length) { + config.testPathPattern = args[i + 1].split('/').at(-1) || args[i + 1]; + i++; // Skip next arg as it's the value + } else if (arg === '--json') { + config.json = true; + } else if (arg === '--useStderr') { + config.useStderr = true; + } else if (arg === '--outputFile' && i + 1 < args.length) { + config.outputFile = args[i + 1]; + i++; // Skip next arg as it's the value + } else if (arg === '--no-coverage') { + config.collectCoverage = false; + } else if (arg === '--watchAll=false') { + config.watchAll = false; + } else if (arg === '--colors') { + config.colors = true; + } else if (arg === '--reporters' && i + 1 < args.length) { + if (!config.reporters) { + config.reporters = []; + } + config.reporters.push(args[i + 1]); + i++; // Skip next arg as it's the value + } + } + + return config; +} + function getUnitReadySpecFiles(rootDir: string): string[] { const specFiles = glob.sync('**/*.spec.ts', { cwd: path.join(rootDir, 'src'), @@ -59,35 +96,37 @@ export function runUnit( return new Promise(async (resolve, reject) => { try { const { results } = await runCLI( - { - rootDir, - roots: ['/src'], - runInBand: true, - testRegex: excludeSpecs - ? ['\\.(test)\\.ts$'] - : (() => { - const unitReadySpecs = getUnitReadySpecFiles(rootDir); + Object.assign( + { + rootDir, + roots: ['/src'], + runInBand: true, + testRegex: excludeSpecs + ? ['\\.(test)\\.ts$'] + : (() => { + const unitReadySpecs = getUnitReadySpecFiles(rootDir); - // Create pattern that includes .test files + specific .spec files - return [ - '\\.(test)\\.ts$', // All .test files - ...unitReadySpecs.map( - file => - file.replace(/\//g, '\\/').replace(/\./g, '\\.') + '$' - ), - ]; - })(), - setupFiles: ['/src/test/support/jest-setup.ts'], - setupFilesAfterEnv: [ - '/src/test/support/jest-setup-after-env.ts', - ], - testTimeout: 20000, - verbose: false, - silent: false, - colors: true, - // Pass through any additional args - _: extraArgs, - } as any, + // Create pattern that includes .test files + specific .spec files + return [ + '\\.(test)\\.ts$', // All .test files + ...unitReadySpecs.map( + file => + file.replace(/\//g, '\\/').replace(/\./g, '\\.') + '$' + ), + ]; + })(), + setupFiles: ['/src/test/support/jest-setup.ts'], + setupFilesAfterEnv: [ + '/src/test/support/jest-setup-after-env.ts', + ], + testTimeout: 20000, + verbose: false, + silent: false, + colors: true, + }, + // Parse additional Jest arguments into config object + parseJestArgs(extraArgs) + ) as any, [rootDir] ); diff --git a/packages/foam-vscode/src/test/vscode-mock-extensions.test.ts b/packages/foam-vscode/src/test/vscode-mock-extensions.test.ts new file mode 100644 index 00000000..cf98213a --- /dev/null +++ b/packages/foam-vscode/src/test/vscode-mock-extensions.test.ts @@ -0,0 +1,41 @@ +import * as vscode from './vscode-mock'; + +describe('vscode-mock extensions API', () => { + it('should provide extensions.getExtension', () => { + expect(vscode.extensions).toBeDefined(); + expect(vscode.extensions.getExtension).toBeDefined(); + }); + + it('should return foam extension', () => { + const ext = vscode.extensions.getExtension('foam.foam-vscode'); + expect(ext).toBeDefined(); + expect(ext?.id).toBe('foam.foam-vscode'); + expect(ext?.isActive).toBe(true); + }); + + it('should return undefined for unknown extensions', () => { + const ext = vscode.extensions.getExtension('unknown.extension'); + expect(ext).toBeUndefined(); + }); + + it('should provide foam instance through extension exports', async () => { + const ext = vscode.extensions.getExtension('foam.foam-vscode'); + expect(ext?.exports).toBeDefined(); + expect(ext?.exports.foam).toBeDefined(); + + // foam is a getter that returns a Promise + const foam = await ext?.exports.foam; + expect(foam).toBeDefined(); + expect(foam.workspace).toBeDefined(); + expect(foam.graph).toBeDefined(); + }); + + it('should support activate() method', async () => { + const ext = vscode.extensions.getExtension('foam.foam-vscode'); + expect(ext?.activate).toBeDefined(); + + const exports = await ext?.activate(); + expect(exports).toBeDefined(); + expect(exports.foam).toBeDefined(); + }); +}); diff --git a/packages/foam-vscode/src/test/vscode-mock.ts b/packages/foam-vscode/src/test/vscode-mock.ts index c3b1b0f2..3431d134 100644 --- a/packages/foam-vscode/src/test/vscode-mock.ts +++ b/packages/foam-vscode/src/test/vscode-mock.ts @@ -6,7 +6,7 @@ import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; -import { Position } from '../core/model/position'; +import { Position as FoamPosition } from '../core/model/position'; import { Range as FoamRange } from '../core/model/range'; import { URI } from '../core/model/uri'; import { Logger } from '../core/utils/log'; @@ -35,7 +35,117 @@ interface Thenable { // ===== Basic VS Code Types ===== -export { Position }; +export class Position implements FoamPosition { + public readonly line: number; + public readonly character: number; + constructor(line: number, character: number) { + this.line = line; + this.character = character; + } + static create(line: number, character: number): Position { + return new Position(line, character); + } + + // Instance methods + compareTo(other: Position): number { + if (this.line < other.line) return -1; + if (this.line > other.line) return 1; + if (this.character < other.character) return -1; + if (this.character > other.character) return 1; + return 0; + } + + isAfter(other: Position): boolean { + return this.compareTo(other) > 0; + } + + isAfterOrEqual(other: Position): boolean { + return this.compareTo(other) >= 0; + } + + isBefore(other: Position): boolean { + return this.compareTo(other) < 0; + } + + isBeforeOrEqual(other: Position): boolean { + return this.compareTo(other) <= 0; + } + + isEqual(other: Position): boolean { + return this.compareTo(other) === 0; + } + + translate(lineDelta?: number, characterDelta?: number): Position; + // eslint-disable-next-line no-dupe-class-members + translate(change: { lineDelta?: number; characterDelta?: number }): Position; + // eslint-disable-next-line no-dupe-class-members + translate( + lineDeltaOrChange?: + | number + | { lineDelta?: number; characterDelta?: number }, + characterDelta?: number + ): Position { + let lineDelta: number; + let charDelta: number; + + if (typeof lineDeltaOrChange === 'object') { + lineDelta = lineDeltaOrChange.lineDelta ?? 0; + charDelta = lineDeltaOrChange.characterDelta ?? 0; + } else { + lineDelta = lineDeltaOrChange ?? 0; + charDelta = characterDelta ?? 0; + } + + return new Position(this.line + lineDelta, this.character + charDelta); + } + + with(line?: number, character?: number): Position; + // eslint-disable-next-line no-dupe-class-members + with(change: { line?: number; character?: number }): Position; + // eslint-disable-next-line no-dupe-class-members + with( + lineOrChange?: number | { line?: number; character?: number }, + character?: number + ): Position { + let line: number; + let char: number; + + if (typeof lineOrChange === 'object') { + line = lineOrChange.line ?? this.line; + char = lineOrChange.character ?? this.character; + } else { + line = lineOrChange ?? this.line; + char = character ?? this.character; + } + + return new Position(line, char); + } + + // Static helper methods + static isAfter(a: Position, b: Position): boolean { + return a.isAfter(b); + } + + static isAfterOrEqual(a: Position, b: Position): boolean { + return a.isAfterOrEqual(b); + } + + static isBefore(a: Position, b: Position): boolean { + return a.isBefore(b); + } + + static isBeforeOrEqual(a: Position, b: Position): boolean { + return a.isBeforeOrEqual(b); + } + + static isEqual(a: Position, b: Position): boolean { + return a.isEqual(b); + } + + static compareTo(a: Position, b: Position): number { + return a.compareTo(b); + } +} // VS Code Range class export class Range implements FoamRange { @@ -56,8 +166,8 @@ export class Range implements FoamRange { endCharacter?: number ) { if (typeof startOrLine === 'number') { - this.start = { line: startOrLine, character: endOrCharacter as number }; - this.end = { line: endLine!, character: endCharacter! }; + this.start = new Position(startOrLine, endOrCharacter as number); + this.end = new Position(endLine!, endCharacter!); } else { this.start = startOrLine; this.end = endOrCharacter as Position; @@ -237,8 +347,8 @@ export class Selection extends Range { let active: Position; if (typeof anchorOrLine === 'number') { - anchor = { line: anchorOrLine, character: activeOrCharacter as number }; - active = { line: activeLine!, character: activeCharacter! }; + anchor = new Position(anchorOrLine, activeOrCharacter as number); + active = new Position(activeLine!, activeCharacter!); } else { anchor = anchorOrLine; active = activeOrCharacter as Position; @@ -819,6 +929,7 @@ class MockTextDocument implements TextDocument { fs.writeFileSync(this.uri.fsPath, content); } catch (error) { Logger.error('vscode-mock: Failed to write file', error); + throw error; } } } @@ -854,6 +965,32 @@ class MockTextEditor implements TextEditor { async edit(callback: (editBuilder: any) => void): Promise { // Simplified edit implementation + const edits: { range: Range; newText: string }[] = []; + const editBuilder = { + replace: (range: Range, newText: string) => { + edits.push({ range, newText }); + }, + }; + callback(editBuilder); + + // Apply edits in reverse order to avoid offset issues + const document = this.document as MockTextDocument; + let content = document.getText(); + edits + .sort( + (a, b) => + document.offsetAt(b.range.start) - document.offsetAt(a.range.start) + ) + .forEach(edit => { + const startOffset = document.offsetAt(edit.range.start); + const endOffset = document.offsetAt(edit.range.end); + content = + content.substring(0, startOffset) + + edit.newText + + content.substring(endOffset); + }); + + document._updateContent(content); return true; } @@ -1114,6 +1251,33 @@ function createMockExtensionContext(): ExtensionContext { }; } +// ===== Extension API ===== + +export interface Extension { + id: string; + extensionPath: string; + isActive: boolean; + packageJSON: any; + exports: T; + activate(): Thenable; +} + +class MockExtension implements Extension { + constructor( + public id: string, + public exports: T, + public isActive: boolean = true + ) {} + + extensionPath = '/mock/extension/path'; + packageJSON = {}; + + activate(): Thenable { + this.isActive = true; + return Promise.resolve(this.exports); + } +} + // ===== Foam Commands Lazy Initialization ===== class TestFoam { @@ -1233,8 +1397,8 @@ async function initializeFoamCommands(foam: Foam): Promise { await foamCommands.openResource(mockContext, foamPromise); await foamCommands.updateGraphCommand(mockContext, foamPromise); await foamCommands.updateWikilinksCommand(mockContext, foamPromise); - await foamCommands.generateStandaloneNote(mockContext, foamPromise); await foamCommands.openDailyNoteForDateCommand(mockContext, foamPromise); + await foamCommands.convertLinksCommand(mockContext, foamPromise); // Commands that only need context await foamCommands.copyWithoutBracketsCommand(mockContext); @@ -1345,8 +1509,10 @@ export const window = { message: string, ...items: string[] ): Promise { - // Mock implementation - do nothing - return undefined; + throw new Error( + 'showErrorMessage called - should be mocked in tests if error handling is expected. Message was: ' + + message + ); }, }; @@ -1599,6 +1765,37 @@ export const languages = { }, }; +// Extensions namespace +export const extensions = { + getExtension(extensionId: string): Extension | undefined { + if (extensionId === 'foam.foam-vscode') { + return new MockExtension( + extensionId, + { + get foam() { + return TestFoam.getInstance(); + }, + }, + true + ) as Extension; + } + return undefined; + }, + + get all(): Extension[] { + const foamExtension = new MockExtension( + 'foam.foam-vscode', + { + get foam() { + return TestFoam.getInstance(); + }, + }, + true + ); + return [foamExtension]; + }, +}; + // Env namespace export const env = { __mockClipboard: '',