feat: add streamd lsp subcommand with LSP server #90

Merged
kfickel merged 12 commits from 89_streamd-lsp-subcommand into main 2026-04-19 21:18:45 +02:00
3 changed files with 129 additions and 6 deletions
Showing only changes of commit d0316e8dac - Show all commits

View file

@ -69,6 +69,7 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de
- `streamd todo --show-future` — Include tasks with future dates in the listing - `streamd todo --show-future` — Include tasks with future dates in the listing
- `streamd edit [number]` — Edit a stream file by index (most recent first) - `streamd edit [number]` — Edit a stream file by index (most recent first)
- `streamd timesheet` — Generate time reports from `@Timesheet` markers - `streamd timesheet` — Generate time reports from `@Timesheet` markers
- `streamd lsp` — Start the LSP server (stdin/stdout transport; see [Editor Integration](#editor-integration) below)
## Configuration ## Configuration
@ -110,3 +111,68 @@ Running `streamd todo` finds all shards marked as open tasks and displays them n
You can quickly edit or complete tasks by number: You can quickly edit or complete tasks by number:
- `streamd todo 1 edit` opens task 1 in your editor at the correct line - `streamd todo 1 edit` opens task 1 in your editor at the correct line
- `streamd todo 1 done` marks task 1 as done by inserting `@Done` after `@Task` - `streamd todo 1 done` marks task 1 as done by inserting `@Done` after `@Task`
## Editor Integration
`streamd lsp` starts a Language Server Protocol server that provides IDE features for your stream markdown files. The server communicates over **stdin/stdout** and auto-activates only when a `.streamd.toml` file is present in the workspace root.
### Features
| Feature | Description |
|---|---|
| `@` completions | Suggests known markers from your config; conditional suggestions (e.g. `@Done` when `@Task` is on the line) |
| Temporal snippets | `@` followed by a digit offers `YYYYMMDD` / `HHMMSS` format snippets |
| Diagnostics | File-name format warnings (R15); timesheet errors (overlapping timecards, unclosed days) |
| Document symbols | Shard tree exposed as outline symbols |
| "Mark task as done" | Quick-fix code action: inserts `@Done` after `@Task` |
| Workspace symbols | Search shards across all `.md` files |
| References | Find all occurrences of an `@Marker` across the workspace |
| Rename | Rename an `@Marker` across all files |
### Zed
Add to `~/.config/zed/settings.json`:
```json
{
"languages": {
"Markdown": {
"language_servers": ["streamd-lsp", "..."]
}
},
"lsp": {
"streamd-lsp": {
"binary": {
"path": "streamd",
"arguments": ["lsp"]
}
}
}
}
```
The `"..."` keeps Zed's default Markdown servers (e.g. `marksman`) active alongside streamd.
### Neovim (nvim-lspconfig)
```lua
local lspconfig = require('lspconfig')
local configs = require('lspconfig.configs')
if not configs.streamd then
configs.streamd = {
default_config = {
cmd = { 'streamd', 'lsp' },
filetypes = { 'markdown' },
root_dir = lspconfig.util.root_pattern('.streamd.toml'),
single_file_support = false,
},
}
end
lspconfig.streamd.setup {}
```
### VS Code (tasks.json / manual)
Use any extension that lets you configure custom LSP servers, pointing `cmd` to `streamd lsp`.

View file

@ -400,6 +400,7 @@ Provide recursive search through the shard tree:
| `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) | | `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) |
| `streamd timesheet` | Generate formatted timesheet report with expected/actual hours | | `streamd timesheet` | Generate formatted timesheet report with expected/actual hours |
| `streamd completions <shell>` | Generate shell completions (bash, zsh, fish, elvish, powershell) | | `streamd completions <shell>` | Generate shell completions (bash, zsh, fish, elvish, powershell) |
| `streamd lsp` | Start Language Server Protocol server over stdin/stdout |
### R21a: Daily Command Behavior ### R21a: Daily Command Behavior
@ -471,3 +472,53 @@ Multiple configurations can be merged:
- Dimensions are combined (later configs can add new dimensions) - Dimensions are combined (later configs can add new dimensions)
- Markers are combined (later configs can add new markers) - Markers are combined (later configs can add new markers)
- This allows base configuration + domain-specific extensions - This allows base configuration + domain-specific extensions
---
## LSP Server
### R25: LSP Subcommand
`streamd lsp` starts a Language Server Protocol server over stdin/stdout.
**Workspace root resolution:**
- The base folder is taken from `initializeParams.rootUri` (or `rootPath` as fallback).
- R22/R23 global config resolution is bypassed in LSP mode.
**Passive mode:**
- If `.streamd.toml` is absent from the workspace root, the server enters passive mode: all requests return empty results and no diagnostics are published.
**Config watching:**
- The server registers a `workspace/didChangeWatchedFiles` watcher for `.streamd.toml`.
- Config is reloaded without restarting the server when `.streamd.toml` changes.
**Document sync:**
- Full-document sync (`TextDocumentSyncKind::FULL`).
- Re-parses on `didOpen`, `didChange`, and `didSave`.
### R25a: LSP Completion
- Trigger character: `@`
- Returns marker names from the merged config (BasicTimesheetConfiguration + TaskConfiguration).
- Conditional suggestions: if marker A is on the line and A has placements with `if_with: {B}`, B is offered with higher priority.
- Temporal snippets: `@` followed by a digit offers `YYYYMMDD` and `HHMMSS` format snippets (R16).
### R25b: LSP Diagnostics
- **File-name format (R15)**: Warning when the file basename does not match `^(\d{8})(?:-(\d{4,6}))?.+\.md$`.
- **Timesheet violations (R18)**: Error when a day ends without a break; Warning for overlapping timecards.
### R25c: LSP Document Symbols
- Returns the `LocalizedShard` tree as nested `DocumentSymbol` nodes.
- Symbol names are derived from marker names or tag names.
### R25d: LSP Code Actions
- "Mark task as done": offered on any line containing `@Task` without `@Done`; inserts ` @Done` after `@Task`.
### R25e: LSP Cross-file Features
- `workspace/symbol`: searches all `.md` files in base folder (depth 1) for shards matching the query.
- `textDocument/references`: finds all occurrences of the `@Marker` under the cursor across the workspace.
- `textDocument/rename`: renames an `@Marker` across all files via `WorkspaceEdit`.

View file

@ -418,7 +418,7 @@ impl Backend {
.unwrap_or(chrono_tz::UTC); .unwrap_or(chrono_tz::UTC);
let config = let config =
merge_repository_configuration(&*BasicTimesheetConfiguration, &*TaskConfiguration); merge_repository_configuration(&BasicTimesheetConfiguration, &TaskConfiguration);
Some(Arc::new(LspState { Some(Arc::new(LspState {
config, config,
@ -437,6 +437,7 @@ impl Backend {
#[tower_lsp::async_trait] #[tower_lsp::async_trait]
impl LanguageServer for Backend { impl LanguageServer for Backend {
async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> { async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
#[allow(deprecated)]
let root: Option<PathBuf> = params let root: Option<PathBuf> = params
.root_uri .root_uri
.as_ref() .as_ref()
@ -691,8 +692,13 @@ impl LanguageServer for Backend {
let start_line = params.range.start.line as usize; let start_line = params.range.start.line as usize;
let end_line = params.range.end.line as usize; let end_line = params.range.end.line as usize;
for line_idx in start_line..=end_line.min(lines.len().saturating_sub(1)) { let range_end = end_line.min(lines.len().saturating_sub(1));
let line = &lines[line_idx]; for (line_idx, line) in lines
.iter()
.enumerate()
.skip(start_line)
.take(range_end.saturating_sub(start_line) + 1)
{
if line.contains("@Task") && !line.contains("@Done") { if line.contains("@Task") && !line.contains("@Done") {
let new_line = line.replacen("@Task", "@Task @Done", 1); let new_line = line.replacen("@Task", "@Task @Done", 1);
let mut changes: std::collections::HashMap<Url, Vec<TextEdit>> = let mut changes: std::collections::HashMap<Url, Vec<TextEdit>> =
@ -745,7 +751,7 @@ impl LanguageServer for Backend {
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ {
let path = entry.path(); let path = entry.path();
if !path.extension().map_or(false, |e| e == "md") { if path.extension().is_none_or(|e| e != "md") {
continue; continue;
} }
let uri = match Url::from_file_path(path) { let uri = match Url::from_file_path(path) {
@ -809,7 +815,7 @@ impl LanguageServer for Backend {
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ {
let path = entry.path(); let path = entry.path();
if !path.extension().map_or(false, |e| e == "md") { if path.extension().is_none_or(|e| e != "md") {
continue; continue;
} }
let file_uri = match Url::from_file_path(path) { let file_uri = match Url::from_file_path(path) {
@ -893,7 +899,7 @@ impl LanguageServer for Backend {
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ {
let path = entry.path(); let path = entry.path();
if !path.extension().map_or(false, |e| e == "md") { if path.extension().is_none_or(|e| e != "md") {
continue; continue;
} }
let file_uri = match Url::from_file_path(path) { let file_uri = match Url::from_file_path(path) {