Skip to content
Draft
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ For map/solo.ini diagnostics and W3D model/bone completions, set
INI definitions are treated as already loaded before the map file; W3D assets
are indexed for model and bone checks.

The server advertises the standard LSP workspace commands
`zerosyntax.clearIndexCache` and `zerosyntax.rebuildIndexCache`. Any editor can
invoke them through `workspace/executeCommand`; rebuilding refreshes the live
index and cache without restarting the language server.

### Standalone language server

Release assets also include the standalone `zerosyntax-lsp` binary. Any editor
Expand Down
2 changes: 1 addition & 1 deletion crates/analysis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ license.workspace = true
zerosyntax-schema.workspace = true
zerosyntax-syntax.workspace = true
rowan.workspace = true
serde.workspace = true

[dev-dependencies]
serde.workspace = true
toml.workspace = true
criterion.workspace = true

Expand Down
207 changes: 201 additions & 6 deletions crates/analysis/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! unreachable sets, scaffold stub definitions, suppress diagnostics in-file).

use zerosyntax_schema::{RefKind, ValueType};
use zerosyntax_syntax::ast::{Field, Module};
use zerosyntax_syntax::ast::{Block, Field, Module};
use zerosyntax_syntax::{Parse, SyntaxErrorKind, SyntaxKind, SyntaxNode, SyntaxToken};

use crate::diagnostics::{pragma_rest, pragma_words, Diagnostic, Severity};
Expand All @@ -22,19 +22,136 @@ pub struct Fix {
/// All fixes applicable within `range` (the editor's selection/cursor line).
/// `diags` should be the suppression-filtered diagnostics for the document
/// (from `diagnose_with_cache`); `index` is the workspace index for cross-file
/// checks (e.g. stub creation).
/// checks (e.g. stub creation). `load_source` resolves indexed file URIs when
/// a fix needs source from another file.
pub fn fixes(
analyzer: &Analyzer,
parse: &Parse,
text: &str,
range: Span,
diags: &[Diagnostic],
index: Option<&WorkspaceIndex>,
load_source: impl Fn(&str) -> Option<String>,
) -> Vec<Fix> {
let mut out = Vec::new();
insert_missing_ends(parse, text, range, &mut out);
suggest_members(analyzer, parse, range, &mut out);
diagnostic_fixes(analyzer, parse, text, range, diags, index, &mut out);
if let Some(index) = index {
out.extend(origin_copy_fixes(
analyzer,
parse,
text,
range,
diags,
index,
load_source,
));
}
out
}

/// Offer to append the complete base definition for a map-layer override.
fn origin_copy_fixes(
analyzer: &Analyzer,
parse: &Parse,
text: &str,
range: Span,
diags: &[Diagnostic],
index: &WorkspaceIndex,
load_source: impl Fn(&str) -> Option<String>,
) -> Vec<Fix> {
use std::collections::HashSet;

let mut out = Vec::new();
let mut seen = HashSet::new();
for diagnostic in diags {
if diagnostic.code != "overrides"
|| diagnostic.span.end < range.start
|| diagnostic.span.start > range.end
{
continue;
}
let block_node = parse
.syntax()
.children()
.filter(|node| node.kind() == SyntaxKind::BLOCK)
.find(|node| {
let node_range = node.text_range();
u32::from(node_range.start()) <= diagnostic.span.start
&& diagnostic.span.end <= u32::from(node_range.end())
});
let Some(block_node) = block_node else {
continue;
};
let block = Block(block_node);
let Some(keyword) = block.keyword() else {
continue;
};
let Some(kind) = analyzer
.block(keyword.text())
.and_then(|schema_block| schema_block.defines)
else {
continue;
};
let Some(name) = block.name().map(|token| token.text().to_string()) else {
continue;
};
if !seen.insert(name.to_ascii_lowercase()) {
continue;
}

let base_location = index.locations(kind, &name).iter().find(|location| {
!location
.file
.rsplit(['/', '\\'])
.next()
.is_some_and(|file| {
file.eq_ignore_ascii_case("map.ini") || file.eq_ignore_ascii_case("solo.ini")
})
});
let Some(base_location) = base_location else {
continue;
};
let Some(base_text) = load_source(&base_location.file) else {
continue;
};
let base_parse = analyzer.parse(&base_text);
let base_block = base_parse
.syntax()
.children()
.filter(|node| node.kind() == SyntaxKind::BLOCK)
.find(|node| {
Block(node.clone())
.name()
.is_some_and(|token| token.text().eq_ignore_ascii_case(&name))
Comment on lines +124 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Match definition kind
The reference-copy fix finds the base location using kind, but then copies the first parsed block with the same name without checking the block keyword. When one INI contains two definition kinds with the same name, the action can insert the wrong reference copy for the overrides diagnostic.

});
let Some(base_block) = base_block else {
continue;
};
let block_range = base_block.text_range();
let block_text =
&base_text[usize::from(block_range.start())..usize::from(block_range.end())];
let base_short = base_location
.file
.rsplit(['/', '\\'])
.next()
.filter(|name| !name.is_empty())
.unwrap_or("base");
let lead = if text.is_empty() || text.ends_with('\n') {
""
} else {
"\n"
};
let at = text.len() as u32;
out.push(Fix {
title: format!("Insert reference copy of `{name}` from {base_short}"),
span: Span::new(at, at),
new_text: format!(
"{lead}\n; === Reference copy of {name} from {base_short} ===\n; Remove the fields and modules you don't need.\n{block_text}\n"
),
});
}
out
}

Expand Down Expand Up @@ -483,6 +600,7 @@ mod tests {
Span::new(0, src.len() as u32),
&diags,
Some(&idx),
|_| None,
)
}

Expand All @@ -491,7 +609,15 @@ mod tests {
let a = Analyzer::embedded();
let src = "Object Tank\n MaxHealth = 1\n";
let parse = a.parse(src);
let fx = fixes(&a, &parse, src, Span::new(0, src.len() as u32), &[], None);
let fx = fixes(
&a,
&parse,
src,
Span::new(0, src.len() as u32),
&[],
None,
|_| None,
);
assert_eq!(fx.len(), 1, "{fx:?}");
assert!(fx[0].title.contains("`End`") && fx[0].title.contains("Object"));
assert_eq!(fx[0].new_text, "End\n");
Expand All @@ -504,7 +630,15 @@ mod tests {
// `Appearance` is enum locomotor_appearance; TREDS ≈ TREADS.
let src = "Locomotor L\n Appearance = TREDS\nEnd\n";
let parse = a.parse(src);
let fx = fixes(&a, &parse, src, Span::new(0, src.len() as u32), &[], None);
let fx = fixes(
&a,
&parse,
src,
Span::new(0, src.len() as u32),
&[],
None,
|_| None,
);
assert!(fx.iter().any(|f| f.new_text == "TREADS"), "{fx:?}");

// Bitflag with prefix op: the `+` is preserved in the replacement.
Expand All @@ -517,6 +651,7 @@ mod tests {
Span::new(0, src2.len() as u32),
&[],
None,
|_| None,
);
assert!(fx2.iter().any(|f| f.new_text == "+EXPLODED"), "{fx2:?}");
}
Expand All @@ -526,7 +661,16 @@ mod tests {
let a = Analyzer::embedded();
let src = "Locomotor L\n Appearance = TREADS\nEnd\n";
let parse = a.parse(src);
assert!(fixes(&a, &parse, src, Span::new(0, src.len() as u32), &[], None).is_empty());
assert!(fixes(
&a,
&parse,
src,
Span::new(0, src.len() as u32),
&[],
None,
|_| None,
)
.is_empty());
}

// ── unreachable-set fixes ────────────────────────────────────────────────
Expand Down Expand Up @@ -869,6 +1013,55 @@ mod tests {
assert_eq!(suppress_count, 1, "duplicate suppress actions: {fx_dup:?}");
}

#[test]
fn reference_copy_uses_source_loader_and_requires_source() {
let analyzer = Analyzer::embedded();
let base_uri = "file:///game/Data/INI/Object.ini";
let map_uri = "file:///game/Maps/Test/map.ini";
let base = "Object Tank\n MaxHealth = 500\nEnd\n";
let source = "Object Tank\n MaxHealth = 750\nEnd\n";
let parse = analyzer.parse(source);
let mut index = WorkspaceIndex::new();
index.set_file(
base_uri,
definitions_in(&analyzer, &analyzer.parse(base), base_uri),
);
index.set_file(map_uri, definitions_in(&analyzer, &parse, map_uri));
let diags = diagnose(&analyzer, &parse, Some(&index), Some(map_uri));
assert!(diags
.iter()
.any(|diagnostic| diagnostic.code == "overrides"));

let loaded = fixes(
&analyzer,
&parse,
source,
Span::new(0, source.len() as u32),
&diags,
Some(&index),
|uri| (uri == base_uri).then(|| base.to_string()),
);
let reference = loaded
.iter()
.find(|fix| fix.title.contains("Insert reference copy"))
.expect("reference-copy fix should be supplied by the source loader");
assert!(reference.title.contains("Object.ini"));
assert!(reference.new_text.contains("MaxHealth = 500"));

let unavailable = fixes(
&analyzer,
&parse,
source,
Span::new(0, source.len() as u32),
&diags,
Some(&index),
|_| None,
);
assert!(!unavailable
.iter()
.any(|fix| fix.title.contains("Insert reference copy")));
}

#[test]
fn fixes_ignore_diags_outside_range() {
// The diagnostic is at EOF; the range covers only the first byte.
Expand All @@ -880,7 +1073,9 @@ mod tests {
idx.set_file("test.ini", definitions_in(&a, &parse, "test.ini"));
let diags = diagnose(&a, &parse, Some(&idx), Some("test.ini"));
// Range = first byte only (nowhere near the unresolved-reference token).
let fx = fixes(&a, &parse, src, Span::new(0, 1), &diags, Some(&idx));
let fx = fixes(&a, &parse, src, Span::new(0, 1), &diags, Some(&idx), |_| {
None
});
assert!(
!fx.iter()
.any(|f| f.title.contains("Suppress") || f.title.contains("Create stub")),
Expand Down
7 changes: 4 additions & 3 deletions crates/analysis/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use std::collections::HashMap;

use serde::{Deserialize, Serialize};
use zerosyntax_schema::{RefKind, ValueType};
use zerosyntax_syntax::ast::{Block, Field, Module};
use zerosyntax_syntax::{Parse, SyntaxKind, SyntaxNode};
Expand All @@ -15,7 +16,7 @@ use crate::model::{scope_schema, ScopeSchema};
use crate::{Analyzer, Span};

/// Model data discovered from a W3D asset.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelAsset {
pub name: String,
pub members: Vec<String>,
Expand All @@ -29,15 +30,15 @@ pub struct Location {
}

/// A named definition discovered in a document.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Definition {
pub name: String,
pub kind: RefKind,
pub span: Span,
}

/// A place where a definition is *referenced* (a Reference-typed field value).
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReferenceSite {
pub name: String,
pub kind: RefKind,
Expand Down
3 changes: 2 additions & 1 deletion crates/analysis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

use std::collections::{HashMap, HashSet};

use serde::{Deserialize, Serialize};
use zerosyntax_schema::{BlockType, ModuleType, RefKind, Schema, ValueSet};
use zerosyntax_syntax::{parse, Edit, OpenerOracle, Parse, Strategy};

Expand All @@ -25,7 +26,7 @@ pub use diagnostics::{Diagnostic, Severity};
pub use index::WorkspaceIndex;

/// A half-open byte range `[start, end)` into the source text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Span {
pub start: u32,
pub end: u32,
Expand Down
2 changes: 1 addition & 1 deletion crates/analysis/tests/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ fn check_action(
));
};
let range = Span::new(off, off + spec.on.len() as u32);
let fixes = actions::fixes(analyzer, parse, src, range, diags, Some(index));
let fixes = actions::fixes(analyzer, parse, src, range, diags, Some(index), |_| None);
let titles: Vec<&str> = fixes.iter().map(|f| f.title.as_str()).collect();

let missing: Vec<&String> = spec
Expand Down
Loading