Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,14 @@ A `PreToolUse` hook in `.claude/settings.json` **denies any `git commit`** until
## Rust crate

A Cargo workspace (`resolver = "3"`, edition 2024) with one member, `crates/pseudoscript`, whose binary is named `pds`. Standard commands: `cargo build`, `cargo test`, `cargo run -p pseudoscript`, `cargo test <name>` for a single test. The implementation is not started; when writing Rust, the `idiomatic-rust` skill is required.

## Completion has two independent engines — do not conflate them

Autocomplete is served by two separate, unsynchronised implementations. A fix to one does **not** reach the other:

- **Native LSP** — `crates/pseudoscript-lsp/src/complete.rs` (context-aware: `.`/`::`/`:`/`<`/`#[`), consumed at `server.rs` `completion()`. This is what real editors (e.g. `pseudoscript-jetbrains`) get over LSP.
- **Web IDE** — `web-ide/src/lib/pseudoscript-language.js` `pseudoscriptCompletion()`, a CodeMirror provider. It is **context-free**: always offers all keywords + all workspace symbols (name and fqn), relying solely on CodeMirror's prefix filter. No `.`/`::`/type-position narrowing.

The wasm bundle (`crates/pseudoscript-wasm`, prebuilt into `web-ide/src/lib/pds-wasm/`) exports `hover`/`definition`/`references` but **no `completion`** — so the web IDE never calls Rust for completion, and rebuilding wasm changes nothing about IDE autocomplete. Rebuild only when a *wasm-exported* surface changes: `wasm-pack build` via `web-ide` `npm run build:wasm`, then commit the regenerated `pds-wasm/` artifacts.

When asked to fix "autocomplete", first establish which surface: web IDE → edit the JS provider; LSP editor → edit `complete.rs`. The long-term fix is to give `complete.rs` a wasm `completion` export and have the IDE call it, collapsing the two into one.
86 changes: 82 additions & 4 deletions crates/pseudoscript-lsp/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@ pub fn completion(
) -> Vec<CompletionItem> {
let offset = position_to_offset(src, position);
let tokens = tokenize(src);
// The token whose context governs completion is the last one ending at or
// before the caret (an identifier under the caret ends after it, so its
// predecessor — the trigger — is selected instead).
let trigger = tokens.iter().rposition(|t| t.span.end <= offset);
let trigger = governing_trigger(&tokens, offset);

match trigger.map(|i| (i, tokens[i].kind)) {
Some((i, TokenKind::Dot)) => member_items(ws, from_fqn, src, &tokens, i),
Expand All @@ -43,6 +40,23 @@ pub fn completion(
}
}

/// Index of the token whose kind governs completion at `offset`.
///
/// The trigger is the rightmost token ending at or before the caret — except
/// for a partial identifier typed *under* the caret, which ends exactly at
/// `offset` (`span.end == offset`). That identifier is the prefix the client
/// filters on, not the context, so its predecessor is the real trigger. A caret
/// strictly inside an identifier (`span.end > offset`) is already excluded by
/// the `<= offset` bound, so only the boundary case needs skipping.
fn governing_trigger(tokens: &[Token], offset: u32) -> Option<usize> {
let last = tokens.iter().rposition(|t| t.span.end <= offset)?;
if tokens[last].kind == TokenKind::Ident && tokens[last].span.end == offset {
last.checked_sub(1)
} else {
Some(last)
}
}

/// Callables and fields of the node named by the base before `tokens[dot]`.
fn member_items(
ws: &Workspace,
Expand Down Expand Up @@ -264,4 +278,68 @@ mod tests {
assert!(labels.contains(&"system".to_owned()), "{labels:?}");
assert!(labels.contains(&"public".to_owned()), "{labels:?}");
}

// With a prefix typed, the caret sits at the end of a partial identifier.
// Each narrowing context must stay scoped — the trigger before the prefix
// governs — and must not leak the general keyword set.

#[test]
fn members_after_self_dot_with_prefix() {
let src =
"//! m\n\nsystem S {\n run() {\n self.he\n }\n helper(x: number): uuid;\n}\n";
let ws = workspace(&[("m", src)]);
let offset = (src.find("self.he").unwrap() + "self.he".len()) as u32;
let labels = labels_at(&ws, "m", src, offset);
assert!(labels.contains(&"helper".to_owned()), "{labels:?}");
assert!(labels.contains(&"run".to_owned()), "{labels:?}");
assert!(
!labels.contains(&"system".to_owned()),
"general scope leaked: {labels:?}"
);
}

#[test]
fn types_after_colon_with_prefix() {
let src = "//! m\n\ndata D { x: numb }\n";
let ws = workspace(&[("m", src)]);
let offset = (src.find("numb").unwrap() + "numb".len()) as u32;
let labels = labels_at(&ws, "m", src, offset);
assert!(labels.contains(&"number".to_owned()), "{labels:?}");
assert!(labels.contains(&"D".to_owned()), "{labels:?}");
assert!(
!labels.contains(&"system".to_owned()),
"general scope leaked: {labels:?}"
);
}

#[test]
fn macros_after_hash_bracket_with_prefix() {
let src = "//! m\n\n#[ht\nsystem S;\n";
let ws = workspace(&[("m", src)]);
let offset = (src.find("#[ht").unwrap() + "#[ht".len()) as u32;
let labels = labels_at(&ws, "m", src, offset);
assert!(labels.contains(&"http".to_owned()), "{labels:?}");
assert!(
!labels.contains(&"system".to_owned()),
"general scope leaked: {labels:?}"
);
}

#[test]
fn public_symbols_after_module_path_with_prefix() {
let mods = [
("a", "//! a\n\npublic system Svc;\n\nsystem Hidden;\n"),
("b", "//! b\n\ncontainer C for a::Sv\n"),
];
let ws = workspace(&mods);
let src = mods[1].1;
let offset = (src.find("a::Sv").unwrap() + "a::Sv".len()) as u32;
let labels = labels_at(&ws, "b", src, offset);
assert!(labels.contains(&"Svc".to_owned()), "{labels:?}");
assert!(!labels.contains(&"Hidden".to_owned()), "{labels:?}");
assert!(
!labels.contains(&"system".to_owned()),
"general scope leaked: {labels:?}"
);
}
}