harn-lsp 0.10.85

Language Server Protocol implementation for Harn
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! Harn language server.
//!
//! Exposed as a library so the single multi-call `harn` binary can dispatch
//! into the LSP server when launched under the `harn-lsp` name (see
//! `harn-cli`'s `main`), instead of shipping a second fully-linked binary.
//! The thin `src/main.rs` shim keeps `harn-lsp` buildable as its own binary
//! for local development (`cargo run -p harn-lsp`).

mod call_hierarchy;
mod constants;
mod document;
mod document_kind;
mod folding;
mod handlers;
mod helpers;
mod prompt;
mod references;
mod rules;
mod semantic_tokens;
mod source_text;
mod symbols;

use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;

use document::DocumentState;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LspService, Server};

pub(crate) struct HarnLsp {
    client: Client,
    documents: Mutex<HashMap<Url, DocumentState>>,
    pending_reparse_versions: Mutex<HashMap<Url, u64>>,
    rule_workspace: Mutex<rules::RuleWorkspace>,
    /// Whether the client advertised dynamic-registration support for
    /// `workspace/didChangeWatchedFiles` in its `initialize` capabilities.
    /// When true we register a `**/*.harn` file watcher in `initialized`
    /// so external edits (git checkout, another tool) re-validate open docs.
    watched_files_dynamic_registration: AtomicBool,
}

impl HarnLsp {
    fn new(client: Client) -> Self {
        Self {
            client,
            documents: Mutex::new(HashMap::new()),
            pending_reparse_versions: Mutex::new(HashMap::new()),
            rule_workspace: Mutex::new(rules::RuleWorkspace::default()),
            watched_files_dynamic_registration: AtomicBool::new(false),
        }
    }
}

/// Run the Harn language server over stdio until the client disconnects.
///
/// Builds a multi-threaded Tokio runtime and blocks on it, mirroring the
/// `#[tokio::main]` the standalone binary used. Called by the `harn-lsp`
/// binary shim and by the `harn` multi-call binary when invoked as `harn-lsp`.
pub fn run() {
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("failed to build Tokio runtime for harn-lsp");
    runtime.block_on(serve());
}

async fn serve() {
    // Defeat rlib dead-code stripping of the linkme distributed slice
    // (linkme issue #36) before reading `all_builtin_manifest()`.
    harn_vm::stdlib::force_link();
    // Install the macro-emitted builtin signature slice into the parser
    // registry so hover/completion/diagnostics see the full builtin set.
    harn_parser::install_builtin_manifest(harn_vm::stdlib::all_builtin_manifest());

    let stdin = tokio::io::stdin();
    let stdout = tokio::io::stdout();

    let (service, socket) = LspService::new(HarnLsp::new);

    Server::new(stdin, stdout, socket).serve(service).await;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::handlers::hover::{resolve_hover_target, HoverTarget};
    use crate::helpers::word_at_position;
    use crate::source_text::SourceText;
    use crate::symbols::HarnSymbolKind;

    /// Resolve the hover target at the given 0-based line/column, through the
    /// real handler code.
    ///
    /// Hover tests call `resolve_hover_target` so they exercise the same
    /// builtin/keyword/symbol ordering as the LSP handler.
    fn hover_target_at(source: &str, line: u32, col: u32) -> Option<HoverTarget> {
        let state = DocumentState::new(source.to_string());
        resolve_hover_target(&state.source, &state.symbols, Position::new(line, col))
    }

    /// The hover symbol at the given position, asserting the word under the
    /// cursor is what the caller thinks it is.
    fn hover_symbol_at(
        source: &str,
        line: u32,
        col: u32,
        word: &str,
    ) -> Option<symbols::SymbolInfo> {
        let extracted = word_at_position(&SourceText::new(source), Position::new(line, col));
        assert_eq!(
            extracted.as_deref(),
            Some(word),
            "word_at_position mismatch"
        );

        match hover_target_at(source, line, col) {
            Some(HoverTarget::Symbol(sym)) => Some(*sym),
            _ => None,
        }
    }

    #[test]
    fn hover_top_level_fn() {
        let source = "fn greet(name: string) -> string {\n  return \"Hello, \" + name\n}\n\nlet result = greet(\"World\")\n";
        let sym = hover_symbol_at(source, 4, 14, "greet").expect("should find greet");
        assert_eq!(sym.kind, HarnSymbolKind::Function);
        assert_eq!(
            sym.signature.as_deref(),
            Some("fn greet(name: string) -> string")
        );
        assert!(sym.scope_span.is_none(), "top-level fn has no scope_span");
        assert!(sym.impl_type.is_none());
    }

    #[test]
    fn hover_fn_with_default_param() {
        let source =
            "fn greet(name: string = \"World\") -> string {\n  return \"Hello, \" + name\n}\n";
        let state = DocumentState::new(source.to_string());
        let fn_sym = state
            .symbols
            .iter()
            .find(|s| s.name == "greet" && s.kind == HarnSymbolKind::Function)
            .expect("should find greet");
        assert_eq!(
            fn_sym.signature.as_deref(),
            Some("fn greet(name: string = \"World\") -> string")
        );
    }

    #[test]
    fn hover_fn_with_doc_comment() {
        let source = "/// Greets a person by name.\n/// Returns a greeting string.\nfn greet(name: string) -> string {\n  return \"Hello, \" + name\n}\n";
        let state = DocumentState::new(source.to_string());
        let fn_sym = state
            .symbols
            .iter()
            .find(|s| s.name == "greet" && s.kind == HarnSymbolKind::Function)
            .expect("should find greet");
        assert_eq!(
            fn_sym.doc_comment.as_deref(),
            Some("Greets a person by name.\nReturns a greeting string.")
        );
    }

    #[test]
    fn hover_fn_exposes_stdlib_metadata_block() {
        let source = "\
/**
 * Read a file as UTF-8.
 *
 * @effects: [fs.read]
 * @errors: [FileNotFound, PermissionDenied]
 * @example: const s = read_to_string(\"/tmp/x\")
 */
fn read_to_string(path: string) -> string {
  return \"\"
}
";
        let state = DocumentState::new(source.to_string());
        let fn_sym = state
            .symbols
            .iter()
            .find(|s| s.name == "read_to_string" && s.kind == HarnSymbolKind::Function)
            .expect("should find fn");
        let meta = fn_sym.stdlib_metadata.as_ref().expect("metadata present");
        assert!(meta.is_complete(), "missing: {:?}", meta.missing_fields());
        assert_eq!(meta.effects.as_deref(), Some(&["fs.read".to_string()][..]));
        // Authored example wins over the derived one in the rendered block.
        let md = meta.to_markdown_with_derived_example(fn_sym.derived_example.as_deref());
        assert!(md.contains("const s = read_to_string(\"/tmp/x\")"));
        assert!(!md.contains("derived from signature"));
    }

    #[test]
    fn hover_fn_without_example_gets_derived_one() {
        let source = "\
/**
 * Read a file as UTF-8.
 *
 * @effects: [fs.read]
 * @errors: [FileNotFound]
 */
fn read_to_string(path: string) -> string {
  return \"\"
}
";
        let state = DocumentState::new(source.to_string());
        let fn_sym = state
            .symbols
            .iter()
            .find(|s| s.name == "read_to_string" && s.kind == HarnSymbolKind::Function)
            .expect("should find fn");
        assert_eq!(
            fn_sym.derived_example.as_deref(),
            Some("const out = read_to_string(path)")
        );
        let meta = fn_sym.stdlib_metadata.as_ref().expect("metadata present");
        let md = meta.to_markdown_with_derived_example(fn_sym.derived_example.as_deref());
        assert!(md.contains("derived from signature"));
        assert!(md.contains("const out = read_to_string(path)"));
    }

    #[test]
    fn hover_undocumented_fn_still_derives_example() {
        let source = "fn notify(event) {\n  return\n}\n";
        let state = DocumentState::new(source.to_string());
        let fn_sym = state
            .symbols
            .iter()
            .find(|s| s.name == "notify" && s.kind == HarnSymbolKind::Function)
            .expect("should find fn");
        assert!(fn_sym.stdlib_metadata.is_none());
        assert_eq!(fn_sym.derived_example.as_deref(), Some("notify(event)"));
    }

    #[test]
    fn hover_fn_with_plain_comment_fallback() {
        let source = "// Greets a person by name.\nfn greet(name: string) -> string {\n  return \"Hello, \" + name\n}\n";
        let state = DocumentState::new(source.to_string());
        let fn_sym = state
            .symbols
            .iter()
            .find(|s| s.name == "greet" && s.kind == HarnSymbolKind::Function)
            .expect("should find greet");
        assert_eq!(
            fn_sym.doc_comment.as_deref(),
            Some("Greets a person by name.")
        );
    }

    #[test]
    fn hover_fn_no_doc_comment() {
        let source =
            "const x = 1\n\nfn greet(name: string) -> string {\n  return \"Hello, \" + name\n}\n";
        let state = DocumentState::new(source.to_string());
        let fn_sym = state
            .symbols
            .iter()
            .find(|s| s.name == "greet" && s.kind == HarnSymbolKind::Function)
            .expect("should find greet");
        assert!(
            fn_sym.doc_comment.is_none(),
            "non-comment line above should not produce doc_comment"
        );
    }

    #[test]
    fn hover_impl_method_visible_outside() {
        let source = concat!(
            "struct Point { x: int, y: int }\n",
            "\n",
            "impl Point {\n",
            "  // Returns the sum of x and y.\n",
            "  fn sum(self) -> int {\n",
            "    return self.x + self.y\n",
            "  }\n",
            "}\n",
            "\n",
            "const p = Point({x: 1, y: 2})\n",
            "const s = p.sum()\n",
        );
        let sym = hover_symbol_at(source, 10, 12, "sum").expect("should find sum method");
        assert_eq!(sym.kind, HarnSymbolKind::Function);
        assert_eq!(sym.signature.as_deref(), Some("fn sum(self) -> int"));
        assert_eq!(sym.impl_type.as_deref(), Some("Point"));
        assert_eq!(
            sym.doc_comment.as_deref(),
            Some("Returns the sum of x and y.")
        );
    }

    #[test]
    fn hover_member_access_does_not_resolve_to_a_user_defined_global_of_the_same_name() {
        // Same constraint as the builtin case, for user-defined globals: a
        // top-level binding carries no scope span, so nothing but an explicit
        // receiver-owned check keeps `value.greet` from resolving to `fn greet`.
        let source = "fn greet() {\n  log(\"hi\")\n}\n\nfn main(harness: Harness) {\n  const value = 1\n  value.greet\n}\n";

        // `.greet` is a member of `value`, which has no such method.
        assert!(
            hover_target_at(source, 6, 9).is_none(),
            "a member access must not resolve to a top-level function of the same name",
        );

        // The same name, used bare, still resolves to the global it names.
        match hover_target_at(source, 0, 4) {
            Some(HoverTarget::Symbol(sym)) => assert_eq!(sym.name, "greet"),
            other => panic!("bare `greet` must still hover as the global fn, got {other:?}"),
        }
    }

    #[test]
    fn hover_member_access_does_not_resolve_to_a_global_builtin_of_the_same_name() {
        // A member access must not consult the global builtin namespace: a
        // member that borrows a builtin's docs describes an API the receiver
        // does not have.
        let source = "fn main(harness: Harness) {\n  harness.exit(2)\n}\n";

        // Column 11 is inside `exit`, after the dot.
        assert!(
            hover_target_at(source, 1, 11).is_none(),
            "hovering `.exit` must resolve to nothing: it is a member that does \
             not exist, and the global `exit` builtin is not what it names"
        );

        // The same word, named in its own right, is still the builtin. Suppressing
        // the member case must not cost the bare case.
        let bare = "fn main(harness: Harness) {\n  exit(2)\n}\n";
        assert!(
            matches!(hover_target_at(bare, 1, 3), Some(HoverTarget::Builtin(_))),
            "a bare `exit` is the global builtin and must still hover"
        );
    }

    #[test]
    fn hover_range_bound_is_not_a_member_access() {
        // `..` is not a receiver. Reading `0..exit` as a member access would
        // suppress a genuine builtin, trading the old false positive for a new
        // false negative.
        let source = "fn main(harness: Harness) {\n  const r = [0..exit]\n}\n";
        assert!(
            matches!(
                hover_target_at(source, 1, 17),
                Some(HoverTarget::Builtin(_))
            ),
            "a range bound must still resolve to the builtin"
        );
    }

    #[test]
    fn hover_member_access_survives_a_wrapped_method_chain() {
        // A chain broken across lines puts whitespace between the dot and the
        // name. It is still a member access.
        let source = "fn main(harness: Harness) {\n  const v = value\n    .exit\n}\n";
        assert!(
            hover_target_at(source, 2, 6).is_none(),
            "a wrapped `.exit` is still a member access, not the global builtin"
        );
    }

    #[test]
    fn hover_fn_untyped_params() {
        let source = "fn add(a, b) {\n  return a + b\n}\n";
        let state = DocumentState::new(source.to_string());
        let fn_sym = state
            .symbols
            .iter()
            .find(|s| s.name == "add" && s.kind == HarnSymbolKind::Function)
            .expect("should find add");
        assert_eq!(fn_sym.signature.as_deref(), Some("fn add(a, b)"));
    }

    #[test]
    fn hover_pipeline() {
        let source = "// Main entry point.\npipeline main() {\n  __io_println(\"hello\")\n}\n";
        let state = DocumentState::new(source.to_string());
        let sym = state
            .symbols
            .iter()
            .find(|s| s.name == "main" && s.kind == HarnSymbolKind::Pipeline)
            .expect("should find main pipeline");
        assert_eq!(sym.signature.as_deref(), Some("pipeline main()"));
        assert_eq!(sym.doc_comment.as_deref(), Some("Main entry point."));
    }

    #[test]
    fn hover_public_pipeline_signature() {
        let source = "pub pipeline build(task) extends base {\n  return\n}\n";
        let state = DocumentState::new(source.to_string());
        let sym = state
            .symbols
            .iter()
            .find(|s| s.name == "build" && s.kind == HarnSymbolKind::Pipeline)
            .expect("should find build pipeline");
        assert_eq!(sym.signature.as_deref(), Some("pub pipeline build(task)"));
    }

    #[test]
    fn hover_captures_flow_predicate_attributes() {
        let source = concat!(
            "@invariant\n",
            "@deterministic\n",
            "@archivist(evidence: [\"https://example.com/spec\"], confidence: 0.9, source_date: \"2026-04-01\")\n",
            "fn no_secrets(slice) -> bool {\n",
            "  return true\n",
            "}\n",
        );
        let state = DocumentState::new(source.to_string());
        let sym = state
            .symbols
            .iter()
            .find(|s| s.name == "no_secrets" && s.kind == HarnSymbolKind::Function)
            .expect("should find no_secrets");
        let names: Vec<&str> = sym.attributes.iter().map(|a| a.name.as_str()).collect();
        assert_eq!(names, vec!["invariant", "deterministic", "archivist"]);
        let block = crate::symbols::format_flow_attributes_block(&sym.attributes)
            .expect("flow metadata block");
        assert!(block.contains("@invariant"));
        assert!(block.contains("@deterministic"));
        assert!(block.contains("@archivist"));
        assert!(block.contains("evidence"));
        assert!(block.contains("https://example.com/spec"));
    }

    #[test]
    fn hover_generic_interface_signature() {
        let source = "interface Repository<T> {\n  fn map<U>(value: T, f: fn(T) -> U) -> U\n}\n";
        let state = DocumentState::new(source.to_string());
        let sym = state
            .symbols
            .iter()
            .find(|s| s.name == "Repository" && s.kind == HarnSymbolKind::Interface)
            .expect("should find Repository interface");
        assert_eq!(
            sym.signature.as_deref(),
            Some("interface Repository<T> { fn map<U>(value, f) }")
        );
    }
}