feat: add streamd lsp subcommand with LSP server #90
3 changed files with 129 additions and 6 deletions
66
README.md
66
README.md
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue