Skip to content

Add Document Links support (textDocument/documentLink) #753

@polarmutex

Description

@polarmutex

Feature Request: Document Links (LSP 3.17)

Description

Implement textDocument/documentLink capability to make file paths, URLs, and document references clickable within beancount files.

Use Cases

Receipt and Document File Links

Make file paths in metadata clickable:

2024-01-15 * "Office Supplies" ^receipt-12345
  Expenses:Office:Supplies    45.23 USD
  Assets:Bank:Checking
  receipt: "/path/to/receipts/2024-01-15-office-supplies.pdf"  ← Clickable link
  invoice: "file:///documents/invoices/INV-2024-001.pdf"        ← Clickable link

Clicking opens the file in default application or editor.

External URLs in Comments and Metadata

Make URLs clickable:

2024-01-15 * "Domain Renewal"
  Expenses:Web:DomainNames    12.99 USD
  Assets:Bank:Checking
  url: "https://example.com/receipt/12345"                    ← Clickable link
  
; Reference: https://docs.beancount.org/                       ← Clickable link

Include Directive File Paths

Make included file paths navigable:

include "accounts.beancount"      ← Click to open file
include "prices.beancount"        ← Click to open file
include "2024/january.bean"       ← Click to open file

Relative File References

Support relative paths from current file:

2024-01-15 * "Expense Report"
  Expenses:Travel:Hotel       150.00 USD
  Assets:Bank:Checking
  attachment: "../receipts/hotel-2024-01-15.pdf"              ← Clickable relative path
  report: "./reports/travel-january-2024.xlsx"                ← Clickable relative path

Account Documentation Links

Link to account planning documents:

2020-01-01 open Assets:Investment:Brokerage
  description: "Main investment account"
  documentation: "https://wiki.example.com/accounts/brokerage" ← Clickable link
  policy: "/documents/investment-policy.pdf"                   ← Clickable link

Implementation Details

File: crates/lsp/src/providers/document_link.rs

Pattern:

pub fn document_links(
    snapshot: LspServerStateSnapshot,
    params: DocumentLinkParams,
) -> Result<Option<Vec<DocumentLink>>> {
    let tree = snapshot.forest.get(&params.text_document.uri)?;
    let content = snapshot.open_docs.get(&params.text_document.uri)?.content.clone();
    let mut links = vec![];
    
    // 1. Find include directives
    for include_node in find_includes(&tree) {
        let path = extract_string_value(&include_node, &content);
        if let Some(resolved_uri) = resolve_include_path(&path, &params.text_document.uri) {
            links.push(DocumentLink {
                range: node_to_range(&include_node),
                target: Some(resolved_uri),
                tooltip: Some(format!("Open {}", path)),
                data: None,
            });
        }
    }
    
    // 2. Find metadata with file paths
    for metadata_node in find_metadata_with_files(&tree) {
        let value = extract_metadata_value(&metadata_node, &content);
        
        if is_file_path(&value) {
            if let Some(uri) = resolve_file_path(&value, &params.text_document.uri) {
                links.push(DocumentLink {
                    range: metadata_value_range(&metadata_node),
                    target: Some(uri),
                    tooltip: Some(format!("Open file: {}", value)),
                    data: None,
                });
            }
        } else if is_url(&value) {
            if let Ok(uri) = Url::parse(&value) {
                links.push(DocumentLink {
                    range: metadata_value_range(&metadata_node),
                    target: Some(uri),
                    tooltip: Some(format!("Open URL: {}", value)),
                    data: None,
                });
            }
        }
    }
    
    // 3. Find URLs in comments
    for comment_node in find_comments(&tree) {
        let comment_text = extract_comment_text(&comment_node, &content);
        for url in extract_urls_from_text(&comment_text) {
            if let Ok(uri) = Url::parse(&url) {
                links.push(DocumentLink {
                    range: calculate_url_range(&comment_node, &url, &comment_text),
                    target: Some(uri),
                    tooltip: Some(format!("Open URL: {}", url)),
                    data: None,
                });
            }
        }
    }
    
    Ok(Some(links))
}

fn is_file_path(value: &str) -> bool {
    // Check if string looks like a file path
    value.starts_with('/') 
        || value.starts_with("./") 
        || value.starts_with("../")
        || value.starts_with("file://")
        || value.contains("\\")  // Windows paths
        || value.ends_with(".pdf") 
        || value.ends_with(".png")
        || value.ends_with(".jpg")
        || value.ends_with(".xlsx")
        // etc.
}

fn is_url(value: &str) -> bool {
    value.starts_with("http://") 
        || value.starts_with("https://")
        || value.starts_with("ftp://")
}

fn resolve_file_path(path: &str, base_uri: &Url) -> Option<Url> {
    // Handle absolute paths
    if path.starts_with('/') {
        return Url::from_file_path(path).ok();
    }
    
    // Handle relative paths
    if let Some(base_path) = base_uri.to_file_path().ok() {
        let base_dir = base_path.parent()?;
        let resolved = base_dir.join(path);
        return Url::from_file_path(resolved).ok();
    }
    
    None
}

fn resolve_include_path(include_path: &str, base_uri: &Url) -> Option<Url> {
    // Resolve include paths relative to current file
    resolve_file_path(include_path, base_uri)
}

Capability Registration

Update crates/lsp/src/capabilities.rs:

document_link_provider: Some(DocumentLinkOptions {
    resolve_provider: Some(false),
    work_done_progress_options: WorkDoneProgressOptions {
        work_done_progress: None,
    },
}),

Link Detection Patterns

Pattern Example Link Type
Include directive include "file.bean" Beancount file
Absolute path /path/to/receipt.pdf Local file
Relative path ./receipts/file.pdf Local file
file:// URI file:///documents/file.pdf Local file
HTTP(S) URL https://example.com Web URL
Metadata values receipt: "file.pdf" Local file
Comment URLs ; See https://... Web URL

Priority

MEDIUM - Convenient feature for accessing external documents and receipts

Configuration Options (Future)

{
  "beancountLanguageServer": {
    "documentLinks": {
      "enableIncludeLinks": true,
      "enableMetadataFileLinks": true,
      "enableCommentUrls": true,
      "enableReceiptLinks": true,
      "receiptBasePath": "/home/user/documents/receipts",
      "supportedFileExtensions": [".pdf", ".png", ".jpg", ".xlsx", ".csv"]
    }
  }
}

User Experience

  • Links should be visually distinct (underlined or colored)
  • Ctrl+Click (or Cmd+Click on Mac) to follow link
  • Tooltip shows target path/URL on hover
  • Support opening in external application or editor
  • Handle missing files gracefully (show error message)

Platform Considerations

  • Path separators: Handle both (Unix) and (Windows)
  • File URI encoding: Properly encode special characters
  • Relative path resolution: Resolve relative to current file's directory
  • Home directory expansion: Support expansion on Unix
  • Case sensitivity: Consider platform differences

Enhancement Ideas

  • Receipt organization: Suggest receipt file naming conventions
  • Auto-create links: Code action to browse for file and insert link
  • Link validation: Warn if linked file doesn't exist
  • Receipt preview: Show thumbnail preview on hover (if client supports)
  • Cloud storage: Support Dropbox, Google Drive, OneDrive links
  • Smart path completion: Autocomplete file paths in metadata

Dependencies

  • Path resolution utilities
  • URL parsing (using url crate)
  • File system access to verify paths exist (optional)

Error Handling

  • Invalid URLs → Skip, don't create link
  • Missing files → Create link anyway (editor will show error on click)
  • Permission denied → Create link (let OS handle permission errors)
  • Malformed paths → Skip

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions