├── .npmignore ├── .gitignore ├── .eslintignore ├── .prettierignore ├── .prettierrc.yaml ├── src ├── common │ ├── connection.ts │ ├── documents.ts │ └── vault.ts ├── handler │ ├── definition.ts │ ├── codeaction.ts │ ├── hover.ts │ ├── rename.ts │ ├── diagnostics.ts │ └── completion.ts └── main.ts ├── tsconfig.json ├── .eslintrc.yaml ├── package.json ├── LICENSE └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | *.js 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | out/** 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | out/** 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: "es5" 2 | tabWidth: 4 3 | useTabs: true 4 | semi: false 5 | singleQuote: false 6 | -------------------------------------------------------------------------------- /src/common/connection.ts: -------------------------------------------------------------------------------- 1 | import { ProposedFeatures, createConnection } from "vscode-languageserver/node" 2 | 3 | export const connection = createConnection(ProposedFeatures.all) 4 | -------------------------------------------------------------------------------- /src/common/documents.ts: -------------------------------------------------------------------------------- 1 | import { TextDocuments } from "vscode-languageserver/node" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | export const documents: TextDocuments = new TextDocuments( 5 | TextDocument 6 | ) 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["es2020"], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "strict": true, 9 | "outDir": "out", 10 | "rootDir": "src" 11 | }, 12 | "include": ["src"], 13 | "exclude": ["node_modules", ".vscode-test"] 14 | } 15 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | root: true 3 | env: 4 | es2020: true 5 | parser: "@typescript-eslint/parser" 6 | parserOptions: 7 | project: ./tsconfig.json 8 | plugins: 9 | - "@typescript-eslint" 10 | extends: 11 | - eslint:recommended 12 | - plugin:@typescript-eslint/eslint-recommended 13 | - plugin:@typescript-eslint/recommended 14 | - prettier 15 | -------------------------------------------------------------------------------- /src/handler/definition.ts: -------------------------------------------------------------------------------- 1 | import { DefinitionParams, Location } from "vscode-languageserver" 2 | import { 3 | parseWikiLink, 4 | getWikiLinkUnderPos, 5 | } from "../common/vault" 6 | import { documents } from "../common/documents" 7 | 8 | export function onDefinition(params: DefinitionParams) { 9 | const doc = documents.get(params.textDocument.uri) 10 | if (!doc) return 11 | const wikilink = getWikiLinkUnderPos(params.position, doc) 12 | if (wikilink !== null) { 13 | try { 14 | const note = parseWikiLink(wikilink).note 15 | return Location.create(note.uri.toString(), { 16 | start: doc.positionAt(0), 17 | end: doc.positionAt(0), 18 | }) 19 | } catch { 20 | return 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/handler/codeaction.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CodeAction, 3 | CodeActionKind, 4 | CodeActionParams, 5 | } from "vscode-languageserver/node" 6 | import { documents } from "../common/documents" 7 | import { parseWikiLink, getWikiLinkUnderPos } from "../common/vault" 8 | import { applyAlias } from "./rename" 9 | 10 | export function onCodeAction(params: CodeActionParams) { 11 | const doc = documents.get(params.textDocument.uri) 12 | if (!doc) return 13 | 14 | const actions = [] 15 | 16 | const wikilink = getWikiLinkUnderPos(params.range.start, doc) 17 | add_title_or_alias: if (wikilink) { 18 | const { alias, note } = parseWikiLink(wikilink) 19 | if (!alias) break add_title_or_alias 20 | 21 | actions.push( 22 | CodeAction.create( 23 | `Add "${alias}" as title (or alias) into ${note.uri}.`, 24 | applyAlias({ alias, note }), 25 | CodeActionKind.QuickFix 26 | ) 27 | ) 28 | } 29 | 30 | return actions 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-lsp", 3 | "description": "LSP server for operating the note-taking system Obsidian", 4 | "version": "1.0.4", 5 | "author": "Amadeus_vn", 6 | "license": "MIT", 7 | "engines": { 8 | "node": "*" 9 | }, 10 | "bin": "out/main.js", 11 | "repository": { 12 | "type": "git", 13 | "url": "http://github.com/gw31415/obsidian-lsp" 14 | }, 15 | "dependencies": { 16 | "gray-matter": "^4.0.3", 17 | "vscode-languageserver": "^8.1.0", 18 | "vscode-languageserver-textdocument": "^1.0.8", 19 | "vscode-uri": "^3.0.7" 20 | }, 21 | "scripts": { 22 | "build": "tsc", 23 | "prepare": "npm run build", 24 | "dev": "ts-node src/main.ts" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^20.3.1", 28 | "@typescript-eslint/eslint-plugin": "^5.60.0", 29 | "@typescript-eslint/parser": "^5.60.0", 30 | "eslint": "7", 31 | "eslint-config-prettier": "^8.8.0", 32 | "eslint-plugin-prettier": "^4.2.1", 33 | "prettier": "^2.8.8", 34 | "ts-node": "^10.9.1", 35 | "typescript": "^5.2.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Amadeus_vn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/handler/hover.ts: -------------------------------------------------------------------------------- 1 | import { HoverParams, MarkupKind } from "vscode-languageserver/node" 2 | import { documents } from "../common/documents" 3 | import { 4 | NoteNotFoundError, 5 | WikiLinkBrokenError, 6 | parseWikiLink, 7 | getWikiLinkUnderPos, 8 | } from "../common/vault" 9 | import { connection } from "../common/connection" 10 | 11 | export function onHover(params: HoverParams) { 12 | const doc = documents.get(params.textDocument.uri) 13 | if (!doc) return 14 | const wikilink = getWikiLinkUnderPos(params.position, doc) 15 | if (wikilink !== null) { 16 | try { 17 | const note = parseWikiLink(wikilink).note 18 | 19 | return { 20 | contents: { 21 | kind: MarkupKind.Markdown, 22 | value: `${note.content}`, 23 | }, 24 | } 25 | } catch (e) { 26 | const params = 27 | e instanceof NoteNotFoundError 28 | ? { 29 | type: 3, 30 | message: `file:\`${e.uri.fsPath}\` is not available yet.`, 31 | } 32 | : e instanceof WikiLinkBrokenError 33 | ? { 34 | type: 1, 35 | message: `Wikilink is broken.`, 36 | } 37 | : null 38 | if (params) 39 | connection.sendNotification("window/showMessage", params) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/handler/rename.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RenameParams, 3 | WorkspaceChange, 4 | WorkspaceEdit, 5 | } from "vscode-languageserver" 6 | import { stringify } from "gray-matter" 7 | import * as matter from "gray-matter" 8 | import { TextDocument } from "vscode-languageserver-textdocument" 9 | import { ObsidianNote } from "../common/vault" 10 | import { URI } from "vscode-uri" 11 | 12 | export function onRenameRequest(params: RenameParams) { 13 | return applyAlias({ 14 | note: new ObsidianNote(URI.parse(params.textDocument.uri)), 15 | alias: params.newName, 16 | }) 17 | } 18 | 19 | export function applyAlias({ 20 | note, 21 | alias, 22 | }: { 23 | note: ObsidianNote 24 | alias: string 25 | }): WorkspaceEdit { 26 | const change = new WorkspaceChange() 27 | 28 | const rawText = note.content 29 | const doc: TextDocument = TextDocument.create( 30 | note.uri.toString(), 31 | "markdown", 32 | 1, 33 | rawText 34 | ) 35 | const doc_parsed = matter(rawText) 36 | const frontmatter = doc_parsed.data 37 | const aliases = new Set(frontmatter["aliases"]) 38 | if ("title" in frontmatter) aliases.add(frontmatter["title"]) 39 | else frontmatter["title"] = alias 40 | aliases.add(alias) 41 | frontmatter["aliases"] = [...aliases].sort() 42 | change.getTextEditChange(doc.uri).replace( 43 | { 44 | start: doc.positionAt(0), 45 | end: doc.positionAt(rawText.length - doc_parsed.content.length), 46 | }, 47 | stringify("", frontmatter).slice(0, -1) // Remove the ending line break 48 | ) 49 | return change.edit 50 | } 51 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import { TextDocumentSyncKind } from "vscode-languageserver/node" 3 | 4 | import { connection } from "./common/connection" 5 | import { documents } from "./common/documents" 6 | 7 | import { onCompletion, onCompletionResolve } from "./handler/completion" 8 | import { onHover } from "./handler/hover" 9 | import { onDefinition } from "./handler/definition" 10 | import { updateObsidianNotes } from "./common/vault" 11 | import { validateWikiLinks } from "./handler/diagnostics" 12 | import { URI } from "vscode-uri" 13 | import { onRenameRequest } from "./handler/rename" 14 | import { onCodeAction } from "./handler/codeaction" 15 | 16 | connection.onInitialize(() => ({ 17 | capabilities: { 18 | textDocumentSync: TextDocumentSyncKind.Incremental, 19 | completionProvider: { 20 | resolveProvider: true, 21 | }, 22 | hoverProvider: true, 23 | definitionProvider: true, 24 | renameProvider: true, 25 | codeActionProvider: true, 26 | workspace: { 27 | workspaceFolders: { 28 | supported: true, 29 | }, 30 | }, 31 | }, 32 | })) 33 | 34 | connection.onInitialized(async () => { 35 | await updateObsidianNotes() 36 | documents.all().forEach(validateWikiLinks) 37 | }) 38 | 39 | connection.onCompletion(onCompletion) 40 | connection.onCompletionResolve(onCompletionResolve) 41 | connection.onDefinition(onDefinition) 42 | connection.onRenameRequest(onRenameRequest) 43 | connection.onHover(onHover) 44 | connection.onCodeAction(onCodeAction) 45 | documents.onDidChangeContent((change) => validateWikiLinks(change.document)) 46 | documents.onDidSave((change) => 47 | updateObsidianNotes(URI.parse(change.document.uri).fsPath) 48 | ) 49 | 50 | documents.listen(connection) 51 | connection.listen() 52 | -------------------------------------------------------------------------------- /src/handler/diagnostics.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver/node" 2 | import { TextDocument } from "vscode-languageserver-textdocument" 3 | 4 | import { connection } from "../common/connection" 5 | import { documents } from "../common/documents" 6 | import { 7 | WikiLinkBrokenError, 8 | parseWikiLink, 9 | } from "../common/vault" 10 | import { existsSync } from "fs" 11 | 12 | const MAX_PROBLEMS_COUNT = 1000 13 | 14 | export function validateWikiLinks(textDocument: TextDocument) { 15 | const text = textDocument.getText() 16 | let probrems = 0 17 | 18 | const diagnostics: Diagnostic[] = [] 19 | for (const m of text.matchAll(/\[\[[^\][]+?\]\]/gu)) { 20 | if (m.index === undefined) continue 21 | if (probrems++ >= MAX_PROBLEMS_COUNT) break 22 | try { 23 | const note = parseWikiLink(m[0]).note 24 | if ( 25 | // No editing nor file exists. 26 | !( 27 | documents.get(note.uri.toString()) || 28 | existsSync(note.uri.fsPath) 29 | ) 30 | ) { 31 | diagnostics.push({ 32 | severity: DiagnosticSeverity.Warning, 33 | range: { 34 | start: textDocument.positionAt(m.index), 35 | end: textDocument.positionAt(m.index + m[0].length), 36 | }, 37 | message: `File not found: ${note.uri}`, 38 | source: "obsidian", 39 | }) 40 | } 41 | } catch (e) { 42 | if (e instanceof WikiLinkBrokenError) 43 | diagnostics.push({ 44 | severity: DiagnosticSeverity.Error, 45 | range: { 46 | start: textDocument.positionAt(m.index), 47 | end: textDocument.positionAt(m.index + m[0].length), 48 | }, 49 | message: `\`${m[0]}\` is invalid link.`, 50 | source: "obsidian", 51 | }) 52 | } 53 | } 54 | connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) 55 | } 56 | -------------------------------------------------------------------------------- /src/handler/completion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CompletionItem, 3 | MarkupKind, 4 | TextDocumentPositionParams, 5 | } from "vscode-languageserver/node" 6 | import { 7 | Position, 8 | Range, 9 | TextDocument, 10 | } from "vscode-languageserver-textdocument" 11 | 12 | import { documents } from "../common/documents" 13 | import { ObsidianNoteCompletionItems } from "../common/vault" 14 | 15 | /** 16 | Returns a Range matching /\[{2}.*\]{0,2}/ around pos 17 | */ 18 | function getAroundBrackets( 19 | pos: Position, 20 | doc: TextDocument 21 | ): Range | undefined { 22 | const offset = doc.offsetAt(pos) 23 | const range = { 24 | start: { line: pos.line, character: 0 }, 25 | end: pos, 26 | } 27 | 28 | // Detect the last closing brackets to the left of pos 29 | let left_hand_side = doc.getText(range) 30 | const pos_be = left_hand_side.lastIndexOf("[[") 31 | if (pos_be === -1) return undefined 32 | left_hand_side = left_hand_side.slice(pos_be + 2) 33 | if (left_hand_side.includes("]]") || !/^\S+$/u.test(left_hand_side)) 34 | return undefined 35 | range.start = doc.positionAt(doc.offsetAt(range.start) + pos_be) 36 | 37 | /** 38 | * The 2 characters immediately after the cursor 39 | */ 40 | const ending_chars = doc.getText({ 41 | start: doc.positionAt(offset), 42 | end: doc.positionAt(offset + 2), 43 | }) 44 | let ending_length = 0 45 | 46 | if (ending_chars.at(0) === "]") { 47 | if (ending_chars.at(1) === "]") ending_length = 2 48 | else ending_length = 1 49 | } 50 | range.end = doc.positionAt(doc.offsetAt(range.end) + ending_length) 51 | 52 | return range 53 | } 54 | 55 | export function onCompletion( 56 | textDocumentPosition: TextDocumentPositionParams 57 | ): CompletionItem[] { 58 | const doc = documents.get(textDocumentPosition.textDocument.uri) 59 | if (doc === undefined) return [] 60 | 61 | // check cursor in brackets 62 | const range = getAroundBrackets(textDocumentPosition.position, doc) 63 | if (undefined === range) return [] 64 | 65 | return ObsidianNoteCompletionItems.map((item) => ({ 66 | ...item, 67 | textEdit: { 68 | range, 69 | newText: item.label, 70 | }, 71 | })) 72 | } 73 | 74 | export function onCompletionResolve(item: CompletionItem): CompletionItem { 75 | item.documentation = { 76 | kind: MarkupKind.Markdown, 77 | value: item.data, 78 | } 79 | return item 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # obsidian-lsp : Language Server for Obsidian.md 2 | 3 | ## Development has stalled 4 | 5 | Updates have been delayed due to lack of time allocated for development. Furthermore, I have no plans to make this project more functional. 6 | If you are looking for something more versatile, I recommend [markdown-oxide](https://github.com/Feel-ix-343/markdown-oxide). 7 | 8 | --- 9 | 10 | ![Screen record](https://github.com/gw31415/obsidian-lsp/assets/24710985/be3e8a1b-230a-4af0-9a0a-ea2e747eed35) 11 | 12 | ## Motivation 13 | 14 | [Obsidian.md](https://obsidian.md/) is a fantastic tool that enables you to 15 | create your own Wiki using Markdown. It's not only convenient but also boasts an 16 | iOS app that makes viewing easy. However, my goal was to further enhance this 17 | experience by allowing the use of any text editor like Neovim. The need for such 18 | flexibility is what led me to the development of this LSP server for 19 | Obsidian.md. It aims to make editing your Obsidian notes more efficient and 20 | flexible, all in your editor of choice. 21 | 22 | ## Features 23 | 24 | The Obsidian.md LSP server provides the following main features: 25 | 26 | - [x] `textDocument/completion`: Provides search within the Vault and 27 | autocompletion of links, enabling efficient navigation within your wiki. 28 | 29 | - [x] `textDocument/codeAction`: If the alias on WikiLink is not listed in the 30 | alias settings in the document's frontmatter, add the string into the alias 31 | entry in the document's frontmatter. 32 | 33 | - [x] `textDocument/publishDiagnostics`: Detects and alerts you of broken or 34 | empty links, ensuring the consistency and integrity of your wiki. 35 | 36 | - [x] `textDocument/definition`: Allows you to jump directly to a page from 37 | its link, aiding swift exploration within your wiki. 38 | 39 | - [x] `textDocument/hover`: Displays the content of the linked article in a 40 | hover-over preview, saving you the need to follow the link. 41 | 42 | - [x] `textDocument/rename`: When Rename is performed on a document being edited, 43 | the string of the renamed symbol is added to the alias. If the title has not 44 | been set, it will also be set to the title of the document. 45 | 46 | - [ ] `textDocument/references`: (Will) display a list of all articles that 47 | contain a link to a specific article, helping you understand the context and 48 | relationships of your notes. This feature is currently under development. 49 | 50 | - [ ] `workspace/symbol`: (Will) enable searching for symbols across the 51 | entire workspace, helping you quickly locate specific topics or keywords. 52 | This feature is currently under development. 53 | 54 | The Obsidian.md LSP server makes your Obsidian usage more potent and efficient. 55 | You can edit your Obsidian Wiki in your preferred editor, maximising its 56 | potential. 57 | 58 | ## How to use? 59 | 60 | This is not a plugin itself and does not provide each function directly to the 61 | editor. If you still want to try it, you can access each function with the 62 | following settings. 63 | 64 | ### Neovim 65 | 66 | ```lua 67 | vim.api.nvim_create_autocmd("BufRead", { 68 | pattern = "*.md", 69 | callback = function() 70 | local lspconfig = require('lspconfig') 71 | local configs = require('lspconfig.configs') 72 | if not configs.obsidian then 73 | configs.obsidian = { 74 | default_config = { 75 | cmd = { "npx", "obsidian-lsp", "--", "--stdio" }, 76 | single_file_support = false, 77 | root_dir = lspconfig.util.root_pattern ".obsidian", 78 | filetypes = { 'markdown' }, 79 | }, 80 | } 81 | end 82 | lspconfig.obsidian.setup {} 83 | end, 84 | }) 85 | ``` 86 | 87 | ## Related Projects 88 | 89 | - [markdown-oxide](https://github.com/Feel-ix-343/markdown-oxide) : Better-updated 90 | LSP for obsidian markdown system. I recommend to use it. 91 | - [obsidian.nvim](https://github.com/epwalsh/obsidian.nvim) : The Neovim 92 | plugin that inspired this project 93 | -------------------------------------------------------------------------------- /src/common/vault.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, readdirSync } from "fs" 2 | import { stat } from "fs/promises" 3 | import { basename, join, resolve } from "path" 4 | import { URI } from "vscode-uri" 5 | import { Position, TextDocument } from "vscode-languageserver-textdocument" 6 | import { connection } from "./connection" 7 | import { documents } from "./documents" 8 | import { CompletionItem, CompletionItemKind } from "vscode-languageserver/node" 9 | import * as matter from "gray-matter" 10 | 11 | let obsidianVault: string | null = null 12 | 13 | /** 14 | An error object when ObsidianNotes is not ready. 15 | */ 16 | export class VaultIsNotReadyError extends Error { 17 | constructor(args?: string) { 18 | super(args) 19 | Object.defineProperty(this, "name", { 20 | configurable: true, 21 | enumerable: false, 22 | value: this.constructor.name, 23 | writable: true, 24 | }) 25 | if (Error.captureStackTrace) 26 | Error.captureStackTrace(this, NoteNotFoundError) 27 | } 28 | } 29 | 30 | /** 31 | An error object when Obsidian Note is not available or not found. 32 | */ 33 | export class NoteNotFoundError extends Error { 34 | constructor(public uri: URI, args?: string) { 35 | super(args) 36 | Object.defineProperty(this, "name", { 37 | configurable: true, 38 | enumerable: false, 39 | value: this.constructor.name, 40 | writable: true, 41 | }) 42 | if (Error.captureStackTrace) 43 | Error.captureStackTrace(this, NoteNotFoundError) 44 | } 45 | } 46 | 47 | /** 48 | Class representing Obsidian documents. 49 | */ 50 | export class ObsidianNote { 51 | constructor(readonly uri: URI) {} 52 | /** 53 | Get the content of Obsidian note. 54 | Throws NoteNotFoundError if the file is not found. 55 | */ 56 | get content() { 57 | const doc = documents.get(this.uri.toString()) 58 | if (doc) return doc.getText() 59 | try { 60 | return readFileSync(this.uri.fsPath).toString() 61 | } catch { 62 | throw new NoteNotFoundError(this.uri) 63 | } 64 | } 65 | /** 66 | Get wikilink string refer to this instance. 67 | */ 68 | getWikiLink(label?: string) { 69 | const path = this.uri.fsPath.toString() 70 | const name = basename(path).slice(0, -3) 71 | return `[[${name}${label !== undefined ? `|${label}` : ""}]]` 72 | } 73 | } 74 | 75 | /** 76 | Returns the inner string matching /\[{2}.*\]{0,2}/ around pos 77 | @param pos The cursor position. 78 | @param doc The document to refer to. 79 | */ 80 | export function getWikiLinkUnderPos( 81 | pos: Position, 82 | doc: TextDocument 83 | ): string | null { 84 | // Move pos to be inside of [[ ]]. 85 | const getchar = (pos: Position): string => 86 | doc.getText({ 87 | start: pos, 88 | end: { line: pos.line, character: pos.character + 1 }, 89 | }) 90 | if (getchar(pos) === "[") 91 | pos = { line: pos.line, character: pos.character + 1 } 92 | if (getchar(pos) === "[") 93 | pos = { line: pos.line, character: pos.character + 1 } 94 | if (getchar(pos) === "]") 95 | pos = { line: pos.line, character: pos.character - 1 } 96 | if (getchar(pos) === "]") 97 | pos = { line: pos.line, character: pos.character - 1 } 98 | 99 | // Detect the last closing brackets to the left of pos 100 | let left_hand_side = doc.getText({ 101 | start: { line: pos.line, character: 0 }, 102 | end: pos, 103 | }) 104 | const pos_be = left_hand_side.lastIndexOf("[[") 105 | if (pos_be === -1) return null 106 | left_hand_side = left_hand_side.slice(pos_be) 107 | if (left_hand_side.includes("]]")) return null 108 | 109 | // Detect the last closing brackets to the left of pos 110 | let right_hand_side = doc.getText({ 111 | start: pos, 112 | end: doc.positionAt( 113 | doc.offsetAt({ line: pos.line + 1, character: 0 }) - 1 114 | ), 115 | }) 116 | const pos_en = right_hand_side.indexOf("]]") 117 | if (pos_en === -1) return null 118 | right_hand_side = right_hand_side.slice(0, pos_en + 2) 119 | if (right_hand_side.includes("[[")) return null 120 | 121 | return left_hand_side + right_hand_side 122 | } 123 | 124 | /** 125 | An error object if the wikilink received is syntactically broken. 126 | */ 127 | export class WikiLinkBrokenError extends Error { 128 | constructor(public broken_link: string, args?: string) { 129 | super(args) 130 | Object.defineProperty(this, "name", { 131 | configurable: true, 132 | enumerable: false, 133 | value: this.constructor.name, 134 | writable: true, 135 | }) 136 | if (Error.captureStackTrace) 137 | Error.captureStackTrace(this, NoteNotFoundError) 138 | } 139 | } 140 | 141 | /** 142 | convert from WikiLink string to ObsidianNote instance. 143 | Throws WikiLinkBrokenError if link is syntactically broken. 144 | Run updateObsidianNotes() before call it in order to look-up notes, 145 | or throws VaultIsNotReadyError. 146 | @param link wikilink string to convert. 147 | */ 148 | export function parseWikiLink(link: string): { 149 | note: ObsidianNote 150 | alias: string | undefined 151 | } { 152 | if (!obsidianVault) throw new VaultIsNotReadyError() 153 | if (!/^\[\[[^\][]+\]\]$/.test(link)) throw new WikiLinkBrokenError(link) 154 | const innerText = link.slice(2, -2) 155 | if (!innerText.includes("|")) { 156 | return { 157 | note: new ObsidianNote( 158 | URI.file(resolve(join(obsidianVault, `${innerText}.md`))) 159 | ), 160 | alias: undefined, 161 | } 162 | } 163 | const split = innerText.split("|") 164 | if (split.length !== 2) throw new WikiLinkBrokenError(link) 165 | return { 166 | note: new ObsidianNote( 167 | URI.file(resolve(join(obsidianVault, `${split[0]}.md`))) 168 | ), 169 | alias: split[1], 170 | } 171 | } 172 | 173 | /** 174 | All obsidian markdown documents in the workspace. 175 | */ 176 | export const ObsidianNoteUrls: Set = new Set() 177 | export let ObsidianNoteCompletionItems: CompletionItem[] = [] 178 | 179 | /** 180 | Reload ObsidianNotes scanning the workspace. 181 | */ 182 | export async function updateObsidianNotes(...paths: string[]) { 183 | /** 184 | A function that recursively searches for .md files. 185 | @param dirPath Path to search 186 | */ 187 | function rec_getmds(dirPath: string) { 188 | const allDirents = readdirSync(dirPath, { withFileTypes: true }) 189 | for (const dirent of allDirents) { 190 | if (dirent.isDirectory()) { 191 | rec_getmds(join(dirPath, dirent.name)) 192 | } else if (dirent.isFile() && dirent.name.slice(-3) === ".md") { 193 | ObsidianNoteUrls.add( 194 | URI.file(resolve(join(dirPath, dirent.name))).toString() 195 | ) 196 | ObsidianNoteCompletionItems = [...ObsidianNoteUrls].flatMap( 197 | (uri) => { 198 | const note = new ObsidianNote(URI.parse(uri)) 199 | const content = note.content // no throws because note is auto generated. 200 | const parsed = matter(content) 201 | const aliases: Set = new Set( 202 | parsed.data.aliases 203 | ) 204 | if ("title" in parsed.data) 205 | aliases.add(parsed.data.title) 206 | aliases.add(undefined) 207 | 208 | return [...aliases].map((alias) => ({ 209 | data: parsed.content, 210 | label: note.getWikiLink(alias), 211 | kind: CompletionItemKind.Reference, 212 | })) 213 | } 214 | ) 215 | } 216 | } 217 | } 218 | if (paths.length === 0) { 219 | await connection.workspace 220 | .getWorkspaceFolders() 221 | .then((workspaceFolders) => { 222 | if ( 223 | workspaceFolders === null || 224 | workspaceFolders === undefined 225 | ) { 226 | connection 227 | .sendNotification("window/showMessage", { 228 | type: 1, 229 | message: 230 | "Please specify the workspace to detect Obsidian Vault.", 231 | }) 232 | .then(() => { 233 | process.exit(1) 234 | }) 235 | } else if (workspaceFolders.length !== 1) { 236 | connection 237 | .sendNotification("window/showMessage", { 238 | type: 1, 239 | message: "Only one workspace is allowed.", 240 | }) 241 | .then(() => { 242 | process.exit(1) 243 | }) 244 | } else { 245 | obsidianVault = resolve( 246 | URI.parse(workspaceFolders[0].uri).fsPath 247 | ) 248 | } 249 | }) 250 | if (!obsidianVault) throw new VaultIsNotReadyError() 251 | rec_getmds(obsidianVault) 252 | } else { 253 | for (const path of paths) { 254 | const dirent = await stat(path) 255 | if (dirent.isDirectory()) { 256 | rec_getmds(path) 257 | } else if (dirent.isFile() && path.slice(-3) === ".md") { 258 | ObsidianNoteUrls.add(URI.file(resolve(path)).toString()) 259 | ObsidianNoteCompletionItems = [...ObsidianNoteUrls].flatMap( 260 | (uri) => { 261 | const note = new ObsidianNote(URI.parse(uri)) 262 | const content = note.content // no throws because note is auto generated. 263 | const parsed = matter(content) 264 | const aliases: Set = new Set( 265 | parsed.data.aliases 266 | ) 267 | if ("title" in parsed.data) 268 | aliases.add(parsed.data.title) 269 | aliases.add(undefined) 270 | 271 | return [...aliases].map((alias) => ({ 272 | data: parsed.content, 273 | label: note.getWikiLink(alias), 274 | kind: CompletionItemKind.Reference, 275 | })) 276 | } 277 | ) 278 | } 279 | } 280 | } 281 | } 282 | --------------------------------------------------------------------------------