Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3fe8ab9
fix(mock-provider): reject unrecognized input bytes as syntax errors
Teakowa Oct 15, 2026
15015bc
fix(mock-provider): stop inverting the byte range of a phantom last line
Teakowa Oct 15, 2026
a0c53cb
fix(mock-provider): diagnose EOF-truncated documents instead of retur…
Teakowa Oct 15, 2026
219dabd
fix(mock-provider): report simulation failure as an error diagnostic
Teakowa Oct 15, 2026
45cce43
fix(mock-provider): answer -32700 for non-UTF-8 input instead of pani…
Teakowa Oct 15, 2026
72c6f06
fix(mock-provider): sort diagnostics by range.start
Teakowa Oct 15, 2026
07b02bc
fix(mock-provider): sort rename textEdits by range.start
Teakowa Oct 15, 2026
3b11cea
fix(mock-provider): exclude line terminators from position character …
Teakowa Oct 15, 2026
9fb5296
fix(mock-provider): resolve references by symbol kind, not by name
Teakowa Oct 15, 2026
6fbd054
fix(mock-provider): refuse rename when a received document has errors
Teakowa Oct 15, 2026
3721fec
fix(mock-provider): resync malformed op declarations at the line boun…
Teakowa Oct 15, 2026
79ccae0
fix(mock-provider): diagnose out-of-range integer literals
Teakowa Oct 15, 2026
30a68ab
fix(mock-provider): harden project source enumeration
Teakowa Oct 15, 2026
50afacd
fix(mock-provider): reject non-identifier names in reconstruct
Teakowa Oct 15, 2026
a9a7280
fix(runner): reap provider on failure paths; detach reader thread
Teakowa Oct 15, 2026
51639cb
fix(mock-provider): enforce integer/string ids and required params
Teakowa Oct 15, 2026
a56a6b5
fix(runner): drain trailing output, substitute raw lines, harden CLI
Teakowa Oct 15, 2026
d4b8b9d
docs(spec): pin refusal details to spec-owned echo keys
Teakowa Oct 15, 2026
4ed2a6a
docs(spec): pin version-gating corner cases
Teakowa Oct 15, 2026
9e49de1
docs(spec): pin ordering, field-handling, and validation-order gaps
Teakowa Oct 15, 2026
a4db7b9
docs: correct ADR README references and 1.7.0 release date
Teakowa Oct 15, 2026
f6bb2cb
style: cargo fmt over the fix series
Teakowa Oct 15, 2026
482791f
fix(mock-provider): satisfy clippy::nonminimal-bool on Rust 1.85
Teakowa Oct 15, 2026
02c6eb5
test(runner): isolate scratch dirs; wait for request before responding
Teakowa Oct 15, 2026
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## [1.7.0](https://github.com/wrightkit/language-provider-protocol/compare/v1.6.0...v1.7.0) (2026-10-10)
## [1.7.0](https://github.com/wrightkit/language-provider-protocol/compare/v1.6.0...v1.7.0) (2026-10-11)


### Features
Expand Down
41 changes: 29 additions & 12 deletions conformance/mock-provider/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ mod wire;
use std::collections::HashSet;
use std::io::{self, BufRead, BufWriter, Write};

use serde_json::Value;

use server::{DEFAULT_PROTOCOL_VERSION, PROTOCOL_VERSIONS, Server};

fn main() {
Expand All @@ -24,29 +26,44 @@ fn main() {
let stdout = io::stdout();
let mut out = BufWriter::new(stdout.lock());
let mut server = Server::new(supported_version, disabled);
let mut line = String::new();
let mut buf = Vec::new();
loop {
line.clear();
let read = reader
.read_line(&mut line)
.unwrap_or_else(|e| panic!("failed to read stdin: {e}"));
buf.clear();
let read = match reader.read_until(b'\n', &mut buf) {
Ok(n) => n,
Err(_) => break,
};
if read == 0 {
break;
}
let message = line.trim_end_matches(['\r', '\n']);
if message.is_empty() {
continue;
let response = match std::str::from_utf8(&buf) {
Ok(line) => {
let message = line.trim_end_matches(['\r', '\n']);
if message.is_empty() {
continue;
}
server.handle_message(message)
}
// A line that is not valid UTF-8 cannot be valid JSON: -32700 and
// the session continues.
Err(_) => rpc::std_error(Value::Null, -32700, "Parse error"),
};
if write_response(&mut out, &response).is_err() {
break;
}
let response = server.handle_message(message);
let serialized = serde_json::to_string(&response).expect("response serializes");
writeln!(out, "{serialized}").expect("write stdout");
out.flush().expect("flush stdout");
if server.exiting {
break;
}
}
}

fn write_response(out: &mut impl Write, response: &Value) -> io::Result<()> {
let serialized = serde_json::to_string(response).expect("response serializes");
out.write_all(serialized.as_bytes())?;
out.write_all(b"\n")?;
out.flush()
}

/// `--protocol-version <v>` pins the supported version; `--without a,b,c`
/// disables capabilities (used by the capability-negotiation fixtures).
fn parse_args() -> (String, HashSet<&'static str>) {
Expand Down
22 changes: 17 additions & 5 deletions conformance/mock-provider/src/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ impl Server {
&params.document.uri,
)?;
let locations: Vec<Value> = puzzle
.occurrences(symbol.name(), params.include_declaration)
.symbol_occurrences(&symbol, params.include_declaration)
.into_iter()
.map(|range| location_json(&params.document.uri, range))
.collect();
Expand Down Expand Up @@ -255,21 +255,33 @@ impl Server {

let edits: Vec<Value> = sorted_entries(&params.documents)
.into_iter()
.filter_map(|(key, doc)| {
let puzzle = parse_document(&doc.text).puzzle?;
.map(|(key, doc)| {
// §15.2: every reference in every received document must be
// covered, so a document that did not parse cleanly makes the
// rename unsafe — refuse instead of emitting partial edits.
let Some(puzzle) = parse_document(&doc.text).into_puzzle() else {
return Err(HandlerError::lpp(
"invalidDocument",
json!({ "uri": doc.uri, "reason": "documentHasErrors" }),
"document has errors and cannot be renamed safely",
));
};
let edits: Vec<Value> = puzzle
.rename_ranges(&symbol, key == &params.position_document_uri)
.into_iter()
.map(|range| json!({ "range": range, "newText": params.new_name }))
.collect();
(!edits.is_empty()).then(|| {
Ok((!edits.is_empty()).then(|| {
json!({
"documentUri": doc.uri,
"version": doc.version,
"textEdits": edits,
})
})
}))
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
if edits.is_empty() {
return Err(HandlerError::refusal(
Expand Down
46 changes: 40 additions & 6 deletions conformance/mock-provider/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,26 +172,60 @@ impl Loader<'_> {
})?;

// The project is the `.xdl` files in the selected entry's directory.
let mut paths: Vec<PathBuf> = std::fs::read_dir(&root)
// A failed directory entry fails the load rather than silently
// dropping a required source (spec section 8.1).
let entries: Vec<_> = std::fs::read_dir(&root)
.map_err(|error| {
self.fail(
"projectDirectoryUnreadable",
None,
format!("project directory could not be read: {error}"),
)
})?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path == &entry_path || path.extension().is_some_and(|ext| ext == "xdl"))
.collect();
.collect::<Result<Vec<_>, _>>()
.map_err(|error| {
self.fail(
"projectDirectoryUnreadable",
None,
format!("project directory could not be read: {error}"),
)
})?;
let mut paths: Vec<PathBuf> = Vec::new();
for entry in entries {
let path = entry.path();
if path != entry_path {
if path.extension().is_none_or(|ext| ext != "xdl") {
continue;
}
// A directory named `*.xdl` is not a source file; symlinks
// still resolve (or fail) through canonicalization below.
let is_dir = entry
.file_type()
.map_err(|error| {
self.fail(
"sourceFileUnreadable",
path_to_file_uri(&path).as_deref(),
format!("source file type could not be read: {error}"),
)
})?
.is_dir();
if is_dir {
continue;
}
}
paths.push(path);
}
paths.sort();

let mut documents = HashMap::new();
for path in paths {
// The failing file's URI is knowable before canonicalization;
// `details.uri` should name it (spec section 8.1).
let unresolved_uri = path_to_file_uri(&path);
let path = std::fs::canonicalize(&path).map_err(|error| {
self.fail(
"sourceFileUnreadable",
None,
unresolved_uri.as_deref(),
format!("source file could not be resolved: {error}"),
)
})?;
Expand Down
15 changes: 15 additions & 0 deletions conformance/mock-provider/src/puzzle/artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,24 @@ struct SheetOp {
arg: i64,
}

/// The `x-demo-lang` identifier grammar: `[A-Za-z_][A-Za-z0-9_]*`.
fn is_ident(name: &str) -> bool {
let mut chars = name.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

/// Canonical source text regenerated from a puzzle evaluation sheet.
/// Names are emitted verbatim, so every one must be a valid identifier or
/// the output would not parse (or would carry injected syntax).
pub(crate) fn reconstruct_source(content: &str) -> Option<String> {
let sheet: Sheet = serde_json::from_str(content).ok()?;
if !is_ident(&sheet.name)
|| sheet.ops.iter().any(|op| !is_ident(&op.name))
|| sheet.solution.iter().any(|name| !is_ident(name))
{
return None;
}
let mut out = format!(
"puzzle {} {{\n target = {}\n start = {}\n ops {{\n",
sheet.name, sheet.target, sheet.start
Expand Down
29 changes: 19 additions & 10 deletions conformance/mock-provider/src/puzzle/lex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ pub(super) enum TokenKind {
Minus,
Slash,
Newline,
/// A character outside the token grammar. Carries the char so diagnostics
/// can name it; the parser never accepts it as an identifier.
Unknown(char),
/// An integer literal outside the i64 range.
BadNumber,
}

impl TokenKind {
Expand Down Expand Up @@ -85,7 +90,7 @@ pub(super) fn tokenize(src: &SourceText<'_>, text: &str) -> Vec<Token> {
b'-' if bytes.get(i + 1).is_some_and(|b| b.is_ascii_digit()) => {
let (n, end) = read_number(text, i);
i = end;
TokenKind::Number(n)
n.map_or(TokenKind::BadNumber, TokenKind::Number)
}
b'-' => {
i += 1;
Expand All @@ -94,7 +99,7 @@ pub(super) fn tokenize(src: &SourceText<'_>, text: &str) -> Vec<Token> {
b if b.is_ascii_digit() => {
let (n, end) = read_number(text, i);
i = end;
TokenKind::Number(n)
n.map_or(TokenKind::BadNumber, TokenKind::Number)
}
b if b.is_ascii_alphabetic() || b == b'_' => {
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
Expand All @@ -108,8 +113,11 @@ pub(super) fn tokenize(src: &SourceText<'_>, text: &str) -> Vec<Token> {
kind.clone()
}
None => {
i += 1;
TokenKind::Ident(format!("<unknown:{b}>"))
// Consume the whole character so token ranges stay on
// UTF-8 boundaries; `SourceText` positions slice `text`.
let ch = text[i..].chars().next().expect("in-bounds byte");
i += ch.len_utf8();
TokenKind::Unknown(ch)
}
},
};
Expand All @@ -121,16 +129,15 @@ pub(super) fn tokenize(src: &SourceText<'_>, text: &str) -> Vec<Token> {
tokens
}

fn read_number(text: &str, i: usize) -> (i64, usize) {
fn read_number(text: &str, i: usize) -> (Option<i64>, usize) {
let bytes = text.as_bytes();
let negative = bytes[i] == b'-';
let mut end = i + usize::from(negative);
let mut end = i + usize::from(bytes[i] == b'-');
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
let digits = &text[i + usize::from(negative)..end];
let value = digits.parse::<i64>().unwrap_or(0);
(if negative { -value } else { value }, end)
// Parse the signed slice so -9223372036854775808 (i64::MIN) stays
// representable; out-of-range literals surface as `None`.
(text[i..end].parse::<i64>().ok(), end)
}

/// Human-readable token description for parse diagnostics.
Expand All @@ -139,6 +146,8 @@ pub(super) fn describe(tok: &Token) -> String {
TokenKind::Ident(name) => format!("'{name}'"),
TokenKind::Number(n) => format!("number {n}"),
TokenKind::Newline => "end of line".to_string(),
TokenKind::Unknown(ch) => format!("unrecognized '{}'", ch.escape_default()),
TokenKind::BadNumber => "out-of-range integer literal".to_string(),
other => format!("'{other:?}'"),
}
}
Loading
Loading