From 7b894ef0d7efabea35b992b723b32ef9543ebfa0 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:36:53 +0800 Subject: [PATCH 01/33] refactor(analyzer): split canonical responsibilities --- crates/wright-analyzer/src/canonical.rs | 2301 +---------------- .../wright-analyzer/src/canonical/analysis.rs | 782 ++++++ crates/wright-analyzer/src/canonical/cfg.rs | 259 ++ crates/wright-analyzer/src/canonical/facts.rs | 93 + .../wright-analyzer/src/canonical/service.rs | 217 ++ .../wright-analyzer/src/canonical/symbols.rs | 956 +++++++ 6 files changed, 2316 insertions(+), 2292 deletions(-) create mode 100644 crates/wright-analyzer/src/canonical/analysis.rs create mode 100644 crates/wright-analyzer/src/canonical/cfg.rs create mode 100644 crates/wright-analyzer/src/canonical/facts.rs create mode 100644 crates/wright-analyzer/src/canonical/service.rs create mode 100644 crates/wright-analyzer/src/canonical/symbols.rs diff --git a/crates/wright-analyzer/src/canonical.rs b/crates/wright-analyzer/src/canonical.rs index 89a3c00..2667bb8 100644 --- a/crates/wright-analyzer/src/canonical.rs +++ b/crates/wright-analyzer/src/canonical.rs @@ -1,2292 +1,9 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; - -use serde_json::{Value as JsonValue, json}; -use workshop_rs::source::{FileId, Span}; -use workshop_rs::{Action, Event, ModifyOp, Program, Rule, Value}; - -use crate::analysis::{Boundedness, EvidenceClass, Severity}; -use crate::registry::{LintConfig, SkippedRule}; -use crate::service::{ErrorInfo, Origin, Request, Response}; - -pub type RuleId = usize; -pub type ActionId = usize; -pub type ValueId = usize; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct SymbolId(pub usize); - -impl SymbolId { - pub const fn from_index(index: usize) -> Self { - Self(index) - } - - pub const fn index(self) -> usize { - self.0 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SymbolKind { - GlobalVariable, - PlayerVariable, - Subroutine, - Rule, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReferenceKind { - Declaration, - Definition, - Read, - Write, - Call, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Symbol { - pub id: SymbolId, - pub kind: SymbolKind, - pub name: String, - pub span: Option, - pub occurrence: Option, - pub rule: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Reference { - pub symbol: SymbolId, - pub kind: ReferenceKind, - pub span: Option, - pub occurrence: Option, - pub rule: Option, - pub action: Option, - pub value: Option, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct UsageSummary { - pub reads: u32, - pub writes: u32, - pub calls: u32, - pub rules: u32, -} - -#[derive(Debug, Clone)] -pub struct SemanticIndex { - symbols: Vec, - references: Vec, - value_ids: HashMap, -} - -fn value_identity_map(program: &Program) -> HashMap { - fn visit_value(value: &Value, identities: &mut HashMap) { - let id = identities.len(); - identities.insert((value as *const Value) as usize, id); - match value { - Value::PlayerVariable { player, .. } => visit_value(player, identities), - Value::Array(values) | Value::Call { args: values, .. } => { - for value in values { - visit_value(value, identities); - } - } - Value::Vector { x, y, z } => { - visit_value(x, identities); - visit_value(y, identities); - visit_value(z, identities); - } - _ => {} - } - } - - fn visit_action(action: &Action, identities: &mut HashMap) { - match action { - Action::SetGlobalVariable { value, .. } - | Action::ModifyGlobalVariable { value, .. } => visit_value(value, identities), - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => { - visit_value(player, identities); - visit_value(value, identities); - } - Action::AssignMember { target, value, .. } => { - visit_value(target, identities); - visit_value(value, identities); - } - Action::If { condition } - | Action::ElseIf { condition } - | Action::While { condition } => visit_value(condition, identities), - Action::ForGlobalVariable { - start, stop, step, .. - } => { - visit_value(start, identities); - visit_value(stop, identities); - visit_value(step, identities); - } - Action::ForPlayerVariable { - player, - start, - stop, - step, - .. - } => { - visit_value(player, identities); - visit_value(start, identities); - visit_value(stop, identities); - visit_value(step, identities); - } - Action::Call { args, .. } => { - for value in args { - visit_value(value, identities); - } - } - Action::Disabled { action } => visit_action(action, identities), - Action::CallSubroutine { .. } | Action::Else | Action::End => {} - } - } - - let mut identities = HashMap::new(); - for rule in &program.rules { - for condition in &rule.conditions { - visit_value(&condition.value, &mut identities); - } - for action in &rule.actions { - visit_action(action, &mut identities); - } - } - identities -} - -impl SemanticIndex { - pub fn build(program: &Program) -> Self { - let mut symbols = Vec::new(); - for variable in &program.global_variables { - let id = SymbolId::from_index(symbols.len()); - let occurrence = declaration_span(program, &["globalvar "], &variable.name); - symbols.push(Symbol { - id, - kind: SymbolKind::GlobalVariable, - name: variable.name.clone(), - span: occurrence, - occurrence, - rule: None, - }); - } - for variable in &program.player_variables { - let id = SymbolId::from_index(symbols.len()); - let occurrence = declaration_span(program, &["playervar "], &variable.name); - symbols.push(Symbol { - id, - kind: SymbolKind::PlayerVariable, - name: variable.name.clone(), - span: occurrence, - occurrence, - rule: None, - }); - } - for subroutine in &program.subroutines { - let id = SymbolId::from_index(symbols.len()); - let occurrence = declaration_span(program, &["subroutine "], &subroutine.name); - symbols.push(Symbol { - id, - kind: SymbolKind::Subroutine, - name: subroutine.name.clone(), - span: occurrence, - occurrence, - rule: None, - }); - } - for (rule, data) in program.rules.iter().enumerate() { - let id = SymbolId::from_index(symbols.len()); - symbols.push(Symbol { - id, - kind: SymbolKind::Rule, - name: data.name.clone(), - span: program.rule_span(rule), - occurrence: program.rule_span(rule), - rule: Some(rule), - }); - } - let mut index = Self { - symbols, - references: Vec::new(), - value_ids: value_identity_map(program), - }; - for symbol in index.symbols.clone() { - if let Some(span) = symbol.occurrence { - index.push( - symbol.id, - ReferenceKind::Declaration, - symbol.rule, - None, - None, - Some(span), - ); - } - } - for (rule, data) in program.rules.iter().enumerate() { - index.walk_event(&data.event, rule, program); - for (condition, value) in data.conditions.iter().enumerate() { - index.walk_value( - &value.value, - rule, - None, - Some(condition), - program.condition_span(rule, condition), - program, - ); - } - for (action, value) in data.actions.iter().enumerate() { - index.walk_action(value, rule, action, program); - } - } - index.value_ids.clear(); - index - } - - pub fn build_with_sources( - program: &Program, - sources: &[(workshop_rs::source::FileId, String)], - ) -> Self { - let mut index = Self::build(program); - for symbol in &mut index.symbols { - let prefixes = match symbol.kind { - SymbolKind::GlobalVariable => &["globalvar "][..], - SymbolKind::PlayerVariable => &["playervar "][..], - SymbolKind::Subroutine => &["subroutine "][..], - SymbolKind::Rule => &[][..], - }; - if let Some(span) = declaration_span_in_sources(sources, prefixes, &symbol.name) { - symbol.span = Some(declaration_line_span(sources, span)); - symbol.occurrence = Some(span); - } - } - for symbol in index.symbols.clone() { - if let Some(span) = symbol.occurrence { - if !index.references.iter().any(|reference| { - reference.symbol == symbol.id && reference.kind == ReferenceKind::Declaration - }) { - index.push( - symbol.id, - ReferenceKind::Declaration, - symbol.rule, - None, - None, - Some(span), - ); - } - } - } - let symbols = index.symbols.clone(); - let mut read_occurrences = HashMap::new(); - for reference in &mut index.references { - let symbol = symbols - .get(reference.symbol.index()) - .cloned() - .expect("reference symbol exists"); - let span = match reference.kind { - ReferenceKind::Declaration => symbol.occurrence, - ReferenceKind::Definition => reference - .rule - .and_then(|rule| program.rule_span(rule)) - .and_then(|span| occurrence_in_sources(sources, span, &symbol.name, false, 0)), - ReferenceKind::Write | ReferenceKind::Call => reference - .rule - .and_then(|rule| { - reference - .action - .and_then(|action| program.action_span(rule, action)) - }) - .and_then(|span| occurrence_in_sources(sources, span, &symbol.name, true, 0)), - ReferenceKind::Read => { - let key = ( - reference.symbol, - reference.rule, - reference.action, - reference.value, - ); - let ordinal = read_occurrences.entry(key).or_insert(0); - let action_span = reference.rule.and_then(|rule| { - reference - .action - .and_then(|action| program.action_span(rule, action)) - }); - let implicit_modify = reference.value.is_none() - && reference.span.is_some() - && reference.span == action_span; - let current = if implicit_modify { 1 } else { *ordinal }; - *ordinal += 1; - reference - .span - .or_else(|| { - action_span.or_else(|| { - reference.rule.and_then(|rule| { - reference - .value - .and_then(|value| program.condition_span(rule, value)) - }) - }) - }) - .and_then(|span| { - occurrence_in_sources(sources, span, &symbol.name, false, current) - }) - } - }; - reference.span = span; - reference.occurrence = span; - } - index - } - - pub fn symbols(&self) -> impl Iterator { - self.symbols.iter() - } - pub fn symbol(&self, id: SymbolId) -> Option<&Symbol> { - self.symbols.get(id.index()) - } - pub fn references(&self, symbol: SymbolId) -> Vec<&Reference> { - self.references - .iter() - .filter(|reference| reference.symbol == symbol) - .collect() - } - pub fn usage(&self, symbol: SymbolId) -> UsageSummary { - let mut usage = UsageSummary::default(); - let mut rules = HashSet::new(); - for reference in self.references(symbol) { - match reference.kind { - ReferenceKind::Read => usage.reads += 1, - ReferenceKind::Write => usage.writes += 1, - ReferenceKind::Call => usage.calls += 1, - _ => {} - } - if let Some(rule) = reference.rule { - rules.insert(rule); - } - } - usage.rules = rules.len() as u32; - usage - } - - fn push( - &mut self, - symbol: SymbolId, - kind: ReferenceKind, - rule: Option, - action: Option, - value: Option, - span: Option, - ) { - self.references.push(Reference { - symbol, - kind, - span, - occurrence: span, - rule, - action, - value, - }); - } - fn global(&self, name: &str) -> Option { - self.symbols - .iter() - .find(|s| s.kind == SymbolKind::GlobalVariable && s.name == name) - .map(|s| s.id) - } - fn player(&self, name: &str) -> Option { - self.symbols - .iter() - .find(|s| s.kind == SymbolKind::PlayerVariable && s.name == name) - .map(|s| s.id) - } - fn subroutine(&self, name: &str) -> Option { - self.symbols - .iter() - .find(|s| s.kind == SymbolKind::Subroutine && s.name == name) - .map(|s| s.id) - } - fn walk_event(&mut self, event: &Event, rule: RuleId, program: &Program) { - if let Event::Subroutine(name) = event { - if let Some(symbol) = self.subroutine(name) { - self.push( - symbol, - ReferenceKind::Definition, - Some(rule), - None, - None, - action_occurrence(program, program.rule_span(rule), name), - ); - } - } - } - fn walk_action( - &mut self, - action: &Action, - rule: RuleId, - action_id: ActionId, - program: &Program, - ) { - let span = program.action_span(rule, action_id); - match action { - Action::SetGlobalVariable { variable, value } - | Action::ModifyGlobalVariable { - variable, value, .. - } => { - if let Some(symbol) = self.global(variable) { - self.push( - symbol, - ReferenceKind::Write, - Some(rule), - Some(action_id), - None, - action_occurrence(program, span, variable), - ); - if matches!(action, Action::ModifyGlobalVariable { .. }) { - self.push( - symbol, - ReferenceKind::Read, - Some(rule), - Some(action_id), - None, - span, - ); - } - } - self.walk_value( - value, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - } - Action::SetPlayerVariable { - player, - variable, - value, - } - | Action::ModifyPlayerVariable { - player, - variable, - value, - .. - } => { - if let Some(symbol) = self.player(variable) { - self.push( - symbol, - ReferenceKind::Write, - Some(rule), - Some(action_id), - None, - action_occurrence(program, span, variable), - ); - if matches!(action, Action::ModifyPlayerVariable { .. }) { - self.push( - symbol, - ReferenceKind::Read, - Some(rule), - Some(action_id), - None, - span, - ); - } - } - self.walk_value( - player, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - self.walk_value( - value, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 1), - program, - ); - } - Action::AssignMember { target, value, .. } => { - self.walk_value( - target, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - self.walk_value( - value, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 1), - program, - ); - } - Action::CallSubroutine { subroutine } => { - if let Some(symbol) = self.subroutine(subroutine) { - self.push( - symbol, - ReferenceKind::Call, - Some(rule), - Some(action_id), - None, - span, - ); - } - } - Action::If { condition } - | Action::ElseIf { condition } - | Action::While { condition } => self.walk_value( - condition, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ), - Action::ForGlobalVariable { - variable, - start, - stop, - step, - } => { - if let Some(symbol) = self.global(variable) { - self.push( - symbol, - ReferenceKind::Write, - Some(rule), - Some(action_id), - None, - action_occurrence(program, span, variable), - ); - } - self.walk_value( - start, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - self.walk_value( - stop, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 1), - program, - ); - self.walk_value( - step, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 2), - program, - ); - } - Action::ForPlayerVariable { - player, - variable, - start, - stop, - step, - } => { - if let Some(symbol) = self.player(variable) { - self.push( - symbol, - ReferenceKind::Write, - Some(rule), - Some(action_id), - None, - action_occurrence(program, span, variable), - ); - } - self.walk_value( - player, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - self.walk_value( - start, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 1), - program, - ); - self.walk_value( - stop, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 2), - program, - ); - self.walk_value( - step, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 3), - program, - ); - } - Action::Disabled { action } => self.walk_action(action, rule, action_id, program), - Action::Call { args, .. } => { - for (argument, value) in args.iter().enumerate() { - self.walk_value( - value, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, argument), - program, - ); - } - } - Action::Else | Action::End => {} - } - } - fn walk_value( - &mut self, - value: &Value, - rule: RuleId, - action: Option, - _value_id: Option, - span: Option, - program: &Program, - ) { - let value_id = self - .value_ids - .get(&((value as *const Value) as usize)) - .copied(); - match value { - Value::GlobalVariable(name) => { - if let Some(symbol) = self.global(name) { - self.push( - symbol, - ReferenceKind::Read, - Some(rule), - action, - value_id, - value_occurrence(program, span, name).or(span), - ); - } - } - Value::PlayerVariable { player, variable } => { - if let Some(symbol) = self.player(variable) { - self.push( - symbol, - ReferenceKind::Read, - Some(rule), - action, - value_id, - value_occurrence(program, span, variable).or(span), - ); - } - self.walk_value(player, rule, action, value_id, span, program); - } - Value::Array(values) | Value::Call { args: values, .. } => { - for value in values { - self.walk_value(value, rule, action, value_id, span, program); - } - } - Value::Vector { x, y, z } => { - self.walk_value(x, rule, action, value_id, span, program); - self.walk_value(y, rule, action, value_id, span, program); - self.walk_value(z, rule, action, value_id, span, program); - } - _ => {} - } - } -} - -fn declaration_span(program: &Program, prefixes: &[&str], name: &str) -> Option { - for file_index in 0..64 { - let file = workshop_rs::source::FileId::from_index(file_index); - let Some(source) = program.source(file) else { - continue; - }; - for (line_index, line) in source.text().lines().enumerate() { - for prefix in prefixes { - let Some(rest) = line.strip_prefix(prefix) else { - continue; - }; - let Some(found) = rest.split_whitespace().next() else { - continue; - }; - let found = found.trim_matches('"'); - if found != name { - continue; - } - let start = prefix.chars().count() as u32 + 1; - return Some(Span::new( - file, - workshop_rs::source::Position::new(line_index as u32 + 1, start), - workshop_rs::source::Position::new( - line_index as u32 + 1, - start + name.chars().count() as u32, - ), - )); - } - } - } - None -} - -fn declaration_span_in_sources( - sources: &[(workshop_rs::source::FileId, String)], - prefixes: &[&str], - name: &str, -) -> Option { - for (file, source) in sources { - for (line_index, line) in source.lines().enumerate() { - for prefix in prefixes { - let Some(rest) = line.strip_prefix(prefix) else { - continue; - }; - let Some(found) = rest.split_whitespace().next() else { - continue; - }; - let found = found.trim_matches('"'); - if found != name { - continue; - } - let start = prefix.chars().count() as u32 + 1; - return Some(Span::new( - *file, - workshop_rs::source::Position::new(line_index as u32 + 1, start), - workshop_rs::source::Position::new( - line_index as u32 + 1, - start + name.chars().count() as u32, - ), - )); - } - } - } - None -} - -fn declaration_line_span( - sources: &[(workshop_rs::source::FileId, String)], - name_span: Span, -) -> Span { - let end_col = sources - .iter() - .find(|(file, _)| *file == name_span.file) - .and_then(|(_, source)| { - source - .lines() - .nth(name_span.start.line.saturating_sub(1) as usize) - .map(|line| line.chars().count() as u32 + 1) - }) - .unwrap_or(name_span.end.col); - Span::new( - name_span.file, - workshop_rs::source::Position::new(name_span.start.line, 1), - workshop_rs::source::Position::new(name_span.start.line, end_col), - ) -} - -fn occurrence_in_sources( - sources: &[(workshop_rs::source::FileId, String)], - span: Span, - name: &str, - before_assignment: bool, - ordinal: usize, -) -> Option { - let source = sources - .iter() - .find(|(file, _)| *file == span.file) - .map(|(_, source)| source.as_str())?; - let name_chars: Vec = name.chars().collect(); - let mut found_index = 0; - for line_number in span.start.line..=span.end.line { - let line = source.lines().nth(line_number.saturating_sub(1) as usize)?; - let chars: Vec = line.chars().collect(); - let lower = if line_number == span.start.line { - span.start.col.saturating_sub(1) as usize - } else { - 0 - }; - let mut upper = if line_number == span.end.line { - span.end.col.saturating_sub(1) as usize - } else { - chars.len() - }; - if before_assignment { - if let Some(operator) = line.find('=') { - upper = upper.min(operator); - } - } - for start in lower.min(chars.len())..=upper.min(chars.len()) { - let end = start.saturating_add(name_chars.len()); - if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { - continue; - } - if !is_code_position(&chars, start) { - continue; - } - let before = start.checked_sub(1).and_then(|index| chars.get(index)); - let after = chars.get(end); - if before.is_some_and(|character| character.is_alphanumeric() || *character == '_') - || after.is_some_and(|character| character.is_alphanumeric() || *character == '_') - { - continue; - } - if found_index == ordinal { - return Some(Span::new( - span.file, - workshop_rs::source::Position::new(line_number, start as u32 + 1), - workshop_rs::source::Position::new(line_number, end as u32 + 1), - )); - } - found_index += 1; - } - } - None -} - -fn is_code_position(chars: &[char], position: usize) -> bool { - let mut quoted = false; - let mut escaped = false; - for character in chars.iter().take(position) { - if *character == '#' && !quoted { - return false; - } - if *character == '"' && !escaped { - quoted = !quoted; - } - escaped = *character == '\\' && !escaped; - if *character != '\\' { - escaped = false; - } - } - !quoted -} - -fn action_occurrence(program: &Program, span: Option, name: &str) -> Option { - let span = span?; - let Some(source_doc) = program.source(span.file) else { - return Some(span); - }; - let source = source_doc.text(); - let name_chars: Vec = name.chars().collect(); - for line_number in span.start.line..=span.end.line { - let line = source.lines().nth(line_number.saturating_sub(1) as usize)?; - let chars: Vec = line.chars().collect(); - let lower = if line_number == span.start.line { - span.start.col.saturating_sub(1) as usize - } else { - 0 - }; - let mut upper = if line_number == span.end.line { - span.end.col.saturating_sub(1) as usize - } else { - chars.len() - }; - if let Some(operator) = line.find('=') { - upper = upper.min(operator); - } - for start in lower.min(chars.len())..=upper.min(chars.len()) { - let end = start.saturating_add(name_chars.len()); - if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { - continue; - } - let before = start.checked_sub(1).and_then(|index| chars.get(index)); - let after = chars.get(end); - if before.is_some_and(|character| character.is_alphanumeric() || *character == '_') - || after.is_some_and(|character| character.is_alphanumeric() || *character == '_') - { - continue; - } - return Some(Span::new( - span.file, - workshop_rs::source::Position::new(line_number, start as u32 + 1), - workshop_rs::source::Position::new(line_number, end as u32 + 1), - )); - } - } - Some(span) -} - -fn value_occurrence(program: &Program, span: Option, name: &str) -> Option { - let span = span?; - let Some(source_doc) = program.source(span.file) else { - return Some(span); - }; - let source = source_doc.text(); - let name_chars: Vec = name.chars().collect(); - for line_number in span.start.line..=span.end.line { - let line = source.lines().nth(line_number.saturating_sub(1) as usize)?; - let chars: Vec = line.chars().collect(); - let lower = if line_number == span.start.line { - span.start.col.saturating_sub(1) as usize - } else { - 0 - }; - let upper = if line_number == span.end.line { - span.end.col.saturating_sub(1) as usize - } else { - chars.len() - }; - for start in lower.min(chars.len())..=upper.min(chars.len()) { - let end = start.saturating_add(name_chars.len()); - if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { - continue; - } - let before = start.checked_sub(1).and_then(|index| chars.get(index)); - let after = chars.get(end); - if before.is_some_and(|character| character.is_alphanumeric() || *character == '_') - || after.is_some_and(|character| character.is_alphanumeric() || *character == '_') - { - continue; - } - return Some(Span::new( - span.file, - workshop_rs::source::Position::new(line_number, start as u32 + 1), - workshop_rs::source::Position::new(line_number, end as u32 + 1), - )); - } - } - Some(span) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Finding { - pub code: String, - pub severity: Severity, - pub message: String, - pub span: Option, - pub rule: RuleId, - pub action: Option, - pub value: Option, - pub evidence: EvidenceClass, - pub boundedness: Option, -} - -pub struct SemanticService<'a> { - program: &'a Program, - index: SemanticIndex, - findings: Vec, - skipped: Vec, - origin: Origin, - config: LintConfig, - registry: Arc, -} - -impl<'a> SemanticService<'a> { - pub fn new(program: &'a Program) -> Self { - Self::with_origin( - program, - Origin { - kind: "unknown".to_string(), - locale: None, - }, - ) - } - pub fn from_workshop(program: &'a Program, locale: &str) -> Self { - Self::with_origin( - program, - Origin { - kind: "workshop".to_string(), - locale: Some(workshop_rs::catalog::Locale::new(locale).to_string()), - }, - ) - } - pub fn with_origin(program: &'a Program, origin: Origin) -> Self { - Self::with_origin_and_config(program, origin, LintConfig::default()) - } - pub fn with_origin_and_config( - program: &'a Program, - origin: Origin, - config: LintConfig, - ) -> Self { - Self::with_origin_and_config_and_registry( - program, - origin, - config, - Arc::new(crate::registry::LintRegistry::default()), - ) - } - pub fn with_origin_and_config_and_registry( - program: &'a Program, - origin: Origin, - config: LintConfig, - registry: Arc, - ) -> Self { - let index = SemanticIndex::build(program); - let report = registry.run_report(program, &config); - Self { - program, - index, - findings: report.findings, - skipped: report.skipped, - origin, - config, - registry, - } - } - pub fn handle_json(&self, request_json: &str) -> String { - let request: Request = match serde_json::from_str(request_json) { - Ok(req) => req, - Err(err) => { - return serde_json::to_string(&Response::Error { - error: ErrorInfo { - code: "invalid-json".to_string(), - message: format!("could not parse request JSON: {err}"), - }, - }) - .expect("error response serializes"); - } - }; - let response = self.handle(&request); - serde_json::to_string(&response).expect("response serializes") - } - pub fn handle(&self, request: &Request) -> Response { - match request { - Request::Version => Response::Ok { result: json!({"name": "wright-tool", "version": env!("CARGO_PKG_VERSION"), "capabilities": ["program", "rules", "symbols", "references", "usage", "cfg", "findings", "persistentObjects", "lintRules"]}) }, - Request::Program => Response::Ok { result: json!({"origin": self.origin, "files": file_count(self.program), "globalVariables": self.program.global_variables.len(), "playerVariables": self.program.player_variables.len(), "subroutines": self.program.subroutines.len(), "rules": self.program.rules.len(), "findings": self.findings.len()}) }, - Request::ListRules => Response::Ok { result: json!(self.program.rules.iter().enumerate().map(|(id, rule)| json!({"id": id, "name": rule.name, "span": span_json(self.program.rule_span(id))})).collect::>()) }, - Request::GetRule { rule } => self.rule(*rule as usize), - Request::ListSymbols { kind } => Response::Ok { result: json!(self.index.symbols().filter(|symbol| kind.as_deref().is_none_or(|kind| symbol_kind_name(symbol.kind) == kind)).map(symbol_json).collect::>()) }, - Request::GetSymbol { symbol } => self.index.symbol(SymbolId::from_index(*symbol as usize)).map_or_else(|| self.error("invalid-id", format!("unknown symbol {symbol}")), |symbol| Response::Ok { result: symbol_json(symbol) }), - Request::FindReferences { symbol } => { let id = SymbolId::from_index(*symbol as usize); if self.index.symbol(id).is_none() { self.error("invalid-id", format!("unknown symbol {symbol}")) } else { Response::Ok { result: json!(self.index.references(id).into_iter().map(|reference| json!({"kind": reference_kind_name(reference.kind), "span": span_json(reference.span), "rule": reference.rule, "action": reference.action, "value": reference.value})).collect::>()) } } } - Request::GetUsage { symbol } => { let id = SymbolId::from_index(*symbol as usize); self.index.symbol(id).map_or_else(|| self.error("invalid-id", format!("unknown symbol {symbol}")), |data| { let usage = self.index.usage(id); Response::Ok { result: json!({"symbol": data.name, "reads": usage.reads, "writes": usage.writes, "calls": usage.calls, "rules": usage.rules}) } }) } - Request::GetCfg { rule } => cfg_response(self.program, *rule as usize), - Request::GetFindings => Response::Ok { result: json!(self.findings.iter().map(finding_json).collect::>()) }, - Request::GetPersistentObjects => Response::Ok { result: json!(persistent_objects(self.program)) }, - Request::LintRules => Response::Ok { - result: lint_rules(&self.registry, &self.config, &self.skipped), - } - } - } - fn rule(&self, id: RuleId) -> Response { - let Some(rule) = self.program.rules.get(id) else { - return self.error("invalid-id", format!("unknown rule {id}")); - }; - Response::Ok { - result: json!({"id": id, "name": rule.name, "span": span_json(self.program.rule_span(id)), "disabled": rule.disabled, "event": event_name(&rule.event), "conditions": rule.conditions.len(), "actions": rule.actions.len()}), - } - } - fn error(&self, code: &str, message: String) -> Response { - Response::Error { - error: ErrorInfo { - code: code.to_string(), - message, - }, - } - } -} - -pub fn analyze(program: &Program, config: &LintConfig) -> Vec { - let value_ids = value_identity_map(program); - let mut findings = Vec::new(); - for (rule_id, rule) in program.rules.iter().enumerate() { - if rule.disabled { - continue; - } - if config.is_enabled("ongoing-condition-hot-path") { - findings.extend(ongoing_condition_findings( - program, rule_id, rule, &value_ids, - )); - } - for (action_id, action) in rule.actions.iter().enumerate() { - let Some((start, end)) = loop_body(rule, action_id) else { - continue; - }; - let body = &rule.actions[start..end]; - if config.is_enabled("min-wait-loop") && body.iter().any(|action| is_wait(action, true)) - { - findings.push(Finding { code: "min-wait-loop".into(), severity: Severity::Warning, message: "loop body waits at the workshop minimum rate; the loop runs at maximum frequency".into(), span: program.action_span(rule_id, action_id), rule: rule_id, action: Some(action_id), value: None, evidence: EvidenceClass::StaticIndicator, boundedness: None }); - } - if config.is_enabled("expensive-loop-check") { - for (offset, body_action) in body.iter().enumerate() { - let mut expensive = Vec::new(); - collect_action_expensive_values(body_action, &mut expensive); - for value in expensive { - let Value::Call { name, .. } = value else { - unreachable!("only expensive calls are collected") - }; - findings.push(Finding { - code: "expensive-loop-check".into(), - severity: Severity::Info, - message: "geometry predicate evaluated inside a loop body may be expensive per iteration" - .into(), - span: value_occurrence( - program, - program.action_span(rule_id, start + offset), - name, - ), - rule: rule_id, - action: Some(action_id), - value: value_ids.get(&(value as *const Value as usize)).copied(), - evidence: EvidenceClass::Heuristic, - boundedness: None, - }); - } - } - } - if let Action::While { condition } = action { - if config.is_enabled("while-without-wait") - && !body.iter().any(|action| is_wait(action, false)) - { - let boundedness = while_boundedness(condition, body, &program.subroutines); - let severity = match boundedness { - Boundedness::StaticallyBounded => Severity::Info, - Boundedness::ObviouslyUnbounded | Boundedness::Unknown => Severity::Warning, - }; - findings.push(Finding { - code: "while-without-wait".into(), - severity, - message: while_without_wait_message(boundedness), - span: program.action_span(rule_id, action_id), - rule: rule_id, - action: Some(action_id), - value: None, - evidence: EvidenceClass::StaticIndicator, - boundedness: Some(boundedness), - }); - } - } - if config.is_enabled("repeated-value") - && matches!( - action, - Action::While { .. } | Action::ForGlobalVariable { .. } - ) - { - findings.extend(repeated_value_findings( - program, rule_id, rule, action_id, start, body, &value_ids, - )); - } - } - if config.is_enabled("duplicate-condition") { - findings.extend(duplicate_condition_findings( - program, rule_id, rule, &value_ids, - )); - } - } - for finding in &mut findings { - if let Some(severity) = config.severity_override(&finding.code) { - finding.severity = severity; - } - } - findings -} - -fn loop_body(rule: &Rule, action: usize) -> Option<(usize, usize)> { - if !matches!( - rule.actions.get(action), - Some(Action::While { .. } | Action::ForGlobalVariable { .. }) - ) { - return None; - } - let mut depth = 0; - for index in action + 1..rule.actions.len() { - match rule.actions[index] { - Action::If { .. } - | Action::While { .. } - | Action::ForGlobalVariable { .. } - | Action::ForPlayerVariable { .. } => depth += 1, - Action::End if depth == 0 => return Some((action + 1, index)), - Action::End => depth -= 1, - _ => {} - } - } - None -} - -fn is_wait(action: &Action, minimum: bool) -> bool { - matches!(action, Action::Call { name, args } if name == "wait" && (!minimum || matches!(args.first(), Some(Value::Number(value)) if *value <= 0.016))) -} -fn collect_action_expensive_values<'a>(action: &'a Action, out: &mut Vec<&'a Value>) { - match action { - Action::Call { args, .. } => { - for value in args { - collect_expensive_values(value, out); - } - } - Action::SetGlobalVariable { value, .. } | Action::ModifyGlobalVariable { value, .. } => { - collect_expensive_values(value, out) - } - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => { - collect_expensive_values(player, out); - collect_expensive_values(value, out); - } - Action::AssignMember { target, value, .. } => { - collect_expensive_values(target, out); - collect_expensive_values(value, out); - } - Action::If { condition } | Action::ElseIf { condition } | Action::While { condition } => { - collect_expensive_values(condition, out) - } - Action::ForGlobalVariable { - start, stop, step, .. - } => { - collect_expensive_values(start, out); - collect_expensive_values(stop, out); - collect_expensive_values(step, out); - } - Action::ForPlayerVariable { - player, - start, - stop, - step, - .. - } => { - collect_expensive_values(player, out); - collect_expensive_values(start, out); - collect_expensive_values(stop, out); - collect_expensive_values(step, out); - } - Action::Disabled { action } => collect_action_expensive_values(action, out), - Action::CallSubroutine { .. } | Action::Else | Action::End => {} - } -} -fn values_equal(left: &Value, right: &Value) -> bool { - match (left, right) { - (Value::Number(a), Value::Number(b)) => a == b, - (Value::String(a), Value::String(b)) - | (Value::LocalizedString(a), Value::LocalizedString(b)) - | (Value::GlobalVariable(a), Value::GlobalVariable(b)) - | (Value::Subroutine(a), Value::Subroutine(b)) => a == b, - (Value::Bool(a), Value::Bool(b)) => a == b, - (Value::Null, Value::Null) | (Value::EventPlayer, Value::EventPlayer) => true, - (Value::Array(a), Value::Array(b)) => { - a.len() == b.len() && a.iter().zip(b).all(|(a, b)| values_equal(a, b)) - } - (Value::Call { name: an, args: aa }, Value::Call { name: bn, args: ba }) => { - an == bn && aa.len() == ba.len() && aa.iter().zip(ba).all(|(a, b)| values_equal(a, b)) - } - ( - Value::Vector { - x: ax, - y: ay, - z: az, - }, - Value::Vector { - x: bx, - y: by, - z: bz, - }, - ) => values_equal(ax, bx) && values_equal(ay, by) && values_equal(az, bz), - ( - Value::Enum { - value_type: at, - value: av, - }, - Value::Enum { - value_type: bt, - value: bv, - }, - ) => at == bt && av == bv, - ( - Value::PlayerVariable { - player: ap, - variable: av, - }, - Value::PlayerVariable { - player: bp, - variable: bv, - }, - ) => av == bv && values_equal(ap, bp), - _ => false, - } -} -fn ongoing_condition_findings( - program: &Program, - rule_id: RuleId, - rule: &Rule, - value_ids: &HashMap, -) -> Vec { - if !matches!( - &rule.event, - Event::Global | Event::EachPlayer | Event::EachPlayerWithFilters { .. } - ) { - return Vec::new(); - } - - let active_conditions: Vec<_> = rule - .conditions - .iter() - .enumerate() - .filter(|(_, condition)| !condition.disabled) - .collect(); - let condition_count = active_conditions.len(); - let mut findings = Vec::new(); - for (index, &(source_index, condition)) in active_conditions.iter().enumerate() { - let mut expensive = Vec::new(); - collect_expensive_values(&condition.value, &mut expensive); - for value in expensive { - let preceding = index; - let later = condition_count - index - 1; - let evaluation = match preceding { - 0 => "is evaluated every server tick".to_string(), - 1 => "is evaluated only after 1 preceding condition passes".to_string(), - count => format!("is evaluated only after {count} preceding conditions pass"), - }; - let later_gates = if later == 0 { - String::new() - } else { - format!( - ", before {later} later short-circuit gate{}", - if later == 1 { "" } else { "s" } - ) - }; - let name = match value { - Value::Call { name, .. } => name, - _ => unreachable!("only expensive call values are collected"), - }; - let span = program.condition_span(rule_id, source_index); - findings.push(Finding { - code: "ongoing-condition-hot-path".into(), - severity: Severity::Info, - message: format!( - "geometry predicate in an ongoing-rule condition {} of {condition_count} {evaluation}{later_gates}; its cost is heuristic, not measured runtime load", - index + 1, - ), - span: value_occurrence(program, span, name).or(span), - rule: rule_id, - action: None, - value: value_ids.get(&(value as *const Value as usize)).copied(), - evidence: EvidenceClass::Heuristic, - boundedness: None, - }); - } - } - findings -} - -fn collect_expensive_values<'a>(value: &'a Value, out: &mut Vec<&'a Value>) { - match value { - Value::Call { name, args } => { - if ["distance", "raycast", "isInLoS"].contains(&name.as_str()) { - out.push(value); - } - for argument in args { - collect_expensive_values(argument, out); - } - } - Value::Array(values) => { - for value in values { - collect_expensive_values(value, out); - } - } - Value::Vector { x, y, z } => { - collect_expensive_values(x, out); - collect_expensive_values(y, out); - collect_expensive_values(z, out); - } - Value::PlayerVariable { player, .. } => collect_expensive_values(player, out), - _ => {} - } -} - -fn duplicate_condition_findings( - program: &Program, - rule_id: RuleId, - rule: &Rule, - value_ids: &HashMap, -) -> Vec { - let mut seen: Vec<&Value> = Vec::new(); - let mut findings = Vec::new(); - for (action_id, action) in rule.actions.iter().enumerate() { - let condition = match action { - Action::If { condition } - | Action::ElseIf { condition } - | Action::While { condition } => condition, - _ => continue, - }; - if seen - .iter() - .any(|previous| values_equal(previous, condition)) - { - let span = program.action_argument_span(rule_id, action_id, 0); - findings.push(Finding { - code: "duplicate-condition".into(), - severity: Severity::Warning, - message: "condition is evaluated more than once in this rule; a later branch can never be taken".into(), - span: span.or_else(|| program.action_span(rule_id, action_id)), - rule: rule_id, - action: Some(action_id), - value: value_ids.get(&(condition as *const Value as usize)).copied(), - evidence: EvidenceClass::Exact, - boundedness: None, - }); - } else { - seen.push(condition); - } - } - findings -} - -fn repeated_value_findings( - program: &Program, - rule_id: RuleId, - rule: &Rule, - loop_action: ActionId, - body_start: usize, - body: &[Action], - value_ids: &HashMap, -) -> Vec { - let mut values = Vec::new(); - let mut parents = Vec::new(); - let mut spans = Vec::new(); - if let Action::While { condition } = &rule.actions[loop_action] { - collect_value_tree( - condition, - None, - program.action_argument_span(rule_id, loop_action, 0), - &mut values, - &mut parents, - &mut spans, - ); - } - let mut action = 0; - while action < body.len() { - if matches!( - body[action], - Action::While { .. } - | Action::ForGlobalVariable { .. } - | Action::ForPlayerVariable { .. } - ) { - action = matching_end(body, action).map_or(action + 1, |end| end + 1); - continue; - } - let action_id = body_start + action; - visit_action_roots(&body[action], &mut |argument, value| { - collect_value_tree( - value, - None, - program.action_argument_span(rule_id, action_id, argument), - &mut values, - &mut parents, - &mut spans, - ); - }); - action += 1; - } - - duplicated_value_families(&values, &parents) - .into_iter() - .map(|family| { - let first = family[0]; - Finding { - code: "repeated-value".into(), - severity: Severity::Warning, - message: format!( - "this value expression is evaluated {} times within the same loop scope", - family.len() - ), - span: spans[first].or_else(|| program.action_span(rule_id, loop_action)), - rule: rule_id, - action: Some(loop_action), - value: value_ids - .get(&(values[first] as *const Value as usize)) - .copied(), - evidence: EvidenceClass::Exact, - boundedness: None, - } - }) - .collect() -} - -fn visit_action_roots<'a>(action: &'a Action, visit: &mut impl FnMut(usize, &'a Value)) { - match action { - Action::SetGlobalVariable { value, .. } | Action::ModifyGlobalVariable { value, .. } => { - visit(0, value) - } - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => { - visit(0, player); - visit(1, value); - } - Action::AssignMember { target, value, .. } => { - visit(0, target); - visit(1, value); - } - Action::If { condition } | Action::ElseIf { condition } | Action::While { condition } => { - visit(0, condition); - } - Action::ForGlobalVariable { - start, stop, step, .. - } => { - visit(0, start); - visit(1, stop); - visit(2, step); - } - Action::ForPlayerVariable { - player, - start, - stop, - step, - .. - } => { - visit(0, player); - visit(1, start); - visit(2, stop); - visit(3, step); - } - Action::Call { args, .. } => { - for (index, value) in args.iter().enumerate() { - visit(index, value); - } - } - Action::CallSubroutine { .. } | Action::Else | Action::End | Action::Disabled { .. } => {} - } -} - -fn collect_value_tree<'a>( - value: &'a Value, - parent: Option, - span: Option, - values: &mut Vec<&'a Value>, - parents: &mut Vec>, - spans: &mut Vec>, -) { - let index = values.len(); - values.push(value); - parents.push(parent); - spans.push(span); - match value { - Value::Array(children) | Value::Call { args: children, .. } => { - for child in children { - collect_value_tree(child, Some(index), span, values, parents, spans); - } - } - Value::Vector { x, y, z } => { - for child in [x, y, z] { - collect_value_tree(child, Some(index), span, values, parents, spans); - } - } - Value::PlayerVariable { player, .. } => { - collect_value_tree(player, Some(index), span, values, parents, spans); - } - _ => {} - } -} - -fn duplicated_value_families(values: &[&Value], parents: &[Option]) -> Vec> { - let mut families: Vec<(usize, Vec)> = Vec::new(); - for (position, value) in values.iter().enumerate() { - if let Some((_, family)) = families - .iter_mut() - .find(|(_, family)| values_equal(values[family[0]], value)) - { - family.push(position); - } else { - families.push((position, vec![position])); - } - } - - let candidates: Vec = families - .iter() - .enumerate() - .filter(|(_, (_, family))| family.len() >= 2 && value_call_count(values[family[0]]) >= 2) - .map(|(index, _)| index) - .collect(); - let mut member_family = vec![None; values.len()]; - for &family in &candidates { - for &member in &families[family].1 { - member_family[member] = Some(family); - } - } - let mut reported: Vec = candidates - .into_iter() - .filter(|&family| { - !families[family].1.iter().any(|&member| { - let mut ancestor = parents[member]; - while let Some(index) = ancestor { - if member_family[index].is_some_and(|other| other != family) { - return true; - } - ancestor = parents[index]; - } - false - }) - }) - .collect(); - reported.sort_by_key(|&family| families[family].0); - reported - .into_iter() - .map(|family| std::mem::take(&mut families[family].1)) - .collect() -} - -fn value_call_count(value: &Value) -> usize { - match value { - Value::Call { args, .. } => 1 + args.iter().map(value_call_count).sum::(), - Value::Array(values) => values.iter().map(value_call_count).sum(), - Value::Vector { x, y, z } => { - value_call_count(x) + value_call_count(y) + value_call_count(z) - } - Value::PlayerVariable { player, .. } => value_call_count(player), - _ => 0, - } -} - -fn while_without_wait_message(boundedness: Boundedness) -> String { - match boundedness { - Boundedness::ObviouslyUnbounded => "loop body contains no wait call and the loop condition is statically true, so the loop repeats without yielding and never terminates on its own; it runs without bound while the rule is active (exact server impact is not statically measurable)".to_string(), - Boundedness::StaticallyBounded => "loop body contains no wait call; the loop is statically bounded by a counter against a literal bound, so it runs a finite number of back-to-back iterations".to_string(), - Boundedness::Unknown => "loop body contains no wait call and the loop's boundedness is unknown (data-dependent condition with no static counter pattern), so the loop may repeat without yielding".to_string(), - } -} - -fn while_boundedness( - condition: &Value, - body: &[Action], - subroutines: &[workshop_rs::Subroutine], -) -> Boundedness { - if matches!(condition, Value::Bool(true)) { - return Boundedness::ObviouslyUnbounded; - } - let Some((variable, toward)) = counter_comparison(condition) else { - return Boundedness::Unknown; - }; - let mut progresses = false; - for (start, end) in direct_action_ranges(body) { - if action_has_unprovable_loop(body, start, end, subroutines) { - return Boundedness::Unknown; - } - match modify_direction(&body[start], &variable) { - Some(direction) if direction == toward => progresses = true, - Some(_) => return Boundedness::Unknown, - None if region_writes(&body[start..end], &variable, subroutines) => { - return Boundedness::Unknown; - } - None => {} - } - } - if progresses { - Boundedness::StaticallyBounded - } else { - Boundedness::Unknown - } -} - -#[derive(Debug, Clone)] -enum CounterVariable { - Global(String), - Player { player: Value, variable: String }, -} - -fn counter_comparison(condition: &Value) -> Option<(CounterVariable, i32)> { - let Value::Call { name, args } = condition else { - return None; - }; - if args.len() != 2 { - return None; - } - let left_variable = variable_of(&args[0]); - let right_variable = variable_of(&args[1]); - let left_literal = matches!(&args[0], Value::Number(_)); - let right_literal = matches!(&args[1], Value::Number(_)); - let (variable, variable_is_left) = match (left_variable, right_literal) { - (Some(variable), true) => (variable, true), - (None, false) if left_literal => (right_variable?, false), - _ => return None, - }; - let direction = match (name.as_str(), variable_is_left) { - ("<", true) | ("<=", true) | (">", false) | (">=", false) => 1, - (">", true) | (">=", true) | ("<", false) | ("<=", false) => -1, - _ => return None, - }; - Some((variable, direction)) -} - -fn variable_of(value: &Value) -> Option { - match value { - Value::GlobalVariable(name) => Some(CounterVariable::Global(name.clone())), - Value::PlayerVariable { player, variable } => Some(CounterVariable::Player { - player: player.as_ref().clone(), - variable: variable.clone(), - }), - _ => None, - } -} - -fn modify_direction(action: &Action, variable: &CounterVariable) -> Option { - let (op, value) = match action { - Action::ModifyGlobalVariable { - variable: target, - op, - value, - } if matches!(variable, CounterVariable::Global(name) if name == target) => (op, value), - Action::ModifyPlayerVariable { - player, - variable: target, - op, - value, - } => { - let CounterVariable::Player { - player: expected_player, - variable: expected_variable, - } = variable - else { - return None; - }; - if target != expected_variable || !values_equal(player, expected_player) { - return None; - } - (op, value) - } - _ => return None, - }; - let Value::Number(step) = value else { - return None; - }; - if *step == 0.0 { - return None; - } - let sign = if *step > 0.0 { 1 } else { -1 }; - match op { - ModifyOp::Add => Some(sign), - ModifyOp::Subtract => Some(-sign), - _ => None, - } -} - -fn region_writes( - actions: &[Action], - variable: &CounterVariable, - subroutines: &[workshop_rs::Subroutine], -) -> bool { - actions - .iter() - .any(|action| action_writes(action, variable, subroutines)) -} - -fn action_writes( - action: &Action, - variable: &CounterVariable, - subroutines: &[workshop_rs::Subroutine], -) -> bool { - match action { - Action::SetGlobalVariable { - variable: target, .. - } - | Action::ModifyGlobalVariable { - variable: target, .. - } => { - matches!(variable, CounterVariable::Global(name) if name == target) - } - Action::SetPlayerVariable { - player, - variable: target, - .. - } - | Action::ModifyPlayerVariable { - player, - variable: target, - .. - } => { - matches!(variable, CounterVariable::Player { player: expected_player, variable: expected_variable } - if target == expected_variable && values_equal(player, expected_player)) - } - Action::CallSubroutine { .. } | Action::AssignMember { .. } => true, - Action::Call { name, .. } => subroutines - .iter() - .any(|subroutine| subroutine.name == *name), - Action::Disabled { action } => action_writes(action, variable, subroutines), - _ => false, - } -} - -fn action_has_unprovable_loop( - actions: &[Action], - start: usize, - end: usize, - subroutines: &[workshop_rs::Subroutine], -) -> bool { - let inner = if end > start + 1 { - &actions[start + 1..end - 1] - } else { - &[] - }; - match &actions[start] { - Action::While { condition } => { - while_boundedness(condition, inner, subroutines) != Boundedness::StaticallyBounded - || region_has_unprovable_loop(inner, subroutines) - } - Action::ForGlobalVariable { variable, step, .. } => { - let finite_step = step_direction(step).is_some(); - !finite_step - || region_writes( - inner, - &CounterVariable::Global(variable.clone()), - subroutines, - ) - || region_has_unprovable_loop(inner, subroutines) - } - Action::ForPlayerVariable { .. } => true, - Action::If { .. } => region_has_unprovable_loop(inner, subroutines), - _ => false, - } -} - -fn region_has_unprovable_loop(actions: &[Action], subroutines: &[workshop_rs::Subroutine]) -> bool { - direct_action_ranges(actions) - .into_iter() - .any(|(start, end)| action_has_unprovable_loop(actions, start, end, subroutines)) -} - -fn direct_action_ranges(actions: &[Action]) -> Vec<(usize, usize)> { - let mut ranges = Vec::new(); - let mut start = 0; - while start < actions.len() { - let end = matching_end(actions, start).map_or(start + 1, |end| end + 1); - ranges.push((start, end)); - start = end; - } - ranges -} - -fn matching_end(actions: &[Action], start: usize) -> Option { - if !matches!( - actions.get(start), - Some( - Action::If { .. } - | Action::While { .. } - | Action::ForGlobalVariable { .. } - | Action::ForPlayerVariable { .. } - ) - ) { - return None; - } - let mut depth = 0; - for (index, action) in actions.iter().enumerate().skip(start + 1) { - match action { - Action::If { .. } - | Action::While { .. } - | Action::ForGlobalVariable { .. } - | Action::ForPlayerVariable { .. } => depth += 1, - Action::End if depth == 0 => return Some(index), - Action::End => depth -= 1, - _ => {} - } - } - None -} - -fn step_direction(step: &Value) -> Option { - let Value::Number(step) = step else { - return None; - }; - if *step == 0.0 { - None - } else { - Some(if *step > 0.0 { 1 } else { -1 }) - } -} - -fn cfg_response(program: &Program, rule: RuleId) -> Response { - let Some(data) = program.rules.get(rule) else { - return Response::Error { - error: ErrorInfo { - code: "invalid-id".into(), - message: format!("unknown rule {rule}"), - }, - }; - }; - let mut builder = CanonicalCfgBuilder { - program, - actions: &data.actions, - blocks: Vec::new(), - }; - let (entry, terminal) = builder.sequence(0, data.actions.len(), "entry"); - let exit = builder.new_block("exit"); - builder.edge(terminal, exit, "fallthrough"); - Response::Ok { - result: json!({ - "entry": entry, - "exit": exit, - "blocks": builder.blocks.iter().enumerate().map(|(id, block)| json!({ - "id": id, - "kind": block.kind, - "waits": block.waits, - "calls": block.calls, - "actions": block.actions, - "successors": block.successors.iter().map(|(to, kind)| json!({"to": to, "kind": kind})).collect::>(), - })).collect::>(), - }), - } -} - -struct CanonicalCfgBlock { - kind: &'static str, - waits: bool, - calls: Vec, - actions: Vec, - successors: Vec<(usize, &'static str)>, -} - -struct CanonicalCfgBuilder<'a> { - program: &'a Program, - actions: &'a [Action], - blocks: Vec, -} - -struct IfBranch { - condition_action: usize, - body_start: usize, - body_end: usize, -} - -struct IfParts { - close: usize, - branches: Vec, - else_body: Option<(usize, usize)>, -} - -impl CanonicalCfgBuilder<'_> { - fn new_block(&mut self, kind: &'static str) -> usize { - let id = self.blocks.len(); - self.blocks.push(CanonicalCfgBlock { - kind, - waits: false, - calls: Vec::new(), - actions: Vec::new(), - successors: Vec::new(), - }); - id - } - - fn edge(&mut self, from: usize, to: usize, kind: &'static str) { - self.blocks[from].successors.push((to, kind)); - } - - fn sequence(&mut self, start: usize, end: usize, entry_kind: &'static str) -> (usize, usize) { - let entry = self.new_block(entry_kind); - let mut current = entry; - let mut index = start; - while index < end { - if matches!(self.actions[index], Action::If { .. }) { - if let Some(parts) = if_parts(self.actions, index) { - let merge = self.new_block("block"); - let mut false_target = if let Some((else_start, else_end)) = parts.else_body { - let (else_entry, else_exit) = self.sequence(else_start, else_end, "block"); - self.edge(else_exit, merge, "fallthrough"); - Some(else_entry) - } else { - None - }; - for branch_data in parts.branches.into_iter().rev() { - let branch = self.new_block("if"); - self.blocks[branch] - .actions - .push(branch_data.condition_action); - let (body_entry, body_exit) = - self.sequence(branch_data.body_start, branch_data.body_end, "block"); - self.edge(branch, body_entry, "true"); - self.edge(body_exit, merge, "fallthrough"); - self.edge(branch, false_target.unwrap_or(merge), "false"); - false_target = Some(branch); - } - self.edge(current, false_target.unwrap_or(merge), "fallthrough"); - current = merge; - index = parts.close + 1; - continue; - } - } - if matches!( - self.actions[index], - Action::While { .. } - | Action::ForGlobalVariable { .. } - | Action::ForPlayerVariable { .. } - ) { - if let Some(close) = matching_end(self.actions, index) { - let kind = if matches!(self.actions[index], Action::While { .. }) { - "while" - } else { - "for" - }; - let header = self.new_block(kind); - self.blocks[header].actions.push(index); - self.edge(current, header, "fallthrough"); - let (body_entry, body_exit) = self.sequence(index + 1, close, "block"); - self.edge( - header, - body_entry, - if kind == "while" { - "true" - } else { - "fallthrough" - }, - ); - self.edge(body_exit, header, "back"); - let after = self.new_block("block"); - self.edge(header, after, "loop-exit"); - current = after; - index = close + 1; - continue; - } - } - match &self.actions[index] { - Action::Else | Action::ElseIf { .. } | Action::End => {} - action => { - let block = &mut self.blocks[current]; - block.actions.push(index); - if is_wait(action, false) { - block.waits = true; - } - if let Some(subroutine) = action_subroutine(self.program, action) { - block.calls.push(subroutine); - } - } - } - index += 1; - } - (entry, current) - } -} - -fn if_parts(actions: &[Action], start: usize) -> Option { - let close = matching_end(actions, start)?; - let mut branches = Vec::new(); - let mut condition_action = start; - let mut body_start = start + 1; - let mut index = body_start; - let mut else_body = None; - while index < close { - match actions[index] { - Action::ElseIf { .. } => { - branches.push(IfBranch { - condition_action, - body_start, - body_end: index, - }); - condition_action = index; - body_start = index + 1; - } - Action::Else => { - branches.push(IfBranch { - condition_action, - body_start, - body_end: index, - }); - else_body = Some((index + 1, close)); - break; - } - _ => { - if let Some(nested_close) = matching_end(actions, index) { - index = nested_close + 1; - continue; - } - } - } - index += 1; - } - if else_body.is_none() { - branches.push(IfBranch { - condition_action, - body_start, - body_end: close, - }); - } - Some(IfParts { - close, - branches, - else_body, - }) -} - -fn action_subroutine(program: &Program, action: &Action) -> Option { - let name = match action { - Action::CallSubroutine { subroutine } => subroutine, - Action::Call { name, .. } => name, - _ => return None, - }; - program - .subroutines - .iter() - .position(|subroutine| subroutine.name == *name) -} - -fn persistent_objects(program: &Program) -> Vec { - let mut output = Vec::new(); - for (rule, data) in program.rules.iter().enumerate() { - for (action, action_data) in data.actions.iter().enumerate() { - let Action::Call { name, args } = action_data else { - continue; - }; - let Some(kind) = persistent_object_kind(name) else { - continue; - }; - let cleanup = match kind { - "hud-text" => "destroyHudText", - "in-world-text" => "destroyInWorldText", - "effect" => "destroyEffect", - _ => continue, - }; - let reevaluation_index = if kind == "hud-text" { 9 } else { 5 }; - let reevaluation = args.get(reevaluation_index).and_then(|value| match value { - Value::Enum { value_type, value } => { - Some(json!({"domain": value_type, "mode": value})) - } - _ => None, - }); - let identity = match kind { - "hud-text" | "in-world-text" => "lastTextId", - "effect" => "lastCreatedEntity", - _ => unreachable!(), - }; - let identity_retained = data - .actions - .get(action + 1) - .is_some_and(|next| action_retains_identity(next, identity)); - let span = span_json(program.action_span(rule, action)); - output.push(json!({ - "kind": kind, - "rule": rule, - "action": action, - "executionScope": execution_scope(&data.event), - "visibility": object_visibility(args), - "reevaluation": reevaluation, - "identityRetained": identity_retained, - "sameKindCleanupInRule": data.actions.iter().any(|action| matches!(action, Action::Call { name, .. } if name == cleanup)), - "span": span, - })); - } - } - output -} - -fn persistent_object_kind(name: &str) -> Option<&'static str> { - match name { - "createHudText" => Some("hud-text"), - "createInWorldText" => Some("in-world-text"), - "createEffect" => Some("effect"), - _ => None, - } -} - -fn execution_scope(event: &Event) -> &'static str { - match event { - Event::Global => "global", - Event::EachPlayer | Event::EachPlayerWithFilters { .. } | Event::Player { .. } => { - "per-player" - } - Event::Subroutine(_) => "subroutine", - } -} - -fn action_retains_identity(action: &Action, identity: &str) -> bool { - let value = match action { - Action::SetGlobalVariable { value, .. } - | Action::SetPlayerVariable { value, .. } - | Action::AssignMember { value, .. } => value, - _ => return false, - }; - matches!(value, Value::Call { name, args } if name == identity && args.is_empty()) -} - -fn object_visibility(args: &[Value]) -> &'static str { - match args.first() { - Some(Value::EventPlayer) => "event-player", - Some(Value::Array(_)) => "explicit-set", - Some(Value::Call { name, .. }) if name == "allPlayers" => "all-players", - Some(Value::Call { .. } | Value::PlayerVariable { .. } | Value::GlobalVariable(_)) => { - "dynamic" - } - _ => "unknown", - } -} -fn lint_rules( - registry: &crate::registry::LintRegistry, - config: &LintConfig, - skipped: &[SkippedRule], -) -> JsonValue { - let descriptors = registry.descriptors(config); - let rules = descriptors - .iter() - .map(|rule| { - json!({ - "id": rule.id, - "defaultSeverity": rule.default_severity, - "effectiveSeverity": rule.effective_severity, - "enabled": rule.enabled, - "summary": rule.summary, - "rationale": rule.rationale, - "documentation": rule.documentation, - "knownLimits": rule.known_limits, - "evidence": rule.evidence, - "tags": rule.tags, - "kind": rule.kind, - }) - }) - .collect::>(); - let config_rules = descriptors - .iter() - .map(|rule| { - ( - rule.id.clone(), - json!({ - "enabled": rule.enabled, - "severity": severity_name(rule.effective_severity), - "options": config.options(&rule.id), - }), - ) - }) - .collect::>(); - json!({ - "rules": rules, - "config": {"rules": config_rules}, - "skipped": skipped, - }) -} -fn file_count(program: &Program) -> usize { - let mut count = 0; - while program.source(FileId::from_index(count)).is_some() { - count += 1; - } - count -} -fn span_json(span: Option) -> JsonValue { - span.map_or(JsonValue::Null, |span| json!({"file": span.file.index(), "start": {"line": span.start.line, "col": span.start.col}, "end": {"line": span.end.line, "col": span.end.col}})) -} -fn symbol_json(symbol: &Symbol) -> JsonValue { - json!({"id": symbol.id.index(), "kind": symbol_kind_name(symbol.kind), "name": symbol.name, "span": span_json(symbol.span)}) -} -fn finding_json(finding: &Finding) -> JsonValue { - json!({"code": finding.code, "severity": severity_name(finding.severity), "message": finding.message, "span": span_json(finding.span), "rule": finding.rule, "action": finding.action, "value": finding.value, "evidence": finding.evidence.as_str(), "boundedness": finding.boundedness.map(Boundedness::as_str)}) -} -fn symbol_kind_name(kind: SymbolKind) -> &'static str { - match kind { - SymbolKind::GlobalVariable => "globalVariable", - SymbolKind::PlayerVariable => "playerVariable", - SymbolKind::Subroutine => "subroutine", - SymbolKind::Rule => "rule", - } -} -fn reference_kind_name(kind: ReferenceKind) -> &'static str { - match kind { - ReferenceKind::Declaration => "declaration", - ReferenceKind::Definition => "definition", - ReferenceKind::Read => "read", - ReferenceKind::Write => "write", - ReferenceKind::Call => "call", - } -} -fn severity_name(severity: Severity) -> &'static str { - match severity { - Severity::Error => "error", - Severity::Warning => "warning", - Severity::Info => "info", - } -} -fn event_name(event: &Event) -> String { - match event { - Event::Subroutine(name) => format!("subroutine:{name}"), - _ => crate::declarative::public_event_id(event).to_string(), - } -} +mod analysis; +mod cfg; +mod facts; +mod service; +mod symbols; + +pub use analysis::{Finding, analyze}; +pub use service::SemanticService; +pub use symbols::*; diff --git a/crates/wright-analyzer/src/canonical/analysis.rs b/crates/wright-analyzer/src/canonical/analysis.rs new file mode 100644 index 0000000..2e06ad6 --- /dev/null +++ b/crates/wright-analyzer/src/canonical/analysis.rs @@ -0,0 +1,782 @@ +use std::collections::HashMap; + +use workshop_rs::source::Span; +use workshop_rs::{Action, Event, ModifyOp, Program, Rule, Value}; + +use super::cfg::{is_wait, matching_end}; +use super::symbols::{ActionId, RuleId, ValueId, value_identity_map, value_occurrence}; +use crate::analysis::{Boundedness, EvidenceClass, Severity}; +use crate::registry::LintConfig; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + pub code: String, + pub severity: Severity, + pub message: String, + pub span: Option, + pub rule: RuleId, + pub action: Option, + pub value: Option, + pub evidence: EvidenceClass, + pub boundedness: Option, +} + +pub fn analyze(program: &Program, config: &LintConfig) -> Vec { + let value_ids = value_identity_map(program); + let mut findings = Vec::new(); + for (rule_id, rule) in program.rules.iter().enumerate() { + if rule.disabled { + continue; + } + if config.is_enabled("ongoing-condition-hot-path") { + findings.extend(ongoing_condition_findings( + program, rule_id, rule, &value_ids, + )); + } + for (action_id, action) in rule.actions.iter().enumerate() { + let Some((start, end)) = loop_body(rule, action_id) else { + continue; + }; + let body = &rule.actions[start..end]; + if config.is_enabled("min-wait-loop") && body.iter().any(|action| is_wait(action, true)) + { + findings.push(Finding { code: "min-wait-loop".into(), severity: Severity::Warning, message: "loop body waits at the workshop minimum rate; the loop runs at maximum frequency".into(), span: program.action_span(rule_id, action_id), rule: rule_id, action: Some(action_id), value: None, evidence: EvidenceClass::StaticIndicator, boundedness: None }); + } + if config.is_enabled("expensive-loop-check") { + for (offset, body_action) in body.iter().enumerate() { + let mut expensive = Vec::new(); + collect_action_expensive_values(body_action, &mut expensive); + for value in expensive { + let Value::Call { name, .. } = value else { + unreachable!("only expensive calls are collected") + }; + findings.push(Finding { + code: "expensive-loop-check".into(), + severity: Severity::Info, + message: "geometry predicate evaluated inside a loop body may be expensive per iteration" + .into(), + span: value_occurrence( + program, + program.action_span(rule_id, start + offset), + name, + ), + rule: rule_id, + action: Some(action_id), + value: value_ids.get(&(value as *const Value as usize)).copied(), + evidence: EvidenceClass::Heuristic, + boundedness: None, + }); + } + } + } + if let Action::While { condition } = action { + if config.is_enabled("while-without-wait") + && !body.iter().any(|action| is_wait(action, false)) + { + let boundedness = while_boundedness(condition, body, &program.subroutines); + let severity = match boundedness { + Boundedness::StaticallyBounded => Severity::Info, + Boundedness::ObviouslyUnbounded | Boundedness::Unknown => Severity::Warning, + }; + findings.push(Finding { + code: "while-without-wait".into(), + severity, + message: while_without_wait_message(boundedness), + span: program.action_span(rule_id, action_id), + rule: rule_id, + action: Some(action_id), + value: None, + evidence: EvidenceClass::StaticIndicator, + boundedness: Some(boundedness), + }); + } + } + if config.is_enabled("repeated-value") + && matches!( + action, + Action::While { .. } | Action::ForGlobalVariable { .. } + ) + { + findings.extend(repeated_value_findings( + program, rule_id, rule, action_id, start, body, &value_ids, + )); + } + } + if config.is_enabled("duplicate-condition") { + findings.extend(duplicate_condition_findings( + program, rule_id, rule, &value_ids, + )); + } + } + for finding in &mut findings { + if let Some(severity) = config.severity_override(&finding.code) { + finding.severity = severity; + } + } + findings +} + +fn loop_body(rule: &Rule, action: usize) -> Option<(usize, usize)> { + if !matches!( + rule.actions.get(action), + Some(Action::While { .. } | Action::ForGlobalVariable { .. }) + ) { + return None; + } + let end = matching_end(&rule.actions, action)?; + Some((action + 1, end)) +} +fn collect_action_expensive_values<'a>(action: &'a Action, out: &mut Vec<&'a Value>) { + match action { + Action::Call { args, .. } => { + for value in args { + collect_expensive_values(value, out); + } + } + Action::SetGlobalVariable { value, .. } | Action::ModifyGlobalVariable { value, .. } => { + collect_expensive_values(value, out) + } + Action::SetPlayerVariable { player, value, .. } + | Action::ModifyPlayerVariable { player, value, .. } => { + collect_expensive_values(player, out); + collect_expensive_values(value, out); + } + Action::AssignMember { target, value, .. } => { + collect_expensive_values(target, out); + collect_expensive_values(value, out); + } + Action::If { condition } | Action::ElseIf { condition } | Action::While { condition } => { + collect_expensive_values(condition, out) + } + Action::ForGlobalVariable { + start, stop, step, .. + } => { + collect_expensive_values(start, out); + collect_expensive_values(stop, out); + collect_expensive_values(step, out); + } + Action::ForPlayerVariable { + player, + start, + stop, + step, + .. + } => { + collect_expensive_values(player, out); + collect_expensive_values(start, out); + collect_expensive_values(stop, out); + collect_expensive_values(step, out); + } + Action::Disabled { action } => collect_action_expensive_values(action, out), + Action::CallSubroutine { .. } | Action::Else | Action::End => {} + } +} +fn values_equal(left: &Value, right: &Value) -> bool { + match (left, right) { + (Value::Number(a), Value::Number(b)) => a == b, + (Value::String(a), Value::String(b)) + | (Value::LocalizedString(a), Value::LocalizedString(b)) + | (Value::GlobalVariable(a), Value::GlobalVariable(b)) + | (Value::Subroutine(a), Value::Subroutine(b)) => a == b, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Null, Value::Null) | (Value::EventPlayer, Value::EventPlayer) => true, + (Value::Array(a), Value::Array(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| values_equal(a, b)) + } + (Value::Call { name: an, args: aa }, Value::Call { name: bn, args: ba }) => { + an == bn && aa.len() == ba.len() && aa.iter().zip(ba).all(|(a, b)| values_equal(a, b)) + } + ( + Value::Vector { + x: ax, + y: ay, + z: az, + }, + Value::Vector { + x: bx, + y: by, + z: bz, + }, + ) => values_equal(ax, bx) && values_equal(ay, by) && values_equal(az, bz), + ( + Value::Enum { + value_type: at, + value: av, + }, + Value::Enum { + value_type: bt, + value: bv, + }, + ) => at == bt && av == bv, + ( + Value::PlayerVariable { + player: ap, + variable: av, + }, + Value::PlayerVariable { + player: bp, + variable: bv, + }, + ) => av == bv && values_equal(ap, bp), + _ => false, + } +} +fn ongoing_condition_findings( + program: &Program, + rule_id: RuleId, + rule: &Rule, + value_ids: &HashMap, +) -> Vec { + if !matches!( + &rule.event, + Event::Global | Event::EachPlayer | Event::EachPlayerWithFilters { .. } + ) { + return Vec::new(); + } + + let active_conditions: Vec<_> = rule + .conditions + .iter() + .enumerate() + .filter(|(_, condition)| !condition.disabled) + .collect(); + let condition_count = active_conditions.len(); + let mut findings = Vec::new(); + for (index, &(source_index, condition)) in active_conditions.iter().enumerate() { + let mut expensive = Vec::new(); + collect_expensive_values(&condition.value, &mut expensive); + for value in expensive { + let preceding = index; + let later = condition_count - index - 1; + let evaluation = match preceding { + 0 => "is evaluated every server tick".to_string(), + 1 => "is evaluated only after 1 preceding condition passes".to_string(), + count => format!("is evaluated only after {count} preceding conditions pass"), + }; + let later_gates = if later == 0 { + String::new() + } else { + format!( + ", before {later} later short-circuit gate{}", + if later == 1 { "" } else { "s" } + ) + }; + let name = match value { + Value::Call { name, .. } => name, + _ => unreachable!("only expensive call values are collected"), + }; + let span = program.condition_span(rule_id, source_index); + findings.push(Finding { + code: "ongoing-condition-hot-path".into(), + severity: Severity::Info, + message: format!( + "geometry predicate in an ongoing-rule condition {} of {condition_count} {evaluation}{later_gates}; its cost is heuristic, not measured runtime load", + index + 1, + ), + span: value_occurrence(program, span, name).or(span), + rule: rule_id, + action: None, + value: value_ids.get(&(value as *const Value as usize)).copied(), + evidence: EvidenceClass::Heuristic, + boundedness: None, + }); + } + } + findings +} + +fn collect_expensive_values<'a>(value: &'a Value, out: &mut Vec<&'a Value>) { + match value { + Value::Call { name, args } => { + if ["distance", "raycast", "isInLoS"].contains(&name.as_str()) { + out.push(value); + } + for argument in args { + collect_expensive_values(argument, out); + } + } + Value::Array(values) => { + for value in values { + collect_expensive_values(value, out); + } + } + Value::Vector { x, y, z } => { + collect_expensive_values(x, out); + collect_expensive_values(y, out); + collect_expensive_values(z, out); + } + Value::PlayerVariable { player, .. } => collect_expensive_values(player, out), + _ => {} + } +} + +fn duplicate_condition_findings( + program: &Program, + rule_id: RuleId, + rule: &Rule, + value_ids: &HashMap, +) -> Vec { + let mut seen: Vec<&Value> = Vec::new(); + let mut findings = Vec::new(); + for (action_id, action) in rule.actions.iter().enumerate() { + let condition = match action { + Action::If { condition } + | Action::ElseIf { condition } + | Action::While { condition } => condition, + _ => continue, + }; + if seen + .iter() + .any(|previous| values_equal(previous, condition)) + { + let span = program.action_argument_span(rule_id, action_id, 0); + findings.push(Finding { + code: "duplicate-condition".into(), + severity: Severity::Warning, + message: "condition is evaluated more than once in this rule; a later branch can never be taken".into(), + span: span.or_else(|| program.action_span(rule_id, action_id)), + rule: rule_id, + action: Some(action_id), + value: value_ids.get(&(condition as *const Value as usize)).copied(), + evidence: EvidenceClass::Exact, + boundedness: None, + }); + } else { + seen.push(condition); + } + } + findings +} + +fn repeated_value_findings( + program: &Program, + rule_id: RuleId, + rule: &Rule, + loop_action: ActionId, + body_start: usize, + body: &[Action], + value_ids: &HashMap, +) -> Vec { + let mut values = Vec::new(); + let mut parents = Vec::new(); + let mut spans = Vec::new(); + if let Action::While { condition } = &rule.actions[loop_action] { + collect_value_tree( + condition, + None, + program.action_argument_span(rule_id, loop_action, 0), + &mut values, + &mut parents, + &mut spans, + ); + } + let mut action = 0; + while action < body.len() { + if matches!( + body[action], + Action::While { .. } + | Action::ForGlobalVariable { .. } + | Action::ForPlayerVariable { .. } + ) { + action = matching_end(body, action).map_or(action + 1, |end| end + 1); + continue; + } + let action_id = body_start + action; + visit_action_roots(&body[action], &mut |argument, value| { + collect_value_tree( + value, + None, + program.action_argument_span(rule_id, action_id, argument), + &mut values, + &mut parents, + &mut spans, + ); + }); + action += 1; + } + + duplicated_value_families(&values, &parents) + .into_iter() + .map(|family| { + let first = family[0]; + Finding { + code: "repeated-value".into(), + severity: Severity::Warning, + message: format!( + "this value expression is evaluated {} times within the same loop scope", + family.len() + ), + span: spans[first].or_else(|| program.action_span(rule_id, loop_action)), + rule: rule_id, + action: Some(loop_action), + value: value_ids + .get(&(values[first] as *const Value as usize)) + .copied(), + evidence: EvidenceClass::Exact, + boundedness: None, + } + }) + .collect() +} + +fn visit_action_roots<'a>(action: &'a Action, visit: &mut impl FnMut(usize, &'a Value)) { + match action { + Action::SetGlobalVariable { value, .. } | Action::ModifyGlobalVariable { value, .. } => { + visit(0, value) + } + Action::SetPlayerVariable { player, value, .. } + | Action::ModifyPlayerVariable { player, value, .. } => { + visit(0, player); + visit(1, value); + } + Action::AssignMember { target, value, .. } => { + visit(0, target); + visit(1, value); + } + Action::If { condition } | Action::ElseIf { condition } | Action::While { condition } => { + visit(0, condition); + } + Action::ForGlobalVariable { + start, stop, step, .. + } => { + visit(0, start); + visit(1, stop); + visit(2, step); + } + Action::ForPlayerVariable { + player, + start, + stop, + step, + .. + } => { + visit(0, player); + visit(1, start); + visit(2, stop); + visit(3, step); + } + Action::Call { args, .. } => { + for (index, value) in args.iter().enumerate() { + visit(index, value); + } + } + Action::CallSubroutine { .. } | Action::Else | Action::End | Action::Disabled { .. } => {} + } +} + +fn collect_value_tree<'a>( + value: &'a Value, + parent: Option, + span: Option, + values: &mut Vec<&'a Value>, + parents: &mut Vec>, + spans: &mut Vec>, +) { + let index = values.len(); + values.push(value); + parents.push(parent); + spans.push(span); + match value { + Value::Array(children) | Value::Call { args: children, .. } => { + for child in children { + collect_value_tree(child, Some(index), span, values, parents, spans); + } + } + Value::Vector { x, y, z } => { + for child in [x, y, z] { + collect_value_tree(child, Some(index), span, values, parents, spans); + } + } + Value::PlayerVariable { player, .. } => { + collect_value_tree(player, Some(index), span, values, parents, spans); + } + _ => {} + } +} + +fn duplicated_value_families(values: &[&Value], parents: &[Option]) -> Vec> { + let mut families: Vec<(usize, Vec)> = Vec::new(); + for (position, value) in values.iter().enumerate() { + if let Some((_, family)) = families + .iter_mut() + .find(|(_, family)| values_equal(values[family[0]], value)) + { + family.push(position); + } else { + families.push((position, vec![position])); + } + } + + let candidates: Vec = families + .iter() + .enumerate() + .filter(|(_, (_, family))| family.len() >= 2 && value_call_count(values[family[0]]) >= 2) + .map(|(index, _)| index) + .collect(); + let mut member_family = vec![None; values.len()]; + for &family in &candidates { + for &member in &families[family].1 { + member_family[member] = Some(family); + } + } + let mut reported: Vec = candidates + .into_iter() + .filter(|&family| { + !families[family].1.iter().any(|&member| { + let mut ancestor = parents[member]; + while let Some(index) = ancestor { + if member_family[index].is_some_and(|other| other != family) { + return true; + } + ancestor = parents[index]; + } + false + }) + }) + .collect(); + reported.sort_by_key(|&family| families[family].0); + reported + .into_iter() + .map(|family| std::mem::take(&mut families[family].1)) + .collect() +} + +fn value_call_count(value: &Value) -> usize { + match value { + Value::Call { args, .. } => 1 + args.iter().map(value_call_count).sum::(), + Value::Array(values) => values.iter().map(value_call_count).sum(), + Value::Vector { x, y, z } => { + value_call_count(x) + value_call_count(y) + value_call_count(z) + } + Value::PlayerVariable { player, .. } => value_call_count(player), + _ => 0, + } +} + +fn while_without_wait_message(boundedness: Boundedness) -> String { + match boundedness { + Boundedness::ObviouslyUnbounded => "loop body contains no wait call and the loop condition is statically true, so the loop repeats without yielding and never terminates on its own; it runs without bound while the rule is active (exact server impact is not statically measurable)".to_string(), + Boundedness::StaticallyBounded => "loop body contains no wait call; the loop is statically bounded by a counter against a literal bound, so it runs a finite number of back-to-back iterations".to_string(), + Boundedness::Unknown => "loop body contains no wait call and the loop's boundedness is unknown (data-dependent condition with no static counter pattern), so the loop may repeat without yielding".to_string(), + } +} + +fn while_boundedness( + condition: &Value, + body: &[Action], + subroutines: &[workshop_rs::Subroutine], +) -> Boundedness { + if matches!(condition, Value::Bool(true)) { + return Boundedness::ObviouslyUnbounded; + } + let Some((variable, toward)) = counter_comparison(condition) else { + return Boundedness::Unknown; + }; + let mut progresses = false; + for (start, end) in direct_action_ranges(body) { + if action_has_unprovable_loop(body, start, end, subroutines) { + return Boundedness::Unknown; + } + match modify_direction(&body[start], &variable) { + Some(direction) if direction == toward => progresses = true, + Some(_) => return Boundedness::Unknown, + None if region_writes(&body[start..end], &variable, subroutines) => { + return Boundedness::Unknown; + } + None => {} + } + } + if progresses { + Boundedness::StaticallyBounded + } else { + Boundedness::Unknown + } +} + +#[derive(Debug, Clone)] +enum CounterVariable { + Global(String), + Player { player: Value, variable: String }, +} + +fn counter_comparison(condition: &Value) -> Option<(CounterVariable, i32)> { + let Value::Call { name, args } = condition else { + return None; + }; + if args.len() != 2 { + return None; + } + let left_variable = variable_of(&args[0]); + let right_variable = variable_of(&args[1]); + let left_literal = matches!(&args[0], Value::Number(_)); + let right_literal = matches!(&args[1], Value::Number(_)); + let (variable, variable_is_left) = match (left_variable, right_literal) { + (Some(variable), true) => (variable, true), + (None, false) if left_literal => (right_variable?, false), + _ => return None, + }; + let direction = match (name.as_str(), variable_is_left) { + ("<", true) | ("<=", true) | (">", false) | (">=", false) => 1, + (">", true) | (">=", true) | ("<", false) | ("<=", false) => -1, + _ => return None, + }; + Some((variable, direction)) +} + +fn variable_of(value: &Value) -> Option { + match value { + Value::GlobalVariable(name) => Some(CounterVariable::Global(name.clone())), + Value::PlayerVariable { player, variable } => Some(CounterVariable::Player { + player: player.as_ref().clone(), + variable: variable.clone(), + }), + _ => None, + } +} + +fn modify_direction(action: &Action, variable: &CounterVariable) -> Option { + let (op, value) = match action { + Action::ModifyGlobalVariable { + variable: target, + op, + value, + } if matches!(variable, CounterVariable::Global(name) if name == target) => (op, value), + Action::ModifyPlayerVariable { + player, + variable: target, + op, + value, + } => { + let CounterVariable::Player { + player: expected_player, + variable: expected_variable, + } = variable + else { + return None; + }; + if target != expected_variable || !values_equal(player, expected_player) { + return None; + } + (op, value) + } + _ => return None, + }; + let Value::Number(step) = value else { + return None; + }; + if *step == 0.0 { + return None; + } + let sign = if *step > 0.0 { 1 } else { -1 }; + match op { + ModifyOp::Add => Some(sign), + ModifyOp::Subtract => Some(-sign), + _ => None, + } +} + +fn region_writes( + actions: &[Action], + variable: &CounterVariable, + subroutines: &[workshop_rs::Subroutine], +) -> bool { + actions + .iter() + .any(|action| action_writes(action, variable, subroutines)) +} + +fn action_writes( + action: &Action, + variable: &CounterVariable, + subroutines: &[workshop_rs::Subroutine], +) -> bool { + match action { + Action::SetGlobalVariable { + variable: target, .. + } + | Action::ModifyGlobalVariable { + variable: target, .. + } => { + matches!(variable, CounterVariable::Global(name) if name == target) + } + Action::SetPlayerVariable { + player, + variable: target, + .. + } + | Action::ModifyPlayerVariable { + player, + variable: target, + .. + } => { + matches!(variable, CounterVariable::Player { player: expected_player, variable: expected_variable } + if target == expected_variable && values_equal(player, expected_player)) + } + Action::CallSubroutine { .. } | Action::AssignMember { .. } => true, + Action::Call { name, .. } => subroutines + .iter() + .any(|subroutine| subroutine.name == *name), + Action::Disabled { action } => action_writes(action, variable, subroutines), + _ => false, + } +} + +fn action_has_unprovable_loop( + actions: &[Action], + start: usize, + end: usize, + subroutines: &[workshop_rs::Subroutine], +) -> bool { + let inner = if end > start + 1 { + &actions[start + 1..end - 1] + } else { + &[] + }; + match &actions[start] { + Action::While { condition } => { + while_boundedness(condition, inner, subroutines) != Boundedness::StaticallyBounded + || region_has_unprovable_loop(inner, subroutines) + } + Action::ForGlobalVariable { variable, step, .. } => { + let finite_step = step_direction(step).is_some(); + !finite_step + || region_writes( + inner, + &CounterVariable::Global(variable.clone()), + subroutines, + ) + || region_has_unprovable_loop(inner, subroutines) + } + Action::ForPlayerVariable { .. } => true, + Action::If { .. } => region_has_unprovable_loop(inner, subroutines), + _ => false, + } +} + +fn region_has_unprovable_loop(actions: &[Action], subroutines: &[workshop_rs::Subroutine]) -> bool { + direct_action_ranges(actions) + .into_iter() + .any(|(start, end)| action_has_unprovable_loop(actions, start, end, subroutines)) +} + +fn direct_action_ranges(actions: &[Action]) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + let mut start = 0; + while start < actions.len() { + let end = matching_end(actions, start).map_or(start + 1, |end| end + 1); + ranges.push((start, end)); + start = end; + } + ranges +} + +fn step_direction(step: &Value) -> Option { + let Value::Number(step) = step else { + return None; + }; + if *step == 0.0 { + None + } else { + Some(if *step > 0.0 { 1 } else { -1 }) + } +} diff --git a/crates/wright-analyzer/src/canonical/cfg.rs b/crates/wright-analyzer/src/canonical/cfg.rs new file mode 100644 index 0000000..5620f13 --- /dev/null +++ b/crates/wright-analyzer/src/canonical/cfg.rs @@ -0,0 +1,259 @@ +use serde_json::json; +use workshop_rs::{Action, Program}; + +use super::symbols::RuleId; +use crate::service::{ErrorInfo, Response}; + +pub(super) fn is_wait(action: &Action, minimum: bool) -> bool { + matches!(action, Action::Call { name, args } if name == "wait" && (!minimum || matches!(args.first(), Some(workshop_rs::Value::Number(value)) if *value <= 0.016))) +} + +pub(super) fn matching_end(actions: &[Action], start: usize) -> Option { + if !matches!( + actions.get(start), + Some( + Action::If { .. } + | Action::While { .. } + | Action::ForGlobalVariable { .. } + | Action::ForPlayerVariable { .. } + ) + ) { + return None; + } + let mut depth = 0; + for (index, action) in actions.iter().enumerate().skip(start + 1) { + match action { + Action::If { .. } + | Action::While { .. } + | Action::ForGlobalVariable { .. } + | Action::ForPlayerVariable { .. } => depth += 1, + Action::End if depth == 0 => return Some(index), + Action::End => depth -= 1, + _ => {} + } + } + None +} + +pub(super) fn cfg_response(program: &Program, rule: RuleId) -> Response { + let Some(data) = program.rules.get(rule) else { + return Response::Error { + error: ErrorInfo { + code: "invalid-id".into(), + message: format!("unknown rule {rule}"), + }, + }; + }; + let mut builder = CanonicalCfgBuilder { + program, + actions: &data.actions, + blocks: Vec::new(), + }; + let (entry, terminal) = builder.sequence(0, data.actions.len(), "entry"); + let exit = builder.new_block("exit"); + builder.edge(terminal, exit, "fallthrough"); + Response::Ok { + result: json!({ + "entry": entry, + "exit": exit, + "blocks": builder.blocks.iter().enumerate().map(|(id, block)| json!({ + "id": id, + "kind": block.kind, + "waits": block.waits, + "calls": block.calls, + "actions": block.actions, + "successors": block.successors.iter().map(|(to, kind)| json!({"to": to, "kind": kind})).collect::>(), + })).collect::>(), + }), + } +} + +struct CanonicalCfgBlock { + kind: &'static str, + waits: bool, + calls: Vec, + actions: Vec, + successors: Vec<(usize, &'static str)>, +} + +struct CanonicalCfgBuilder<'a> { + program: &'a Program, + actions: &'a [Action], + blocks: Vec, +} + +struct IfBranch { + condition_action: usize, + body_start: usize, + body_end: usize, +} + +struct IfParts { + close: usize, + branches: Vec, + else_body: Option<(usize, usize)>, +} + +impl CanonicalCfgBuilder<'_> { + fn new_block(&mut self, kind: &'static str) -> usize { + let id = self.blocks.len(); + self.blocks.push(CanonicalCfgBlock { + kind, + waits: false, + calls: Vec::new(), + actions: Vec::new(), + successors: Vec::new(), + }); + id + } + + fn edge(&mut self, from: usize, to: usize, kind: &'static str) { + self.blocks[from].successors.push((to, kind)); + } + + fn sequence(&mut self, start: usize, end: usize, entry_kind: &'static str) -> (usize, usize) { + let entry = self.new_block(entry_kind); + let mut current = entry; + let mut index = start; + while index < end { + if matches!(self.actions[index], Action::If { .. }) { + if let Some(parts) = if_parts(self.actions, index) { + let merge = self.new_block("block"); + let mut false_target = if let Some((else_start, else_end)) = parts.else_body { + let (else_entry, else_exit) = self.sequence(else_start, else_end, "block"); + self.edge(else_exit, merge, "fallthrough"); + Some(else_entry) + } else { + None + }; + for branch_data in parts.branches.into_iter().rev() { + let branch = self.new_block("if"); + self.blocks[branch] + .actions + .push(branch_data.condition_action); + let (body_entry, body_exit) = + self.sequence(branch_data.body_start, branch_data.body_end, "block"); + self.edge(branch, body_entry, "true"); + self.edge(body_exit, merge, "fallthrough"); + self.edge(branch, false_target.unwrap_or(merge), "false"); + false_target = Some(branch); + } + self.edge(current, false_target.unwrap_or(merge), "fallthrough"); + current = merge; + index = parts.close + 1; + continue; + } + } + if matches!( + self.actions[index], + Action::While { .. } + | Action::ForGlobalVariable { .. } + | Action::ForPlayerVariable { .. } + ) { + if let Some(close) = matching_end(self.actions, index) { + let kind = if matches!(self.actions[index], Action::While { .. }) { + "while" + } else { + "for" + }; + let header = self.new_block(kind); + self.blocks[header].actions.push(index); + self.edge(current, header, "fallthrough"); + let (body_entry, body_exit) = self.sequence(index + 1, close, "block"); + self.edge( + header, + body_entry, + if kind == "while" { + "true" + } else { + "fallthrough" + }, + ); + self.edge(body_exit, header, "back"); + let after = self.new_block("block"); + self.edge(header, after, "loop-exit"); + current = after; + index = close + 1; + continue; + } + } + match &self.actions[index] { + Action::Else | Action::ElseIf { .. } | Action::End => {} + action => { + let block = &mut self.blocks[current]; + block.actions.push(index); + if is_wait(action, false) { + block.waits = true; + } + if let Some(subroutine) = action_subroutine(self.program, action) { + block.calls.push(subroutine); + } + } + } + index += 1; + } + (entry, current) + } +} + +fn if_parts(actions: &[Action], start: usize) -> Option { + let close = matching_end(actions, start)?; + let mut branches = Vec::new(); + let mut condition_action = start; + let mut body_start = start + 1; + let mut index = body_start; + let mut else_body = None; + while index < close { + match actions[index] { + Action::ElseIf { .. } => { + branches.push(IfBranch { + condition_action, + body_start, + body_end: index, + }); + condition_action = index; + body_start = index + 1; + } + Action::Else => { + branches.push(IfBranch { + condition_action, + body_start, + body_end: index, + }); + else_body = Some((index + 1, close)); + break; + } + _ => { + if let Some(nested_close) = matching_end(actions, index) { + index = nested_close + 1; + continue; + } + } + } + index += 1; + } + if else_body.is_none() { + branches.push(IfBranch { + condition_action, + body_start, + body_end: close, + }); + } + Some(IfParts { + close, + branches, + else_body, + }) +} + +fn action_subroutine(program: &Program, action: &Action) -> Option { + let name = match action { + Action::CallSubroutine { subroutine } => subroutine, + Action::Call { name, .. } => name, + _ => return None, + }; + program + .subroutines + .iter() + .position(|subroutine| subroutine.name == *name) +} diff --git a/crates/wright-analyzer/src/canonical/facts.rs b/crates/wright-analyzer/src/canonical/facts.rs new file mode 100644 index 0000000..e6aefdf --- /dev/null +++ b/crates/wright-analyzer/src/canonical/facts.rs @@ -0,0 +1,93 @@ +use super::service::span_json; +use serde_json::{Value as JsonValue, json}; +use workshop_rs::{Action, Event, Program, Value}; + +pub(super) fn persistent_objects(program: &Program) -> Vec { + let mut output = Vec::new(); + for (rule, data) in program.rules.iter().enumerate() { + for (action, action_data) in data.actions.iter().enumerate() { + let Action::Call { name, args } = action_data else { + continue; + }; + let Some(kind) = persistent_object_kind(name) else { + continue; + }; + let cleanup = match kind { + "hud-text" => "destroyHudText", + "in-world-text" => "destroyInWorldText", + "effect" => "destroyEffect", + _ => continue, + }; + let reevaluation_index = if kind == "hud-text" { 9 } else { 5 }; + let reevaluation = args.get(reevaluation_index).and_then(|value| match value { + Value::Enum { value_type, value } => { + Some(json!({"domain": value_type, "mode": value})) + } + _ => None, + }); + let identity = match kind { + "hud-text" | "in-world-text" => "lastTextId", + "effect" => "lastCreatedEntity", + _ => unreachable!(), + }; + let identity_retained = data + .actions + .get(action + 1) + .is_some_and(|next| action_retains_identity(next, identity)); + let span = span_json(program.action_span(rule, action)); + output.push(json!({ + "kind": kind, + "rule": rule, + "action": action, + "executionScope": execution_scope(&data.event), + "visibility": object_visibility(args), + "reevaluation": reevaluation, + "identityRetained": identity_retained, + "sameKindCleanupInRule": data.actions.iter().any(|action| matches!(action, Action::Call { name, .. } if name == cleanup)), + "span": span, + })); + } + } + output +} + +fn persistent_object_kind(name: &str) -> Option<&'static str> { + match name { + "createHudText" => Some("hud-text"), + "createInWorldText" => Some("in-world-text"), + "createEffect" => Some("effect"), + _ => None, + } +} + +fn execution_scope(event: &Event) -> &'static str { + match event { + Event::Global => "global", + Event::EachPlayer | Event::EachPlayerWithFilters { .. } | Event::Player { .. } => { + "per-player" + } + Event::Subroutine(_) => "subroutine", + } +} + +fn action_retains_identity(action: &Action, identity: &str) -> bool { + let value = match action { + Action::SetGlobalVariable { value, .. } + | Action::SetPlayerVariable { value, .. } + | Action::AssignMember { value, .. } => value, + _ => return false, + }; + matches!(value, Value::Call { name, args } if name == identity && args.is_empty()) +} + +fn object_visibility(args: &[Value]) -> &'static str { + match args.first() { + Some(Value::EventPlayer) => "event-player", + Some(Value::Array(_)) => "explicit-set", + Some(Value::Call { name, .. }) if name == "allPlayers" => "all-players", + Some(Value::Call { .. } | Value::PlayerVariable { .. } | Value::GlobalVariable(_)) => { + "dynamic" + } + _ => "unknown", + } +} diff --git a/crates/wright-analyzer/src/canonical/service.rs b/crates/wright-analyzer/src/canonical/service.rs new file mode 100644 index 0000000..d8be26c --- /dev/null +++ b/crates/wright-analyzer/src/canonical/service.rs @@ -0,0 +1,217 @@ +use std::sync::Arc; + +use serde_json::{Value as JsonValue, json}; +use workshop_rs::source::{FileId, Span}; +use workshop_rs::{Event, Program}; + +use super::analysis::Finding; +use super::cfg::cfg_response; +use super::facts::persistent_objects; +use super::symbols::{ReferenceKind, RuleId, SemanticIndex, Symbol, SymbolId, SymbolKind}; +use crate::analysis::{Boundedness, Severity}; +use crate::registry::{LintConfig, SkippedRule}; +use crate::service::{ErrorInfo, Origin, Request, Response}; + +pub struct SemanticService<'a> { + program: &'a Program, + index: SemanticIndex, + findings: Vec, + skipped: Vec, + origin: Origin, + config: LintConfig, + registry: Arc, +} + +impl<'a> SemanticService<'a> { + pub fn new(program: &'a Program) -> Self { + Self::with_origin( + program, + Origin { + kind: "unknown".to_string(), + locale: None, + }, + ) + } + pub fn from_workshop(program: &'a Program, locale: &str) -> Self { + Self::with_origin( + program, + Origin { + kind: "workshop".to_string(), + locale: Some(workshop_rs::catalog::Locale::new(locale).to_string()), + }, + ) + } + pub fn with_origin(program: &'a Program, origin: Origin) -> Self { + Self::with_origin_and_config(program, origin, LintConfig::default()) + } + pub fn with_origin_and_config( + program: &'a Program, + origin: Origin, + config: LintConfig, + ) -> Self { + Self::with_origin_and_config_and_registry( + program, + origin, + config, + Arc::new(crate::registry::LintRegistry::default()), + ) + } + pub fn with_origin_and_config_and_registry( + program: &'a Program, + origin: Origin, + config: LintConfig, + registry: Arc, + ) -> Self { + let index = SemanticIndex::build(program); + let report = registry.run_report(program, &config); + Self { + program, + index, + findings: report.findings, + skipped: report.skipped, + origin, + config, + registry, + } + } + pub fn handle_json(&self, request_json: &str) -> String { + let request: Request = match serde_json::from_str(request_json) { + Ok(req) => req, + Err(err) => { + return serde_json::to_string(&Response::Error { + error: ErrorInfo { + code: "invalid-json".to_string(), + message: format!("could not parse request JSON: {err}"), + }, + }) + .expect("error response serializes"); + } + }; + let response = self.handle(&request); + serde_json::to_string(&response).expect("response serializes") + } + pub fn handle(&self, request: &Request) -> Response { + match request { + Request::Version => Response::Ok { result: json!({"name": "wright-tool", "version": env!("CARGO_PKG_VERSION"), "capabilities": ["program", "rules", "symbols", "references", "usage", "cfg", "findings", "persistentObjects", "lintRules"]}) }, + Request::Program => Response::Ok { result: json!({"origin": self.origin, "files": file_count(self.program), "globalVariables": self.program.global_variables.len(), "playerVariables": self.program.player_variables.len(), "subroutines": self.program.subroutines.len(), "rules": self.program.rules.len(), "findings": self.findings.len()}) }, + Request::ListRules => Response::Ok { result: json!(self.program.rules.iter().enumerate().map(|(id, rule)| json!({"id": id, "name": rule.name, "span": span_json(self.program.rule_span(id))})).collect::>()) }, + Request::GetRule { rule } => self.rule(*rule as usize), + Request::ListSymbols { kind } => Response::Ok { result: json!(self.index.symbols().filter(|symbol| kind.as_deref().is_none_or(|kind| symbol_kind_name(symbol.kind) == kind)).map(symbol_json).collect::>()) }, + Request::GetSymbol { symbol } => self.index.symbol(SymbolId::from_index(*symbol as usize)).map_or_else(|| self.error("invalid-id", format!("unknown symbol {symbol}")), |symbol| Response::Ok { result: symbol_json(symbol) }), + Request::FindReferences { symbol } => { let id = SymbolId::from_index(*symbol as usize); if self.index.symbol(id).is_none() { self.error("invalid-id", format!("unknown symbol {symbol}")) } else { Response::Ok { result: json!(self.index.references(id).into_iter().map(|reference| json!({"kind": reference_kind_name(reference.kind), "span": span_json(reference.span), "rule": reference.rule, "action": reference.action, "value": reference.value})).collect::>()) } } } + Request::GetUsage { symbol } => { let id = SymbolId::from_index(*symbol as usize); self.index.symbol(id).map_or_else(|| self.error("invalid-id", format!("unknown symbol {symbol}")), |data| { let usage = self.index.usage(id); Response::Ok { result: json!({"symbol": data.name, "reads": usage.reads, "writes": usage.writes, "calls": usage.calls, "rules": usage.rules}) } }) } + Request::GetCfg { rule } => cfg_response(self.program, *rule as usize), + Request::GetFindings => Response::Ok { result: json!(self.findings.iter().map(finding_json).collect::>()) }, + Request::GetPersistentObjects => Response::Ok { result: json!(persistent_objects(self.program)) }, + Request::LintRules => Response::Ok { + result: lint_rules(&self.registry, &self.config, &self.skipped), + } + } + } + fn rule(&self, id: RuleId) -> Response { + let Some(rule) = self.program.rules.get(id) else { + return self.error("invalid-id", format!("unknown rule {id}")); + }; + Response::Ok { + result: json!({"id": id, "name": rule.name, "span": span_json(self.program.rule_span(id)), "disabled": rule.disabled, "event": event_name(&rule.event), "conditions": rule.conditions.len(), "actions": rule.actions.len()}), + } + } + fn error(&self, code: &str, message: String) -> Response { + Response::Error { + error: ErrorInfo { + code: code.to_string(), + message, + }, + } + } +} + +fn lint_rules( + registry: &crate::registry::LintRegistry, + config: &LintConfig, + skipped: &[SkippedRule], +) -> JsonValue { + let descriptors = registry.descriptors(config); + let rules = descriptors + .iter() + .map(|rule| { + json!({ + "id": rule.id, + "defaultSeverity": rule.default_severity, + "effectiveSeverity": rule.effective_severity, + "enabled": rule.enabled, + "summary": rule.summary, + "rationale": rule.rationale, + "documentation": rule.documentation, + "knownLimits": rule.known_limits, + "evidence": rule.evidence, + "tags": rule.tags, + "kind": rule.kind, + }) + }) + .collect::>(); + let config_rules = descriptors + .iter() + .map(|rule| { + ( + rule.id.clone(), + json!({ + "enabled": rule.enabled, + "severity": severity_name(rule.effective_severity), + "options": config.options(&rule.id), + }), + ) + }) + .collect::>(); + json!({ + "rules": rules, + "config": {"rules": config_rules}, + "skipped": skipped, + }) +} +fn file_count(program: &Program) -> usize { + let mut count = 0; + while program.source(FileId::from_index(count)).is_some() { + count += 1; + } + count +} +pub(super) fn span_json(span: Option) -> JsonValue { + span.map_or(JsonValue::Null, |span| json!({"file": span.file.index(), "start": {"line": span.start.line, "col": span.start.col}, "end": {"line": span.end.line, "col": span.end.col}})) +} +fn symbol_json(symbol: &Symbol) -> JsonValue { + json!({"id": symbol.id.index(), "kind": symbol_kind_name(symbol.kind), "name": symbol.name, "span": span_json(symbol.span)}) +} +fn finding_json(finding: &Finding) -> JsonValue { + json!({"code": finding.code, "severity": severity_name(finding.severity), "message": finding.message, "span": span_json(finding.span), "rule": finding.rule, "action": finding.action, "value": finding.value, "evidence": finding.evidence.as_str(), "boundedness": finding.boundedness.map(Boundedness::as_str)}) +} +fn symbol_kind_name(kind: SymbolKind) -> &'static str { + match kind { + SymbolKind::GlobalVariable => "globalVariable", + SymbolKind::PlayerVariable => "playerVariable", + SymbolKind::Subroutine => "subroutine", + SymbolKind::Rule => "rule", + } +} +fn reference_kind_name(kind: ReferenceKind) -> &'static str { + match kind { + ReferenceKind::Declaration => "declaration", + ReferenceKind::Definition => "definition", + ReferenceKind::Read => "read", + ReferenceKind::Write => "write", + ReferenceKind::Call => "call", + } +} +fn severity_name(severity: Severity) -> &'static str { + match severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + } +} +fn event_name(event: &Event) -> String { + match event { + Event::Subroutine(name) => format!("subroutine:{name}"), + _ => crate::declarative::public_event_id(event).to_string(), + } +} diff --git a/crates/wright-analyzer/src/canonical/symbols.rs b/crates/wright-analyzer/src/canonical/symbols.rs new file mode 100644 index 0000000..e031e34 --- /dev/null +++ b/crates/wright-analyzer/src/canonical/symbols.rs @@ -0,0 +1,956 @@ +use std::collections::{HashMap, HashSet}; + +use workshop_rs::source::Span; +use workshop_rs::{Action, Event, Program, Value}; + +pub type RuleId = usize; +pub type ActionId = usize; +pub type ValueId = usize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SymbolId(pub usize); + +impl SymbolId { + pub const fn from_index(index: usize) -> Self { + Self(index) + } + + pub const fn index(self) -> usize { + self.0 + } +} + +fn append_named_symbols<'a>( + symbols: &mut Vec, + program: &Program, + kind: SymbolKind, + prefix: &str, + names: impl Iterator, +) { + for name in names { + let id = SymbolId::from_index(symbols.len()); + let occurrence = declaration_span(program, prefix, name); + symbols.push(Symbol { + id, + kind, + name: name.to_string(), + span: occurrence, + occurrence, + rule: None, + }); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SymbolKind { + GlobalVariable, + PlayerVariable, + Subroutine, + Rule, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReferenceKind { + Declaration, + Definition, + Read, + Write, + Call, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Symbol { + pub id: SymbolId, + pub kind: SymbolKind, + pub name: String, + pub span: Option, + pub occurrence: Option, + pub rule: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reference { + pub symbol: SymbolId, + pub kind: ReferenceKind, + pub span: Option, + pub occurrence: Option, + pub rule: Option, + pub action: Option, + pub value: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct UsageSummary { + pub reads: u32, + pub writes: u32, + pub calls: u32, + pub rules: u32, +} + +#[derive(Debug, Clone)] +pub struct SemanticIndex { + symbols: Vec, + references: Vec, + value_ids: HashMap, +} + +pub(super) fn value_identity_map(program: &Program) -> HashMap { + fn visit_value(value: &Value, identities: &mut HashMap) { + let id = identities.len(); + identities.insert((value as *const Value) as usize, id); + match value { + Value::PlayerVariable { player, .. } => visit_value(player, identities), + Value::Array(values) | Value::Call { args: values, .. } => { + for value in values { + visit_value(value, identities); + } + } + Value::Vector { x, y, z } => { + visit_value(x, identities); + visit_value(y, identities); + visit_value(z, identities); + } + _ => {} + } + } + + fn visit_action(action: &Action, identities: &mut HashMap) { + match action { + Action::SetGlobalVariable { value, .. } + | Action::ModifyGlobalVariable { value, .. } => visit_value(value, identities), + Action::SetPlayerVariable { player, value, .. } + | Action::ModifyPlayerVariable { player, value, .. } => { + visit_value(player, identities); + visit_value(value, identities); + } + Action::AssignMember { target, value, .. } => { + visit_value(target, identities); + visit_value(value, identities); + } + Action::If { condition } + | Action::ElseIf { condition } + | Action::While { condition } => visit_value(condition, identities), + Action::ForGlobalVariable { + start, stop, step, .. + } => { + visit_value(start, identities); + visit_value(stop, identities); + visit_value(step, identities); + } + Action::ForPlayerVariable { + player, + start, + stop, + step, + .. + } => { + visit_value(player, identities); + visit_value(start, identities); + visit_value(stop, identities); + visit_value(step, identities); + } + Action::Call { args, .. } => { + for value in args { + visit_value(value, identities); + } + } + Action::Disabled { action } => visit_action(action, identities), + Action::CallSubroutine { .. } | Action::Else | Action::End => {} + } + } + + let mut identities = HashMap::new(); + for rule in &program.rules { + for condition in &rule.conditions { + visit_value(&condition.value, &mut identities); + } + for action in &rule.actions { + visit_action(action, &mut identities); + } + } + identities +} + +impl SemanticIndex { + pub fn build(program: &Program) -> Self { + let mut symbols = Vec::new(); + append_named_symbols( + &mut symbols, + program, + SymbolKind::GlobalVariable, + "globalvar ", + program + .global_variables + .iter() + .map(|variable| variable.name.as_str()), + ); + append_named_symbols( + &mut symbols, + program, + SymbolKind::PlayerVariable, + "playervar ", + program + .player_variables + .iter() + .map(|variable| variable.name.as_str()), + ); + append_named_symbols( + &mut symbols, + program, + SymbolKind::Subroutine, + "subroutine ", + program + .subroutines + .iter() + .map(|subroutine| subroutine.name.as_str()), + ); + for (rule, data) in program.rules.iter().enumerate() { + let id = SymbolId::from_index(symbols.len()); + symbols.push(Symbol { + id, + kind: SymbolKind::Rule, + name: data.name.clone(), + span: program.rule_span(rule), + occurrence: program.rule_span(rule), + rule: Some(rule), + }); + } + let mut index = Self { + symbols, + references: Vec::new(), + value_ids: value_identity_map(program), + }; + for symbol in index.symbols.clone() { + if let Some(span) = symbol.occurrence { + index.push( + symbol.id, + ReferenceKind::Declaration, + symbol.rule, + None, + None, + Some(span), + ); + } + } + for (rule, data) in program.rules.iter().enumerate() { + index.walk_event(&data.event, rule, program); + for (condition, value) in data.conditions.iter().enumerate() { + index.walk_value( + &value.value, + rule, + None, + Some(condition), + program.condition_span(rule, condition), + program, + ); + } + for (action, value) in data.actions.iter().enumerate() { + index.walk_action(value, rule, action, program); + } + } + index.value_ids.clear(); + index + } + + pub fn build_with_sources( + program: &Program, + sources: &[(workshop_rs::source::FileId, String)], + ) -> Self { + let mut index = Self::build(program); + for symbol in &mut index.symbols { + let prefixes = match symbol.kind { + SymbolKind::GlobalVariable => &["globalvar "][..], + SymbolKind::PlayerVariable => &["playervar "][..], + SymbolKind::Subroutine => &["subroutine "][..], + SymbolKind::Rule => &[][..], + }; + if let Some(span) = declaration_span_in_sources(sources, prefixes, &symbol.name) { + symbol.span = Some(declaration_line_span(sources, span)); + symbol.occurrence = Some(span); + } + } + for symbol in index.symbols.clone() { + if let Some(span) = symbol.occurrence { + if !index.references.iter().any(|reference| { + reference.symbol == symbol.id && reference.kind == ReferenceKind::Declaration + }) { + index.push( + symbol.id, + ReferenceKind::Declaration, + symbol.rule, + None, + None, + Some(span), + ); + } + } + } + let symbols = index.symbols.clone(); + let mut read_occurrences = HashMap::new(); + for reference in &mut index.references { + let symbol = symbols + .get(reference.symbol.index()) + .cloned() + .expect("reference symbol exists"); + let span = match reference.kind { + ReferenceKind::Declaration => symbol.occurrence, + ReferenceKind::Definition => reference + .rule + .and_then(|rule| program.rule_span(rule)) + .and_then(|span| occurrence_in_sources(sources, span, &symbol.name, false, 0)), + ReferenceKind::Write | ReferenceKind::Call => reference + .rule + .and_then(|rule| { + reference + .action + .and_then(|action| program.action_span(rule, action)) + }) + .and_then(|span| occurrence_in_sources(sources, span, &symbol.name, true, 0)), + ReferenceKind::Read => { + let key = ( + reference.symbol, + reference.rule, + reference.action, + reference.value, + ); + let ordinal = read_occurrences.entry(key).or_insert(0); + let action_span = reference.rule.and_then(|rule| { + reference + .action + .and_then(|action| program.action_span(rule, action)) + }); + let implicit_modify = reference.value.is_none() + && reference.span.is_some() + && reference.span == action_span; + let current = if implicit_modify { 1 } else { *ordinal }; + *ordinal += 1; + reference + .span + .or_else(|| { + action_span.or_else(|| { + reference.rule.and_then(|rule| { + reference + .value + .and_then(|value| program.condition_span(rule, value)) + }) + }) + }) + .and_then(|span| { + occurrence_in_sources(sources, span, &symbol.name, false, current) + }) + } + }; + reference.span = span; + reference.occurrence = span; + } + index + } + + pub fn symbols(&self) -> impl Iterator { + self.symbols.iter() + } + pub fn symbol(&self, id: SymbolId) -> Option<&Symbol> { + self.symbols.get(id.index()) + } + pub fn references(&self, symbol: SymbolId) -> Vec<&Reference> { + self.references + .iter() + .filter(|reference| reference.symbol == symbol) + .collect() + } + pub fn usage(&self, symbol: SymbolId) -> UsageSummary { + let mut usage = UsageSummary::default(); + let mut rules = HashSet::new(); + for reference in self.references(symbol) { + match reference.kind { + ReferenceKind::Read => usage.reads += 1, + ReferenceKind::Write => usage.writes += 1, + ReferenceKind::Call => usage.calls += 1, + _ => {} + } + if let Some(rule) = reference.rule { + rules.insert(rule); + } + } + usage.rules = rules.len() as u32; + usage + } + + fn push( + &mut self, + symbol: SymbolId, + kind: ReferenceKind, + rule: Option, + action: Option, + value: Option, + span: Option, + ) { + self.references.push(Reference { + symbol, + kind, + span, + occurrence: span, + rule, + action, + value, + }); + } + fn find_symbol(&self, kind: SymbolKind, name: &str) -> Option { + self.symbols + .iter() + .find(|symbol| symbol.kind == kind && symbol.name == name) + .map(|s| s.id) + } + fn walk_event(&mut self, event: &Event, rule: RuleId, program: &Program) { + if let Event::Subroutine(name) = event { + if let Some(symbol) = self.find_symbol(SymbolKind::Subroutine, name) { + self.push( + symbol, + ReferenceKind::Definition, + Some(rule), + None, + None, + action_occurrence(program, program.rule_span(rule), name), + ); + } + } + } + fn walk_action( + &mut self, + action: &Action, + rule: RuleId, + action_id: ActionId, + program: &Program, + ) { + let span = program.action_span(rule, action_id); + match action { + Action::SetGlobalVariable { variable, value } + | Action::ModifyGlobalVariable { + variable, value, .. + } => { + if let Some(symbol) = self.find_symbol(SymbolKind::GlobalVariable, variable) { + self.push( + symbol, + ReferenceKind::Write, + Some(rule), + Some(action_id), + None, + action_occurrence(program, span, variable), + ); + if matches!(action, Action::ModifyGlobalVariable { .. }) { + self.push( + symbol, + ReferenceKind::Read, + Some(rule), + Some(action_id), + None, + span, + ); + } + } + self.walk_value( + value, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 0), + program, + ); + } + Action::SetPlayerVariable { + player, + variable, + value, + } + | Action::ModifyPlayerVariable { + player, + variable, + value, + .. + } => { + if let Some(symbol) = self.find_symbol(SymbolKind::PlayerVariable, variable) { + self.push( + symbol, + ReferenceKind::Write, + Some(rule), + Some(action_id), + None, + action_occurrence(program, span, variable), + ); + if matches!(action, Action::ModifyPlayerVariable { .. }) { + self.push( + symbol, + ReferenceKind::Read, + Some(rule), + Some(action_id), + None, + span, + ); + } + } + self.walk_value( + player, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 0), + program, + ); + self.walk_value( + value, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 1), + program, + ); + } + Action::AssignMember { target, value, .. } => { + self.walk_value( + target, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 0), + program, + ); + self.walk_value( + value, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 1), + program, + ); + } + Action::CallSubroutine { subroutine } => { + if let Some(symbol) = self.find_symbol(SymbolKind::Subroutine, subroutine) { + self.push( + symbol, + ReferenceKind::Call, + Some(rule), + Some(action_id), + None, + span, + ); + } + } + Action::If { condition } + | Action::ElseIf { condition } + | Action::While { condition } => self.walk_value( + condition, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 0), + program, + ), + Action::ForGlobalVariable { + variable, + start, + stop, + step, + } => { + if let Some(symbol) = self.find_symbol(SymbolKind::GlobalVariable, variable) { + self.push( + symbol, + ReferenceKind::Write, + Some(rule), + Some(action_id), + None, + action_occurrence(program, span, variable), + ); + } + self.walk_value( + start, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 0), + program, + ); + self.walk_value( + stop, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 1), + program, + ); + self.walk_value( + step, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 2), + program, + ); + } + Action::ForPlayerVariable { + player, + variable, + start, + stop, + step, + } => { + if let Some(symbol) = self.find_symbol(SymbolKind::PlayerVariable, variable) { + self.push( + symbol, + ReferenceKind::Write, + Some(rule), + Some(action_id), + None, + action_occurrence(program, span, variable), + ); + } + self.walk_value( + player, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 0), + program, + ); + self.walk_value( + start, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 1), + program, + ); + self.walk_value( + stop, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 2), + program, + ); + self.walk_value( + step, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, 3), + program, + ); + } + Action::Disabled { action } => self.walk_action(action, rule, action_id, program), + Action::Call { args, .. } => { + for (argument, value) in args.iter().enumerate() { + self.walk_value( + value, + rule, + Some(action_id), + None, + program.action_argument_span(rule, action_id, argument), + program, + ); + } + } + Action::Else | Action::End => {} + } + } + fn walk_value( + &mut self, + value: &Value, + rule: RuleId, + action: Option, + _value_id: Option, + span: Option, + program: &Program, + ) { + let value_id = self + .value_ids + .get(&((value as *const Value) as usize)) + .copied(); + match value { + Value::GlobalVariable(name) => { + if let Some(symbol) = self.find_symbol(SymbolKind::GlobalVariable, name) { + self.push( + symbol, + ReferenceKind::Read, + Some(rule), + action, + value_id, + value_occurrence(program, span, name).or(span), + ); + } + } + Value::PlayerVariable { player, variable } => { + if let Some(symbol) = self.find_symbol(SymbolKind::PlayerVariable, variable) { + self.push( + symbol, + ReferenceKind::Read, + Some(rule), + action, + value_id, + value_occurrence(program, span, variable).or(span), + ); + } + self.walk_value(player, rule, action, value_id, span, program); + } + Value::Array(values) | Value::Call { args: values, .. } => { + for value in values { + self.walk_value(value, rule, action, value_id, span, program); + } + } + Value::Vector { x, y, z } => { + self.walk_value(x, rule, action, value_id, span, program); + self.walk_value(y, rule, action, value_id, span, program); + self.walk_value(z, rule, action, value_id, span, program); + } + _ => {} + } + } +} + +fn declaration_span(program: &Program, prefix: &str, name: &str) -> Option { + for file_index in 0..64 { + let file = workshop_rs::source::FileId::from_index(file_index); + let Some(source) = program.source(file) else { + continue; + }; + for (line_index, line) in source.text().lines().enumerate() { + let Some(rest) = line.strip_prefix(prefix) else { + continue; + }; + let Some(found) = rest.split_whitespace().next() else { + continue; + }; + let found = found.trim_matches('"'); + if found != name { + continue; + } + let start = prefix.chars().count() as u32 + 1; + return Some(Span::new( + file, + workshop_rs::source::Position::new(line_index as u32 + 1, start), + workshop_rs::source::Position::new( + line_index as u32 + 1, + start + name.chars().count() as u32, + ), + )); + } + } + None +} + +fn declaration_span_in_sources( + sources: &[(workshop_rs::source::FileId, String)], + prefixes: &[&str], + name: &str, +) -> Option { + for (file, source) in sources { + for (line_index, line) in source.lines().enumerate() { + for prefix in prefixes { + let Some(rest) = line.strip_prefix(prefix) else { + continue; + }; + let Some(found) = rest.split_whitespace().next() else { + continue; + }; + let found = found.trim_matches('"'); + if found != name { + continue; + } + let start = prefix.chars().count() as u32 + 1; + return Some(Span::new( + *file, + workshop_rs::source::Position::new(line_index as u32 + 1, start), + workshop_rs::source::Position::new( + line_index as u32 + 1, + start + name.chars().count() as u32, + ), + )); + } + } + } + None +} + +fn declaration_line_span( + sources: &[(workshop_rs::source::FileId, String)], + name_span: Span, +) -> Span { + let end_col = sources + .iter() + .find(|(file, _)| *file == name_span.file) + .and_then(|(_, source)| { + source + .lines() + .nth(name_span.start.line.saturating_sub(1) as usize) + .map(|line| line.chars().count() as u32 + 1) + }) + .unwrap_or(name_span.end.col); + Span::new( + name_span.file, + workshop_rs::source::Position::new(name_span.start.line, 1), + workshop_rs::source::Position::new(name_span.start.line, end_col), + ) +} + +fn occurrence_in_sources( + sources: &[(workshop_rs::source::FileId, String)], + span: Span, + name: &str, + before_assignment: bool, + ordinal: usize, +) -> Option { + let source = sources + .iter() + .find(|(file, _)| *file == span.file) + .map(|(_, source)| source.as_str())?; + let name_chars: Vec = name.chars().collect(); + let mut found_index = 0; + for line_number in span.start.line..=span.end.line { + let line = source.lines().nth(line_number.saturating_sub(1) as usize)?; + let chars: Vec = line.chars().collect(); + let lower = if line_number == span.start.line { + span.start.col.saturating_sub(1) as usize + } else { + 0 + }; + let mut upper = if line_number == span.end.line { + span.end.col.saturating_sub(1) as usize + } else { + chars.len() + }; + if before_assignment { + if let Some(operator) = line.find('=') { + upper = upper.min(operator); + } + } + for start in lower.min(chars.len())..=upper.min(chars.len()) { + let end = start.saturating_add(name_chars.len()); + if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { + continue; + } + if !is_code_position(&chars, start) { + continue; + } + let before = start.checked_sub(1).and_then(|index| chars.get(index)); + let after = chars.get(end); + if before.is_some_and(|character| character.is_alphanumeric() || *character == '_') + || after.is_some_and(|character| character.is_alphanumeric() || *character == '_') + { + continue; + } + if found_index == ordinal { + return Some(Span::new( + span.file, + workshop_rs::source::Position::new(line_number, start as u32 + 1), + workshop_rs::source::Position::new(line_number, end as u32 + 1), + )); + } + found_index += 1; + } + } + None +} + +fn is_code_position(chars: &[char], position: usize) -> bool { + let mut quoted = false; + let mut escaped = false; + for character in chars.iter().take(position) { + if *character == '#' && !quoted { + return false; + } + if *character == '"' && !escaped { + quoted = !quoted; + } + escaped = *character == '\\' && !escaped; + if *character != '\\' { + escaped = false; + } + } + !quoted +} + +fn action_occurrence(program: &Program, span: Option, name: &str) -> Option { + let span = span?; + let Some(source_doc) = program.source(span.file) else { + return Some(span); + }; + let source = source_doc.text(); + let name_chars: Vec = name.chars().collect(); + for line_number in span.start.line..=span.end.line { + let line = source.lines().nth(line_number.saturating_sub(1) as usize)?; + let chars: Vec = line.chars().collect(); + let lower = if line_number == span.start.line { + span.start.col.saturating_sub(1) as usize + } else { + 0 + }; + let mut upper = if line_number == span.end.line { + span.end.col.saturating_sub(1) as usize + } else { + chars.len() + }; + if let Some(operator) = line.find('=') { + upper = upper.min(operator); + } + for start in lower.min(chars.len())..=upper.min(chars.len()) { + let end = start.saturating_add(name_chars.len()); + if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { + continue; + } + let before = start.checked_sub(1).and_then(|index| chars.get(index)); + let after = chars.get(end); + if before.is_some_and(|character| character.is_alphanumeric() || *character == '_') + || after.is_some_and(|character| character.is_alphanumeric() || *character == '_') + { + continue; + } + return Some(Span::new( + span.file, + workshop_rs::source::Position::new(line_number, start as u32 + 1), + workshop_rs::source::Position::new(line_number, end as u32 + 1), + )); + } + } + Some(span) +} + +pub(super) fn value_occurrence(program: &Program, span: Option, name: &str) -> Option { + let span = span?; + let Some(source_doc) = program.source(span.file) else { + return Some(span); + }; + let source = source_doc.text(); + let name_chars: Vec = name.chars().collect(); + for line_number in span.start.line..=span.end.line { + let line = source.lines().nth(line_number.saturating_sub(1) as usize)?; + let chars: Vec = line.chars().collect(); + let lower = if line_number == span.start.line { + span.start.col.saturating_sub(1) as usize + } else { + 0 + }; + let upper = if line_number == span.end.line { + span.end.col.saturating_sub(1) as usize + } else { + chars.len() + }; + for start in lower.min(chars.len())..=upper.min(chars.len()) { + let end = start.saturating_add(name_chars.len()); + if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { + continue; + } + let before = start.checked_sub(1).and_then(|index| chars.get(index)); + let after = chars.get(end); + if before.is_some_and(|character| character.is_alphanumeric() || *character == '_') + || after.is_some_and(|character| character.is_alphanumeric() || *character == '_') + { + continue; + } + return Some(Span::new( + span.file, + workshop_rs::source::Position::new(line_number, start as u32 + 1), + workshop_rs::source::Position::new(line_number, end as u32 + 1), + )); + } + } + Some(span) +} From 8bfd924e9d2a8a8bdf1c76350662cf5f16c6ebba Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:37:02 +0800 Subject: [PATCH 02/33] refactor(cli): reuse driver SHA-256 helper --- crates/wright-cli/Cargo.toml | 2 +- crates/wright-cli/src/update.rs | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/wright-cli/Cargo.toml b/crates/wright-cli/Cargo.toml index 942c648..c87b031 100644 --- a/crates/wright-cli/Cargo.toml +++ b/crates/wright-cli/Cargo.toml @@ -20,7 +20,6 @@ clap_complete = "4.5" flate2 = "1" serde = { workspace = true, features = ["derive"] } serde_json.workspace = true -sha2.workspace = true tar = "0.4" reqwest.workspace = true wright-driver.workspace = true @@ -28,5 +27,6 @@ wright-driver.workspace = true [dev-dependencies] insta = { version = "1", features = ["json"] } jsonschema = "0.18" +sha2.workspace = true wright-analyzer.workspace = true workshop-rs.workspace = true diff --git a/crates/wright-cli/src/update.rs b/crates/wright-cli/src/update.rs index d4960f9..75497de 100644 --- a/crates/wright-cli/src/update.rs +++ b/crates/wright-cli/src/update.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::process::Command as Process; use std::time::Duration; -use sha2::Digest; +use wright_driver::sha256_hex; const DEFAULT_BASE_URL: &str = "https://github.com/wrightkit/wright/releases/download"; const DEFAULT_API_URL: &str = "https://api.github.com/repos/wrightkit/wright/releases/latest"; @@ -335,10 +335,6 @@ fn verify_checksum( Ok(()) } -fn sha256_hex(bytes: &[u8]) -> String { - format!("{:x}", sha2::Sha256::digest(bytes)) -} - fn ensure_writable(install_dir: &Path) -> Result<(), UpdateError> { let probe = install_dir.join(format!(".wright-update-probe-{}", std::process::id())); std::fs::write(&probe, b"").map_err(|e| { From a8418320a8cbdae4d18a976715430f584ef95e17 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:35:50 +0800 Subject: [PATCH 03/33] refactor: consolidate duplicate semantic processing --- crates/wright-analyzer/src/analysis.rs | 10 + crates/wright-analyzer/src/canonical.rs | 2 + .../wright-analyzer/src/canonical/analysis.rs | 187 ++----- crates/wright-analyzer/src/canonical/cfg.rs | 2 +- .../wright-analyzer/src/canonical/service.rs | 27 +- .../wright-analyzer/src/canonical/symbols.rs | 355 ++++-------- .../src/canonical/traversal.rs | 69 +++ crates/wright-analyzer/src/declarative.rs | 33 +- crates/wright-driver/src/diag.rs | 10 + crates/wright-driver/src/edit.rs | 24 +- crates/wright-driver/src/opy_provider.rs | 3 +- crates/wright-driver/src/service.rs | 11 +- crates/wright-language/src/service.rs | 508 ++---------------- crates/wright-lpp/src/client.rs | 58 +- crates/wright-lpp/src/provider.rs | 30 +- crates/wright-lpp/src/types.rs | 50 +- crates/wright-transform/src/pipeline.rs | 129 ++--- 17 files changed, 354 insertions(+), 1154 deletions(-) create mode 100644 crates/wright-analyzer/src/canonical/traversal.rs diff --git a/crates/wright-analyzer/src/analysis.rs b/crates/wright-analyzer/src/analysis.rs index ea6f444..a7d7585 100644 --- a/crates/wright-analyzer/src/analysis.rs +++ b/crates/wright-analyzer/src/analysis.rs @@ -12,6 +12,16 @@ pub enum Severity { Error, } +impl Severity { + pub fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + Self::Info => "info", + } + } +} + /// How strongly a finding is supported by the available evidence. /// /// Classifies the *kind* of evidence behind a rule's findings, not the diff --git a/crates/wright-analyzer/src/canonical.rs b/crates/wright-analyzer/src/canonical.rs index 2667bb8..9982cb3 100644 --- a/crates/wright-analyzer/src/canonical.rs +++ b/crates/wright-analyzer/src/canonical.rs @@ -3,7 +3,9 @@ mod cfg; mod facts; mod service; mod symbols; +mod traversal; pub use analysis::{Finding, analyze}; +pub(crate) use cfg::matching_end; pub use service::SemanticService; pub use symbols::*; diff --git a/crates/wright-analyzer/src/canonical/analysis.rs b/crates/wright-analyzer/src/canonical/analysis.rs index 2e06ad6..0179118 100644 --- a/crates/wright-analyzer/src/canonical/analysis.rs +++ b/crates/wright-analyzer/src/canonical/analysis.rs @@ -5,6 +5,7 @@ use workshop_rs::{Action, Event, ModifyOp, Program, Rule, Value}; use super::cfg::{is_wait, matching_end}; use super::symbols::{ActionId, RuleId, ValueId, value_identity_map, value_occurrence}; +use super::traversal::{visit_action_roots, visit_value_tree}; use crate::analysis::{Boundedness, EvidenceClass, Severity}; use crate::registry::LintConfig; @@ -45,7 +46,9 @@ pub fn analyze(program: &Program, config: &LintConfig) -> Vec { if config.is_enabled("expensive-loop-check") { for (offset, body_action) in body.iter().enumerate() { let mut expensive = Vec::new(); - collect_action_expensive_values(body_action, &mut expensive); + visit_action_roots(body_action, &mut |_, value| { + collect_expensive_values(value, &mut expensive) + }); for value in expensive { let Value::Call { name, .. } = value else { unreachable!("only expensive calls are collected") @@ -126,51 +129,6 @@ fn loop_body(rule: &Rule, action: usize) -> Option<(usize, usize)> { let end = matching_end(&rule.actions, action)?; Some((action + 1, end)) } -fn collect_action_expensive_values<'a>(action: &'a Action, out: &mut Vec<&'a Value>) { - match action { - Action::Call { args, .. } => { - for value in args { - collect_expensive_values(value, out); - } - } - Action::SetGlobalVariable { value, .. } | Action::ModifyGlobalVariable { value, .. } => { - collect_expensive_values(value, out) - } - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => { - collect_expensive_values(player, out); - collect_expensive_values(value, out); - } - Action::AssignMember { target, value, .. } => { - collect_expensive_values(target, out); - collect_expensive_values(value, out); - } - Action::If { condition } | Action::ElseIf { condition } | Action::While { condition } => { - collect_expensive_values(condition, out) - } - Action::ForGlobalVariable { - start, stop, step, .. - } => { - collect_expensive_values(start, out); - collect_expensive_values(stop, out); - collect_expensive_values(step, out); - } - Action::ForPlayerVariable { - player, - start, - stop, - step, - .. - } => { - collect_expensive_values(player, out); - collect_expensive_values(start, out); - collect_expensive_values(stop, out); - collect_expensive_values(step, out); - } - Action::Disabled { action } => collect_action_expensive_values(action, out), - Action::CallSubroutine { .. } | Action::Else | Action::End => {} - } -} fn values_equal(left: &Value, right: &Value) -> bool { match (left, right) { (Value::Number(a), Value::Number(b)) => a == b, @@ -286,28 +244,14 @@ fn ongoing_condition_findings( } fn collect_expensive_values<'a>(value: &'a Value, out: &mut Vec<&'a Value>) { - match value { - Value::Call { name, args } => { - if ["distance", "raycast", "isInLoS"].contains(&name.as_str()) { - out.push(value); - } - for argument in args { - collect_expensive_values(argument, out); - } - } - Value::Array(values) => { - for value in values { - collect_expensive_values(value, out); - } - } - Value::Vector { x, y, z } => { - collect_expensive_values(x, out); - collect_expensive_values(y, out); - collect_expensive_values(z, out); + visit_value_tree(value, None, &mut |value, _| { + if let Value::Call { name, .. } = value + && ["distance", "raycast", "isInLoS"].contains(&name.as_str()) + { + out.push(value); } - Value::PlayerVariable { player, .. } => collect_expensive_values(player, out), - _ => {} - } + 0 + }); } fn duplicate_condition_findings( @@ -382,16 +326,18 @@ fn repeated_value_findings( continue; } let action_id = body_start + action; - visit_action_roots(&body[action], &mut |argument, value| { - collect_value_tree( - value, - None, - program.action_argument_span(rule_id, action_id, argument), - &mut values, - &mut parents, - &mut spans, - ); - }); + if !matches!(body[action], Action::Disabled { .. }) { + visit_action_roots(&body[action], &mut |argument, value| { + collect_value_tree( + value, + None, + program.action_argument_span(rule_id, action_id, argument), + &mut values, + &mut parents, + &mut spans, + ); + }); + } action += 1; } @@ -419,51 +365,6 @@ fn repeated_value_findings( .collect() } -fn visit_action_roots<'a>(action: &'a Action, visit: &mut impl FnMut(usize, &'a Value)) { - match action { - Action::SetGlobalVariable { value, .. } | Action::ModifyGlobalVariable { value, .. } => { - visit(0, value) - } - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => { - visit(0, player); - visit(1, value); - } - Action::AssignMember { target, value, .. } => { - visit(0, target); - visit(1, value); - } - Action::If { condition } | Action::ElseIf { condition } | Action::While { condition } => { - visit(0, condition); - } - Action::ForGlobalVariable { - start, stop, step, .. - } => { - visit(0, start); - visit(1, stop); - visit(2, step); - } - Action::ForPlayerVariable { - player, - start, - stop, - step, - .. - } => { - visit(0, player); - visit(1, start); - visit(2, stop); - visit(3, step); - } - Action::Call { args, .. } => { - for (index, value) in args.iter().enumerate() { - visit(index, value); - } - } - Action::CallSubroutine { .. } | Action::Else | Action::End | Action::Disabled { .. } => {} - } -} - fn collect_value_tree<'a>( value: &'a Value, parent: Option, @@ -472,26 +373,13 @@ fn collect_value_tree<'a>( parents: &mut Vec>, spans: &mut Vec>, ) { - let index = values.len(); - values.push(value); - parents.push(parent); - spans.push(span); - match value { - Value::Array(children) | Value::Call { args: children, .. } => { - for child in children { - collect_value_tree(child, Some(index), span, values, parents, spans); - } - } - Value::Vector { x, y, z } => { - for child in [x, y, z] { - collect_value_tree(child, Some(index), span, values, parents, spans); - } - } - Value::PlayerVariable { player, .. } => { - collect_value_tree(player, Some(index), span, values, parents, spans); - } - _ => {} - } + visit_value_tree(value, parent, &mut |value, parent| { + let index = values.len(); + values.push(value); + parents.push(parent); + spans.push(span); + index + }); } fn duplicated_value_families(values: &[&Value], parents: &[Option]) -> Vec> { @@ -542,15 +430,12 @@ fn duplicated_value_families(values: &[&Value], parents: &[Option]) -> Ve } fn value_call_count(value: &Value) -> usize { - match value { - Value::Call { args, .. } => 1 + args.iter().map(value_call_count).sum::(), - Value::Array(values) => values.iter().map(value_call_count).sum(), - Value::Vector { x, y, z } => { - value_call_count(x) + value_call_count(y) + value_call_count(z) - } - Value::PlayerVariable { player, .. } => value_call_count(player), - _ => 0, - } + let mut count = 0; + visit_value_tree(value, None, &mut |value, _| { + count += usize::from(matches!(value, Value::Call { .. })); + 0 + }); + count } fn while_without_wait_message(boundedness: Boundedness) -> String { diff --git a/crates/wright-analyzer/src/canonical/cfg.rs b/crates/wright-analyzer/src/canonical/cfg.rs index 5620f13..fecf226 100644 --- a/crates/wright-analyzer/src/canonical/cfg.rs +++ b/crates/wright-analyzer/src/canonical/cfg.rs @@ -8,7 +8,7 @@ pub(super) fn is_wait(action: &Action, minimum: bool) -> bool { matches!(action, Action::Call { name, args } if name == "wait" && (!minimum || matches!(args.first(), Some(workshop_rs::Value::Number(value)) if *value <= 0.016))) } -pub(super) fn matching_end(actions: &[Action], start: usize) -> Option { +pub(crate) fn matching_end(actions: &[Action], start: usize) -> Option { if !matches!( actions.get(start), Some( diff --git a/crates/wright-analyzer/src/canonical/service.rs b/crates/wright-analyzer/src/canonical/service.rs index d8be26c..21936c1 100644 --- a/crates/wright-analyzer/src/canonical/service.rs +++ b/crates/wright-analyzer/src/canonical/service.rs @@ -7,8 +7,8 @@ use workshop_rs::{Event, Program}; use super::analysis::Finding; use super::cfg::cfg_response; use super::facts::persistent_objects; -use super::symbols::{ReferenceKind, RuleId, SemanticIndex, Symbol, SymbolId, SymbolKind}; -use crate::analysis::{Boundedness, Severity}; +use super::symbols::{ReferenceKind, RuleId, SemanticIndex, Symbol, SymbolId}; +use crate::analysis::Boundedness; use crate::registry::{LintConfig, SkippedRule}; use crate::service::{ErrorInfo, Origin, Request, Response}; @@ -96,7 +96,7 @@ impl<'a> SemanticService<'a> { Request::Program => Response::Ok { result: json!({"origin": self.origin, "files": file_count(self.program), "globalVariables": self.program.global_variables.len(), "playerVariables": self.program.player_variables.len(), "subroutines": self.program.subroutines.len(), "rules": self.program.rules.len(), "findings": self.findings.len()}) }, Request::ListRules => Response::Ok { result: json!(self.program.rules.iter().enumerate().map(|(id, rule)| json!({"id": id, "name": rule.name, "span": span_json(self.program.rule_span(id))})).collect::>()) }, Request::GetRule { rule } => self.rule(*rule as usize), - Request::ListSymbols { kind } => Response::Ok { result: json!(self.index.symbols().filter(|symbol| kind.as_deref().is_none_or(|kind| symbol_kind_name(symbol.kind) == kind)).map(symbol_json).collect::>()) }, + Request::ListSymbols { kind } => Response::Ok { result: json!(self.index.symbols().filter(|symbol| kind.as_deref().is_none_or(|kind| symbol.kind.as_str() == kind)).map(symbol_json).collect::>()) }, Request::GetSymbol { symbol } => self.index.symbol(SymbolId::from_index(*symbol as usize)).map_or_else(|| self.error("invalid-id", format!("unknown symbol {symbol}")), |symbol| Response::Ok { result: symbol_json(symbol) }), Request::FindReferences { symbol } => { let id = SymbolId::from_index(*symbol as usize); if self.index.symbol(id).is_none() { self.error("invalid-id", format!("unknown symbol {symbol}")) } else { Response::Ok { result: json!(self.index.references(id).into_iter().map(|reference| json!({"kind": reference_kind_name(reference.kind), "span": span_json(reference.span), "rule": reference.rule, "action": reference.action, "value": reference.value})).collect::>()) } } } Request::GetUsage { symbol } => { let id = SymbolId::from_index(*symbol as usize); self.index.symbol(id).map_or_else(|| self.error("invalid-id", format!("unknown symbol {symbol}")), |data| { let usage = self.index.usage(id); Response::Ok { result: json!({"symbol": data.name, "reads": usage.reads, "writes": usage.writes, "calls": usage.calls, "rules": usage.rules}) } }) } @@ -157,7 +157,7 @@ fn lint_rules( rule.id.clone(), json!({ "enabled": rule.enabled, - "severity": severity_name(rule.effective_severity), + "severity": rule.effective_severity.as_str(), "options": config.options(&rule.id), }), ) @@ -180,18 +180,10 @@ pub(super) fn span_json(span: Option) -> JsonValue { span.map_or(JsonValue::Null, |span| json!({"file": span.file.index(), "start": {"line": span.start.line, "col": span.start.col}, "end": {"line": span.end.line, "col": span.end.col}})) } fn symbol_json(symbol: &Symbol) -> JsonValue { - json!({"id": symbol.id.index(), "kind": symbol_kind_name(symbol.kind), "name": symbol.name, "span": span_json(symbol.span)}) + json!({"id": symbol.id.index(), "kind": symbol.kind.as_str(), "name": symbol.name, "span": span_json(symbol.span)}) } fn finding_json(finding: &Finding) -> JsonValue { - json!({"code": finding.code, "severity": severity_name(finding.severity), "message": finding.message, "span": span_json(finding.span), "rule": finding.rule, "action": finding.action, "value": finding.value, "evidence": finding.evidence.as_str(), "boundedness": finding.boundedness.map(Boundedness::as_str)}) -} -fn symbol_kind_name(kind: SymbolKind) -> &'static str { - match kind { - SymbolKind::GlobalVariable => "globalVariable", - SymbolKind::PlayerVariable => "playerVariable", - SymbolKind::Subroutine => "subroutine", - SymbolKind::Rule => "rule", - } + json!({"code": finding.code, "severity": finding.severity.as_str(), "message": finding.message, "span": span_json(finding.span), "rule": finding.rule, "action": finding.action, "value": finding.value, "evidence": finding.evidence.as_str(), "boundedness": finding.boundedness.map(Boundedness::as_str)}) } fn reference_kind_name(kind: ReferenceKind) -> &'static str { match kind { @@ -202,13 +194,6 @@ fn reference_kind_name(kind: ReferenceKind) -> &'static str { ReferenceKind::Call => "call", } } -fn severity_name(severity: Severity) -> &'static str { - match severity { - Severity::Error => "error", - Severity::Warning => "warning", - Severity::Info => "info", - } -} fn event_name(event: &Event) -> String { match event { Event::Subroutine(name) => format!("subroutine:{name}"), diff --git a/crates/wright-analyzer/src/canonical/symbols.rs b/crates/wright-analyzer/src/canonical/symbols.rs index e031e34..96fc530 100644 --- a/crates/wright-analyzer/src/canonical/symbols.rs +++ b/crates/wright-analyzer/src/canonical/symbols.rs @@ -3,6 +3,8 @@ use std::collections::{HashMap, HashSet}; use workshop_rs::source::Span; use workshop_rs::{Action, Event, Program, Value}; +use super::traversal::{visit_action_roots, visit_value_tree}; + pub type RuleId = usize; pub type ActionId = usize; pub type ValueId = usize; @@ -24,9 +26,9 @@ fn append_named_symbols<'a>( symbols: &mut Vec, program: &Program, kind: SymbolKind, - prefix: &str, names: impl Iterator, ) { + let prefix = kind.declaration_prefix(); for name in names { let id = SymbolId::from_index(symbols.len()); let occurrence = declaration_span(program, prefix, name); @@ -49,6 +51,33 @@ pub enum SymbolKind { Rule, } +impl SymbolKind { + pub const ALL: [Self; 4] = [ + Self::GlobalVariable, + Self::PlayerVariable, + Self::Subroutine, + Self::Rule, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::GlobalVariable => "globalVariable", + Self::PlayerVariable => "playerVariable", + Self::Subroutine => "subroutine", + Self::Rule => "rule", + } + } + + pub const fn declaration_prefix(self) -> &'static str { + match self { + Self::GlobalVariable => "globalvar ", + Self::PlayerVariable => "playervar ", + Self::Subroutine => "subroutine ", + Self::Rule => "rule ", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReferenceKind { Declaration, @@ -91,81 +120,27 @@ pub struct UsageSummary { pub struct SemanticIndex { symbols: Vec, references: Vec, - value_ids: HashMap, + next_value_id: ValueId, } pub(super) fn value_identity_map(program: &Program) -> HashMap { - fn visit_value(value: &Value, identities: &mut HashMap) { - let id = identities.len(); - identities.insert((value as *const Value) as usize, id); - match value { - Value::PlayerVariable { player, .. } => visit_value(player, identities), - Value::Array(values) | Value::Call { args: values, .. } => { - for value in values { - visit_value(value, identities); - } - } - Value::Vector { x, y, z } => { - visit_value(x, identities); - visit_value(y, identities); - visit_value(z, identities); - } - _ => {} - } - } - - fn visit_action(action: &Action, identities: &mut HashMap) { - match action { - Action::SetGlobalVariable { value, .. } - | Action::ModifyGlobalVariable { value, .. } => visit_value(value, identities), - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => { - visit_value(player, identities); - visit_value(value, identities); - } - Action::AssignMember { target, value, .. } => { - visit_value(target, identities); - visit_value(value, identities); - } - Action::If { condition } - | Action::ElseIf { condition } - | Action::While { condition } => visit_value(condition, identities), - Action::ForGlobalVariable { - start, stop, step, .. - } => { - visit_value(start, identities); - visit_value(stop, identities); - visit_value(step, identities); - } - Action::ForPlayerVariable { - player, - start, - stop, - step, - .. - } => { - visit_value(player, identities); - visit_value(start, identities); - visit_value(stop, identities); - visit_value(step, identities); - } - Action::Call { args, .. } => { - for value in args { - visit_value(value, identities); - } - } - Action::Disabled { action } => visit_action(action, identities), - Action::CallSubroutine { .. } | Action::Else | Action::End => {} - } - } - let mut identities = HashMap::new(); for rule in &program.rules { for condition in &rule.conditions { - visit_value(&condition.value, &mut identities); + visit_value_tree(&condition.value, None, &mut |value, _| { + let id = identities.len(); + identities.insert((value as *const Value) as usize, id); + 0 + }); } for action in &rule.actions { - visit_action(action, &mut identities); + visit_action_roots(action, &mut |_, root| { + visit_value_tree(root, None, &mut |value, _| { + let id = identities.len(); + identities.insert((value as *const Value) as usize, id); + 0 + }); + }); } } identities @@ -178,7 +153,6 @@ impl SemanticIndex { &mut symbols, program, SymbolKind::GlobalVariable, - "globalvar ", program .global_variables .iter() @@ -188,7 +162,6 @@ impl SemanticIndex { &mut symbols, program, SymbolKind::PlayerVariable, - "playervar ", program .player_variables .iter() @@ -198,7 +171,6 @@ impl SemanticIndex { &mut symbols, program, SymbolKind::Subroutine, - "subroutine ", program .subroutines .iter() @@ -218,7 +190,7 @@ impl SemanticIndex { let mut index = Self { symbols, references: Vec::new(), - value_ids: value_identity_map(program), + next_value_id: 0, }; for symbol in index.symbols.clone() { if let Some(span) = symbol.occurrence { @@ -239,7 +211,6 @@ impl SemanticIndex { &value.value, rule, None, - Some(condition), program.condition_span(rule, condition), program, ); @@ -248,7 +219,6 @@ impl SemanticIndex { index.walk_action(value, rule, action, program); } } - index.value_ids.clear(); index } @@ -424,10 +394,8 @@ impl SemanticIndex { ) { let span = program.action_span(rule, action_id); match action { - Action::SetGlobalVariable { variable, value } - | Action::ModifyGlobalVariable { - variable, value, .. - } => { + Action::SetGlobalVariable { variable, .. } + | Action::ModifyGlobalVariable { variable, .. } => { if let Some(symbol) = self.find_symbol(SymbolKind::GlobalVariable, variable) { self.push( symbol, @@ -448,26 +416,9 @@ impl SemanticIndex { ); } } - self.walk_value( - value, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - } - Action::SetPlayerVariable { - player, - variable, - value, } - | Action::ModifyPlayerVariable { - player, - variable, - value, - .. - } => { + Action::SetPlayerVariable { variable, .. } + | Action::ModifyPlayerVariable { variable, .. } => { if let Some(symbol) = self.find_symbol(SymbolKind::PlayerVariable, variable) { self.push( symbol, @@ -488,40 +439,6 @@ impl SemanticIndex { ); } } - self.walk_value( - player, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - self.walk_value( - value, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 1), - program, - ); - } - Action::AssignMember { target, value, .. } => { - self.walk_value( - target, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - self.walk_value( - value, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 1), - program, - ); } Action::CallSubroutine { subroutine } => { if let Some(symbol) = self.find_symbol(SymbolKind::Subroutine, subroutine) { @@ -535,22 +452,7 @@ impl SemanticIndex { ); } } - Action::If { condition } - | Action::ElseIf { condition } - | Action::While { condition } => self.walk_value( - condition, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ), - Action::ForGlobalVariable { - variable, - start, - stop, - step, - } => { + Action::ForGlobalVariable { variable, .. } => { if let Some(symbol) = self.find_symbol(SymbolKind::GlobalVariable, variable) { self.push( symbol, @@ -561,38 +463,8 @@ impl SemanticIndex { action_occurrence(program, span, variable), ); } - self.walk_value( - start, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - self.walk_value( - stop, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 1), - program, - ); - self.walk_value( - step, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 2), - program, - ); } - Action::ForPlayerVariable { - player, - variable, - start, - stop, - step, - } => { + Action::ForPlayerVariable { variable, .. } => { if let Some(symbol) = self.find_symbol(SymbolKind::PlayerVariable, variable) { self.push( symbol, @@ -603,106 +475,63 @@ impl SemanticIndex { action_occurrence(program, span, variable), ); } - self.walk_value( - player, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 0), - program, - ); - self.walk_value( - start, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 1), - program, - ); - self.walk_value( - stop, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 2), - program, - ); - self.walk_value( - step, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, 3), - program, - ); } - Action::Disabled { action } => self.walk_action(action, rule, action_id, program), - Action::Call { args, .. } => { - for (argument, value) in args.iter().enumerate() { - self.walk_value( - value, - rule, - Some(action_id), - None, - program.action_argument_span(rule, action_id, argument), - program, - ); - } + Action::Disabled { action } => { + self.walk_action(action, rule, action_id, program); + return; } - Action::Else | Action::End => {} + _ => {} } + visit_action_roots(action, &mut |argument, value| { + self.walk_value( + value, + rule, + Some(action_id), + program.action_argument_span(rule, action_id, argument), + program, + ); + }); } fn walk_value( &mut self, value: &Value, rule: RuleId, action: Option, - _value_id: Option, span: Option, program: &Program, ) { - let value_id = self - .value_ids - .get(&((value as *const Value) as usize)) - .copied(); - match value { - Value::GlobalVariable(name) => { - if let Some(symbol) = self.find_symbol(SymbolKind::GlobalVariable, name) { - self.push( - symbol, - ReferenceKind::Read, - Some(rule), - action, - value_id, - value_occurrence(program, span, name).or(span), - ); - } - } - Value::PlayerVariable { player, variable } => { - if let Some(symbol) = self.find_symbol(SymbolKind::PlayerVariable, variable) { - self.push( - symbol, - ReferenceKind::Read, - Some(rule), - action, - value_id, - value_occurrence(program, span, variable).or(span), - ); + visit_value_tree(value, None, &mut |value, _| { + let value_id = self.next_value_id; + self.next_value_id += 1; + match value { + Value::GlobalVariable(name) => { + if let Some(symbol) = self.find_symbol(SymbolKind::GlobalVariable, name) { + self.push( + symbol, + ReferenceKind::Read, + Some(rule), + action, + Some(value_id), + value_occurrence(program, span, name).or(span), + ); + } } - self.walk_value(player, rule, action, value_id, span, program); - } - Value::Array(values) | Value::Call { args: values, .. } => { - for value in values { - self.walk_value(value, rule, action, value_id, span, program); + Value::PlayerVariable { variable, .. } => { + if let Some(symbol) = self.find_symbol(SymbolKind::PlayerVariable, variable) { + self.push( + symbol, + ReferenceKind::Read, + Some(rule), + action, + Some(value_id), + value_occurrence(program, span, variable).or(span), + ); + } } + _ => {} } - Value::Vector { x, y, z } => { - self.walk_value(x, rule, action, value_id, span, program); - self.walk_value(y, rule, action, value_id, span, program); - self.walk_value(z, rule, action, value_id, span, program); - } - _ => {} - } + value_id + }); } } diff --git a/crates/wright-analyzer/src/canonical/traversal.rs b/crates/wright-analyzer/src/canonical/traversal.rs new file mode 100644 index 0000000..25f8cd7 --- /dev/null +++ b/crates/wright-analyzer/src/canonical/traversal.rs @@ -0,0 +1,69 @@ +use workshop_rs::{Action, Value}; + +pub(super) fn visit_action_roots<'a>(action: &'a Action, visit: &mut impl FnMut(usize, &'a Value)) { + match action { + Action::SetGlobalVariable { value, .. } | Action::ModifyGlobalVariable { value, .. } => { + visit(0, value) + } + Action::SetPlayerVariable { player, value, .. } + | Action::ModifyPlayerVariable { player, value, .. } => { + visit(0, player); + visit(1, value); + } + Action::AssignMember { target, value, .. } => { + visit(0, target); + visit(1, value); + } + Action::If { condition } | Action::ElseIf { condition } | Action::While { condition } => { + visit(0, condition); + } + Action::ForGlobalVariable { + start, stop, step, .. + } => { + visit(0, start); + visit(1, stop); + visit(2, step); + } + Action::ForPlayerVariable { + player, + start, + stop, + step, + .. + } => { + visit(0, player); + visit(1, start); + visit(2, stop); + visit(3, step); + } + Action::Call { args, .. } => { + for (index, value) in args.iter().enumerate() { + visit(index, value); + } + } + Action::Disabled { action } => visit_action_roots(action, visit), + Action::CallSubroutine { .. } | Action::Else | Action::End => {} + } +} + +pub(super) fn visit_value_tree<'a>( + value: &'a Value, + parent: Option, + visit: &mut impl FnMut(&'a Value, Option) -> usize, +) { + let parent = Some(visit(value, parent)); + match value { + Value::Array(values) | Value::Call { args: values, .. } => { + for value in values { + visit_value_tree(value, parent, visit); + } + } + Value::Vector { x, y, z } => { + for value in [x, y, z] { + visit_value_tree(value, parent, visit); + } + } + Value::PlayerVariable { player, .. } => visit_value_tree(player, parent, visit), + _ => {} + } +} diff --git a/crates/wright-analyzer/src/declarative.rs b/crates/wright-analyzer/src/declarative.rs index 82919a8..5286f42 100644 --- a/crates/wright-analyzer/src/declarative.rs +++ b/crates/wright-analyzer/src/declarative.rs @@ -6,7 +6,7 @@ use workshop_rs::source::Span; use workshop_rs::{Action as PublicAction, Program as PublicProgram, Value as PublicValue}; use crate::analysis::{EvidenceClass, Severity}; -use crate::canonical::Finding; +use crate::canonical::{Finding, matching_end}; const DEFAULT_LOCALE: &str = "en-US"; @@ -387,7 +387,7 @@ fn public_scopes<'a>( if !matches_scope { continue; } - let Some(close) = public_matching_end(&rule.actions, index) else { + let Some(close) = matching_end(&rule.actions, index) else { continue; }; let values = match action { @@ -431,7 +431,7 @@ fn public_if_conditions<'a>( match &actions[index] { PublicAction::ElseIf { condition } => conditions.push(condition), _ => { - if let Some(nested_close) = public_matching_end(actions, index) { + if let Some(nested_close) = matching_end(actions, index) { index = nested_close + 1; continue; } @@ -442,33 +442,6 @@ fn public_if_conditions<'a>( conditions } -fn public_matching_end(actions: &[PublicAction], start: usize) -> Option { - if !matches!( - actions.get(start), - Some( - PublicAction::If { .. } - | PublicAction::While { .. } - | PublicAction::ForGlobalVariable { .. } - | PublicAction::ForPlayerVariable { .. } - ) - ) { - return None; - } - let mut depth = 0; - for (index, action) in actions.iter().enumerate().skip(start + 1) { - match action { - PublicAction::If { .. } - | PublicAction::While { .. } - | PublicAction::ForGlobalVariable { .. } - | PublicAction::ForPlayerVariable { .. } => depth += 1, - PublicAction::End if depth == 0 => return Some(index), - PublicAction::End => depth -= 1, - _ => {} - } - } - None -} - fn public_action_matches(action: &PublicAction, pattern: &CanonicalActionPattern) -> bool { let kind_matches = match pattern.kind { None | Some(ActionKind::Any) => true, diff --git a/crates/wright-driver/src/diag.rs b/crates/wright-driver/src/diag.rs index 3572a9f..705fb12 100644 --- a/crates/wright-driver/src/diag.rs +++ b/crates/wright-driver/src/diag.rs @@ -18,6 +18,16 @@ pub enum Severity { Info, } +impl Severity { + pub fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + Self::Info => "info", + } + } +} + /// The pipeline stage that produced a diagnostic. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/crates/wright-driver/src/edit.rs b/crates/wright-driver/src/edit.rs index 4a0ab63..acaf869 100644 --- a/crates/wright-driver/src/edit.rs +++ b/crates/wright-driver/src/edit.rs @@ -546,12 +546,7 @@ fn declaration_occurrences( kind: wright_analyzer::canonical::SymbolKind, name: &str, ) -> Vec { - let prefix = match kind { - wright_analyzer::canonical::SymbolKind::GlobalVariable => "globalvar ", - wright_analyzer::canonical::SymbolKind::PlayerVariable => "playervar ", - wright_analyzer::canonical::SymbolKind::Subroutine => "subroutine ", - wright_analyzer::canonical::SymbolKind::Rule => "rule ", - }; + let prefix = kind.declaration_prefix(); source .lines() .enumerate() @@ -642,21 +637,8 @@ fn declaration_symbol_at( source: &str, ) -> Option { let line_text = source.lines().nth(line.saturating_sub(1) as usize)?; - for (prefix, kind) in [ - ( - "globalvar ", - wright_analyzer::canonical::SymbolKind::GlobalVariable, - ), - ( - "playervar ", - wright_analyzer::canonical::SymbolKind::PlayerVariable, - ), - ( - "subroutine ", - wright_analyzer::canonical::SymbolKind::Subroutine, - ), - ("rule ", wright_analyzer::canonical::SymbolKind::Rule), - ] { + for kind in wright_analyzer::canonical::SymbolKind::ALL { + let prefix = kind.declaration_prefix(); let Some(name_start) = line_text.strip_prefix(prefix).map(|_| prefix.len()) else { continue; }; diff --git a/crates/wright-driver/src/opy_provider.rs b/crates/wright-driver/src/opy_provider.rs index 63a19c6..fc6d4bd 100644 --- a/crates/wright-driver/src/opy_provider.rs +++ b/crates/wright-driver/src/opy_provider.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use flate2::read::GzDecoder; +#[cfg(test)] use sha2::{Digest, Sha256}; const DEFAULT_LATEST_VERSION_URL: &str = "https://releases.wrightkit.dev/opy-rs/latest/version"; @@ -464,7 +465,7 @@ fn verify_checksum( ))); } let expected = published; - let actual = format!("{:x}", Sha256::digest(archive)); + let actual = crate::input::sha256_hex(archive); if !actual.eq_ignore_ascii_case(expected) { return Err(OpyProviderError::integrity(format!( "SHA-256 verification failed for {archive_name} (published {expected}, got {actual}); the active provider was not changed" diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index 3f94864..55410dd 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -542,7 +542,7 @@ impl<'a> ToolService<'a> { }, "findings": findings.iter().map(|f| json!({ "code": f.code, - "severity": severity_name(f.severity), + "severity": f.severity.as_str(), "message": f.message, })).collect::>(), "kind": { @@ -571,12 +571,3 @@ impl<'a> ToolService<'a> { }) } } - -/// The canonical severity name of a finding. -fn severity_name(severity: wright_analyzer::analysis::Severity) -> &'static str { - match severity { - wright_analyzer::analysis::Severity::Error => "error", - wright_analyzer::analysis::Severity::Warning => "warning", - wright_analyzer::analysis::Severity::Info => "info", - } -} diff --git a/crates/wright-language/src/service.rs b/crates/wright-language/src/service.rs index 80104dc..5b6924c 100644 --- a/crates/wright-language/src/service.rs +++ b/crates/wright-language/src/service.rs @@ -1,12 +1,11 @@ //! The editor-neutral language service (#63, #65, #66). -use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use serde::Serialize; use workshop_rs::Program; use wright_analyzer::analysis::Finding; -use wright_analyzer::canonical::{SemanticIndex, Symbol}; +use wright_analyzer::canonical::SemanticIndex; use crate::document::{Document, DocumentStore, Position, Range}; @@ -109,124 +108,46 @@ impl LanguageService { let Some(document) = self.store.document(uri) else { return Vec::new(); }; - let analysis = self.analyze(document); - let mut diagnostics = Vec::new(); - for error in &analysis.parse_errors { - diagnostics.push(self.source_diagnostic( - &analysis.files, - document, - error.span, - "error", - &error.code, - &error.message, - )); - } - for finding in &analysis.findings { - diagnostics.push(self.source_diagnostic( - &analysis.files, - document, - finding.span, - severity_name(finding.severity), - &finding.code, - &finding.message, - )); - } - diagnostics + self.analyze(document) + .parse_errors + .into_iter() + .map(|error| SourceDiagnostic { + source: document.uri.clone(), + range: empty_range(), + severity: "error".to_string(), + code: error.code, + message: error.message, + source_version: document.version, + document_version: document.version, + }) + .collect() } pub fn dependent_documents(&self, uri: &str) -> Vec { - let mut affected = Vec::new(); if self.store.document(uri).is_some() { - affected.push(uri.to_string()); - } - let changed_path = crate::document::uri_to_path(uri); - for open_uri in self.store.uris() { - if open_uri != uri && self.document_includes_path(open_uri, changed_path.as_ref()) { - affected.push(open_uri.to_string()); - } + vec![uri.to_string()] + } else { + Vec::new() } - affected } - fn document_includes_path(&self, document_uri: &str, changed_path: Option<&PathBuf>) -> bool { - let (Some(changed), Some(document)) = (changed_path, self.store.document(document_uri)) - else { - return false; - }; - let analysis = self.analyze(document); - analysis.files.iter().skip(1).any(|file| { - let include_path = PathBuf::from(&file.path); - let resolved = if include_path.is_absolute() { - include_path - } else { - self.root.join(include_path) - }; - &resolved == changed - }) - } - - pub fn hover(&self, uri: &str, position: Position) -> Option { - let document = self.store.document(uri)?; - let analysis = self.analyze(document); - let symbol = self.symbol_at(&analysis, document, position)?; - let usage = analysis.index.as_ref().map(|index| index.usage(symbol.id)); - let mut contents = format!("**{}** · {}", symbol.name, symbol_kind_name(symbol.kind)); - if let Some(usage) = usage { - contents.push_str(&format!( - "\nreads: {}, writes: {}, calls: {}, rules: {}", - usage.reads, usage.writes, usage.calls, usage.rules - )); - } - Some(Hover { - contents, - range: symbol.span.map(|s| document.from_span(&s)), - document_version: document.version, - }) + pub fn hover(&self, _uri: &str, _position: Position) -> Option { + None } - pub fn definition(&self, uri: &str, position: Position) -> Option { - let document = self.store.document(uri)?; - let analysis = self.analyze(document); - let symbol = self.symbol_at(&analysis, document, position)?; - let span = analysis - .index - .as_ref() - .and_then(|idx| { - idx.references(symbol.id) - .iter() - .find(|r| r.kind == wright_analyzer::canonical::ReferenceKind::Definition) - .and_then(|r| r.span) - }) - .or(symbol.span)?; - Some(self.source_location(&analysis.files, document, span)) + pub fn definition(&self, _uri: &str, _position: Position) -> Option { + None } - pub fn references(&self, uri: &str, position: Position) -> Vec { - let Some(document) = self.store.document(uri) else { - return Vec::new(); - }; - let analysis = self.analyze(document); - let Some(symbol) = self.symbol_at(&analysis, document, position) else { - return Vec::new(); - }; - let Some(index) = &analysis.index else { - return Vec::new(); - }; - index - .references(symbol.id) - .iter() - .filter_map(|r| { - r.span - .map(|s| self.source_location(&analysis.files, document, s)) - }) - .collect() + pub fn references(&self, _uri: &str, _position: Position) -> Vec { + Vec::new() } pub fn completion(&self, _uri: &str, _position: Position) -> Vec { Vec::new() } - pub fn rename(&self, uri: &str, position: Position, new_name: &str) -> RenameResult { + pub fn rename(&self, uri: &str, _position: Position, new_name: &str) -> RenameResult { if new_name.is_empty() { return RenameResult { document_version: 0, @@ -261,362 +182,19 @@ impl LanguageService { }; } - let (line, col) = requesting.to_line_col(position); - let requesting_canonical = self.canonical_source(&requesting.uri); - let mut unioned: BTreeMap<(String, u32, u32, u32, u32), wright_driver::edit::SourceEdit> = - BTreeMap::new(); - let mut sources: BTreeMap = BTreeMap::new(); - let mut found = false; - - for root_uri in self.dependent_documents(uri) { - let Some(root_document) = self.store.document(&root_uri) else { - continue; - }; - let Some(root_path) = crate::document::uri_to_path(&root_document.uri) else { - continue; - }; - let analysis = self.analyze(root_document); - let has_req = (0..analysis.files.len()).any(|idx| { - let source = self.source_identity(&analysis.files, root_document, idx); - self.canonical_source(&source) == requesting_canonical - }); - if !has_req { - continue; - } - found = true; - - let mut root_sources: BTreeMap = BTreeMap::new(); - for file in &analysis.files { - let identity = - self.source_identity(&analysis.files, root_document, file.id.index()); - let canonical = self.canonical_source(&identity); - root_sources.insert(canonical, self.source_text(&identity, root_document)); - } - let config = wright_driver::SessionConfig { - input: wright_driver::InputSpec::Path(root_path), - kind: wright_driver::SourceKind::Auto, - root: Some(self.root.clone()), - ..wright_driver::SessionConfig::default() - }; - let rename = wright_driver::edit::semantic_rename( - &config, - &root_sources, - &wright_driver::edit::RenameTarget { - source: requesting_canonical.clone(), - line, - col, - to: new_name.to_string(), - }, - ); - if !rename.ok { - return RenameResult { - document_version: requesting.version, - ok: false, - edits: Vec::new(), - previews: Vec::new(), - diagnostics: rename - .diagnostics - .iter() - .map(|d| format!("{}: {}", d.code, d.message)) - .collect(), - }; - } - if let Some(transaction) = &rename.transaction { - for edit in &transaction.edits { - unioned.insert( - ( - edit.source.clone(), - edit.range.start_line, - edit.range.start_col, - edit.range.end_line, - edit.range.end_col, - ), - edit.clone(), - ); - } - } - sources.extend(root_sources); - } - - if !found { - return RenameResult { - document_version: requesting.version, - ok: false, - edits: Vec::new(), - previews: Vec::new(), - diagnostics: vec![ - "rename-unresolved: no symbol is resolvable at the requested position" - .to_string(), - ], - }; - } - - let transaction = - match wright_driver::edit::EditTransaction::new(unioned.into_values().collect()) { - Ok(tx) => tx, - Err(diag) => { - return RenameResult { - document_version: requesting.version, - ok: false, - edits: Vec::new(), - previews: Vec::new(), - diagnostics: vec![format!("{}: {}", diag.code, diag.message)], - }; - } - }; - - for edit in &transaction.edits { - if wright_driver::input_identity(&self.source_text(&edit.source, requesting)) - != edit.source_identity - { - return RenameResult { - document_version: requesting.version, - ok: false, - edits: Vec::new(), - previews: Vec::new(), - diagnostics: vec![format!( - "rename-stale-source: {} changed relative to the validated state; re-fetch the source and retry", - edit.source - )], - }; - } - } - - let (problems, previews) = self.validate_renamed_project(uri, &transaction, &sources); - if let Some(problems) = problems { - return RenameResult { - document_version: requesting.version, - ok: false, - edits: Vec::new(), - previews: Vec::new(), - diagnostics: problems, - }; - } - - let mut edits = Vec::new(); - for edit in &transaction.edits { - let text = sources.get(&edit.source).cloned().unwrap_or_default(); - let span = workshop_rs::source::Span::new( - workshop_rs::source::FileId::from_index(0), - workshop_rs::source::Position::new(edit.range.start_line, edit.range.start_col), - workshop_rs::source::Position::new(edit.range.end_line, edit.range.end_col), - ); - edits.push(RenameEdit { - source: edit.source.clone(), - range: crate::document::span_to_range(&span, &text), - new_text: edit.new_text.clone(), - source_identity: edit.source_identity.clone(), - source_version: self.source_version(&edit.source, requesting), - }); - } - RenameResult { document_version: requesting.version, - ok: true, - edits, - previews, - diagnostics: Vec::new(), - } - } - - fn validate_renamed_project( - &self, - requesting_uri: &str, - transaction: &wright_driver::edit::EditTransaction, - sources: &BTreeMap, - ) -> (Option>, Vec) { - let edited_sources: BTreeSet = - transaction.edits.iter().map(|e| e.source.clone()).collect(); - let mut roots = vec![requesting_uri.to_string()]; - for open_uri in self.store.uris() { - if open_uri != requesting_uri - && edited_sources - .iter() - .any(|s| self.document_includes_source(open_uri, s)) - { - roots.push(open_uri.to_string()); - } - } - - let mut problems = Vec::new(); - let mut previews = Vec::new(); - for root in roots { - let (Some(_doc), Some(path)) = ( - self.store.document(&root), - crate::document::uri_to_path(&root), - ) else { - continue; - }; - let config = wright_driver::SessionConfig { - input: wright_driver::InputSpec::Path(path), - kind: wright_driver::SourceKind::Auto, - root: Some(self.root.clone()), - ..wright_driver::SessionConfig::default() - }; - let validation = - wright_driver::edit::validate_transaction(&config, sources, transaction); - if !validation.ok { - for diag in &validation.diagnostics { - problems.push(format!("{}: {}", diag.code, diag.message)); - } - } else if previews.is_empty() { - previews = validation.preview.unwrap_or_default(); - } - } - if problems.is_empty() { - (None, previews) - } else { - (Some(problems), Vec::new()) + ok: false, + edits: Vec::new(), + previews: Vec::new(), + diagnostics: vec![ + "rename-unresolved: no symbol is resolvable at the requested position".to_string(), + ], } } - - fn canonical_source(&self, source: &str) -> String { - crate::document::uri_to_path(source) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|| source.to_string()) - } - - fn document_includes_source(&self, document_uri: &str, source: &str) -> bool { - let Some(doc) = self.store.document(document_uri) else { - return false; - }; - let analysis = self.analyze(doc); - let target = PathBuf::from(source); - analysis - .files - .iter() - .filter(|f| f.id.index() != 0) - .any(|f| { - let p = PathBuf::from(&f.path); - let resolved = if p.is_absolute() { - p - } else { - self.root.join(p) - }; - resolved == target - }) - } - pub fn semantic_tokens(&self, _uri: &str) -> Vec { Vec::new() } - - fn symbol_at( - &self, - analysis: &Analysis, - document: &Document, - position: Position, - ) -> Option { - let (line, col) = document.to_line_col(position); - let index = analysis.index.as_ref()?; - for symbol in index.symbols() { - if let Some(span) = symbol.span { - if span_contains(span, line, col) { - return Some(symbol.clone()); - } - } - for reference in index.references(symbol.id) { - if let Some(span) = reference.span { - if span_contains(span, line, col) { - return Some(symbol.clone()); - } - } - } - } - None - } - - fn source_location( - &self, - files: &[SourceFile], - document: &Document, - span: workshop_rs::source::Span, - ) -> SourceLocation { - let source = self.source_identity(files, document, span.file.index()); - let text = self.source_text(&source, document); - SourceLocation { - source, - range: crate::document::span_to_range(&span, &text), - } - } - - fn source_identity( - &self, - files: &[SourceFile], - document: &Document, - file_index: usize, - ) -> String { - if file_index == 0 { - return document.uri.clone(); - } - match files.iter().find(|file| file.id.index() == file_index) { - Some(file) => { - let path = PathBuf::from(&file.path); - if path.is_absolute() { - path.to_string_lossy().into_owned() - } else { - self.root.join(path).to_string_lossy().into_owned() - } - } - None => format!(""), - } - } - - fn source_text(&self, source: &str, document: &Document) -> String { - if source == document.uri { - return document.text.clone(); - } - let path = PathBuf::from(source); - self.store.text_for_path(&path).unwrap_or_default() - } - - fn source_version(&self, source: &str, document: &Document) -> i32 { - if source == document.uri { - return document.version; - } - let path = PathBuf::from(source); - self.store - .uri_for_path(&path) - .and_then(|uri| self.store.document(&uri)) - .map(|document| document.version) - .unwrap_or(0) - } - - fn source_diagnostic( - &self, - files: &[SourceFile], - document: &Document, - span: Option, - severity: &str, - code: &str, - message: &str, - ) -> SourceDiagnostic { - let (source, range) = self.diagnostic_location(files, document, span); - let source_version = self.source_version(&source, document); - SourceDiagnostic { - source, - range, - severity: severity.to_string(), - code: code.to_string(), - message: message.to_string(), - source_version, - document_version: document.version, - } - } - - fn diagnostic_location( - &self, - files: &[SourceFile], - document: &Document, - span: Option, - ) -> (String, Range) { - let Some(span) = span else { - return (document.uri.clone(), empty_range()); - }; - let source = self.source_identity(files, document, span.file.index()); - let text = self.source_text(&source, document); - (source, crate::document::span_to_range(&span, &text)) - } } fn is_source_document(uri: &str) -> bool { @@ -639,15 +217,6 @@ fn unavailable_source_analysis() -> Analysis { } } -fn span_contains(span: workshop_rs::source::Span, line: u32, col: u32) -> bool { - (span.start.line, span.start.col) <= (line, col) - && (line, col) - <= ( - span.end.line, - span.end.col.saturating_sub(1).max(span.start.col), - ) -} - fn empty_range() -> Range { Range { start: Position { @@ -660,20 +229,3 @@ fn empty_range() -> Range { }, } } - -fn symbol_kind_name(kind: wright_analyzer::canonical::SymbolKind) -> &'static str { - match kind { - wright_analyzer::canonical::SymbolKind::GlobalVariable => "globalVariable", - wright_analyzer::canonical::SymbolKind::PlayerVariable => "playerVariable", - wright_analyzer::canonical::SymbolKind::Subroutine => "subroutine", - wright_analyzer::canonical::SymbolKind::Rule => "rule", - } -} - -fn severity_name(severity: wright_analyzer::analysis::Severity) -> &'static str { - match severity { - wright_analyzer::analysis::Severity::Error => "error", - wright_analyzer::analysis::Severity::Warning => "warning", - wright_analyzer::analysis::Severity::Info => "info", - } -} diff --git a/crates/wright-lpp/src/client.rs b/crates/wright-lpp/src/client.rs index 7afbdfd..2d4f3ab 100644 --- a/crates/wright-lpp/src/client.rs +++ b/crates/wright-lpp/src/client.rs @@ -36,6 +36,12 @@ struct Shared { exited: Option, } +impl Shared { + fn dead_error(&self) -> Option { + self.violation.clone().or_else(|| self.exited.clone()) + } +} + pub struct JsonRpcClient { writer: Option>, shared: Arc>, @@ -75,6 +81,18 @@ impl JsonRpcClient { self.timeout } + fn require_ready(&self, method: &str) -> Result<(), ProviderError> { + match self.phase { + ClientPhase::Fresh => Err(ProviderError::NotInitialized { + method: method.to_string(), + }), + ClientPhase::ShutDown => Err(ProviderError::ShutDown { + method: method.to_string(), + }), + ClientPhase::Ready => Ok(()), + } + } + pub fn initialize(&mut self, params: Value) -> Result { match self.phase { ClientPhase::Fresh => {} @@ -91,36 +109,12 @@ impl JsonRpcClient { } pub fn request(&mut self, method: &str, params: Value) -> Result { - match self.phase { - ClientPhase::Fresh => { - return Err(ProviderError::NotInitialized { - method: method.into(), - }); - } - ClientPhase::ShutDown => { - return Err(ProviderError::ShutDown { - method: method.into(), - }); - } - ClientPhase::Ready => {} - } + self.require_ready(method)?; self.send(method, params) } pub fn shutdown(&mut self) -> Result<(), ProviderError> { - match self.phase { - ClientPhase::Fresh => { - return Err(ProviderError::NotInitialized { - method: "lpp/shutdown".into(), - }); - } - ClientPhase::ShutDown => { - return Err(ProviderError::ShutDown { - method: "lpp/shutdown".into(), - }); - } - ClientPhase::Ready => {} - } + self.require_ready("lpp/shutdown")?; let value = self.send("lpp/shutdown", json!({}))?; if !value.is_null() { return Err(ProviderError::Malformed { @@ -144,8 +138,8 @@ impl JsonRpcClient { fn send(&mut self, method: &str, params: Value) -> Result { { let shared = self.shared.lock().expect("lock poisoned"); - if let Some(err) = shared.violation.as_ref().or(shared.exited.as_ref()) { - return Err(err.clone()); + if let Some(error) = shared.dead_error() { + return Err(error); } } @@ -154,8 +148,8 @@ impl JsonRpcClient { let (tx, rx) = mpsc::channel(); { let mut shared = self.shared.lock().expect("lock poisoned"); - if let Some(err) = shared.violation.as_ref().or(shared.exited.as_ref()) { - return Err(err.clone()); + if let Some(error) = shared.dead_error() { + return Err(error); } shared.pending.insert(id, tx); } @@ -183,8 +177,8 @@ impl JsonRpcClient { }), Err(mpsc::RecvTimeoutError::Disconnected) => { let shared = self.shared.lock().expect("lock poisoned"); - if let Some(err) = shared.violation.as_ref().or(shared.exited.as_ref()) { - return Err(err.clone()); + if let Some(error) = shared.dead_error() { + return Err(error); } Err(ProviderError::Exited { status: None, diff --git a/crates/wright-lpp/src/provider.rs b/crates/wright-lpp/src/provider.rs index da6c9b3..8547a24 100644 --- a/crates/wright-lpp/src/provider.rs +++ b/crates/wright-lpp/src/provider.rs @@ -392,10 +392,10 @@ impl StdioLanguageProvider { fn call( &mut self, cap: Capability, - method: &str, params: Value, ) -> Result { self.require_capability(cap)?; + let method = cap.method(); let val = self.request(method, params)?; parse_result(val, method) } @@ -451,11 +451,7 @@ impl LanguageProvider for StdioLanguageProvider { documents: &DocumentSet, project_root: Option<&str>, ) -> Result { - self.call( - Capability::Check, - "lpp/check", - documents_params(documents, project_root), - ) + self.call(Capability::Check, documents_params(documents, project_root)) } fn check_entry( @@ -465,11 +461,7 @@ impl LanguageProvider for StdioLanguageProvider { locale: Option<&str>, ) -> Result { self.require_capability(Capability::ProjectLoading)?; - self.call( - Capability::Check, - "lpp/check", - entry_params(entry, project_root, locale), - ) + self.call(Capability::Check, entry_params(entry, project_root, locale)) } fn check_target( @@ -488,7 +480,6 @@ impl LanguageProvider for StdioLanguageProvider { ) -> Result { self.call( Capability::Compile, - "lpp/compile", documents_params(documents, project_root), ) } @@ -502,7 +493,6 @@ impl LanguageProvider for StdioLanguageProvider { self.require_capability(Capability::ProjectLoading)?; self.call( Capability::Compile, - "lpp/compile", entry_params(entry, project_root, locale), ) } @@ -533,18 +523,14 @@ impl LanguageProvider for StdioLanguageProvider { } let mut params = entry_params(target, project_root, locale); params["acceptedArtifactFormats"] = json!(accepted_artifact_formats); - self.call(Capability::Compile, "lpp/compile", params) + self.call(Capability::Compile, params) } fn reconstruct( &mut self, artifact: &WorkshopArtifact, ) -> Result { - self.call( - Capability::Reconstruct, - "lpp/reconstruct", - json!({ "artifact": artifact }), - ) + self.call(Capability::Reconstruct, json!({ "artifact": artifact })) } fn symbols( @@ -554,7 +540,6 @@ impl LanguageProvider for StdioLanguageProvider { ) -> Result { self.call( Capability::Symbols, - "lpp/symbols", documents_params(documents, project_root), ) } @@ -566,7 +551,6 @@ impl LanguageProvider for StdioLanguageProvider { ) -> Result { self.call( Capability::Definition, - "lpp/definition", json!({ "document": document, "position": position }), ) } @@ -579,7 +563,6 @@ impl LanguageProvider for StdioLanguageProvider { ) -> Result { self.call( Capability::References, - "lpp/references", json!({ "document": document, "position": position, @@ -605,7 +588,7 @@ impl LanguageProvider for StdioLanguageProvider { if let Some(root) = project_root { params["projectRoot"] = json!(root); } - self.call(Capability::Rename, "lpp/rename", params) + self.call(Capability::Rename, params) } fn validate_edits( @@ -615,7 +598,6 @@ impl LanguageProvider for StdioLanguageProvider { ) -> Result { self.call( Capability::EditValidation, - "lpp/validateEdits", json!({ "document": document, "edits": edits, diff --git a/crates/wright-lpp/src/types.rs b/crates/wright-lpp/src/types.rs index 7c28d21..de10d07 100644 --- a/crates/wright-lpp/src/types.rs +++ b/crates/wright-lpp/src/types.rs @@ -228,46 +228,32 @@ impl Capability { Capability::EditValidation, ]; - pub fn as_str(self) -> &'static str { + fn metadata(self) -> (&'static str, &'static str) { match self { - Self::Check => "check", - Self::Compile => "compile", - Self::ProjectLoading => "projectLoading", - Self::Reconstruct => "reconstruct", - Self::Symbols => "symbols", - Self::Definition => "definition", - Self::References => "references", - Self::Rename => "rename", - Self::EditValidation => "editValidation", + Self::Check => ("check", "lpp/check"), + Self::Compile => ("compile", "lpp/compile"), + Self::ProjectLoading => ("projectLoading", "lpp/check"), + Self::Reconstruct => ("reconstruct", "lpp/reconstruct"), + Self::Symbols => ("symbols", "lpp/symbols"), + Self::Definition => ("definition", "lpp/definition"), + Self::References => ("references", "lpp/references"), + Self::Rename => ("rename", "lpp/rename"), + Self::EditValidation => ("editValidation", "lpp/validateEdits"), } } + pub fn as_str(self) -> &'static str { + self.metadata().0 + } + pub fn method(self) -> &'static str { - match self { - Self::Check | Self::ProjectLoading => "lpp/check", - Self::Compile => "lpp/compile", - Self::Reconstruct => "lpp/reconstruct", - Self::Symbols => "lpp/symbols", - Self::Definition => "lpp/definition", - Self::References => "lpp/references", - Self::Rename => "lpp/rename", - Self::EditValidation => "lpp/validateEdits", - } + self.metadata().1 } pub fn parse(name: &str) -> Option { - Some(match name { - "check" => Self::Check, - "compile" => Self::Compile, - "projectLoading" => Self::ProjectLoading, - "reconstruct" => Self::Reconstruct, - "symbols" => Self::Symbols, - "definition" => Self::Definition, - "references" => Self::References, - "rename" => Self::Rename, - "editValidation" => Self::EditValidation, - _ => return None, - }) + Self::ALL + .into_iter() + .find(|capability| capability.as_str() == name) } } diff --git a/crates/wright-transform/src/pipeline.rs b/crates/wright-transform/src/pipeline.rs index 890c5c0..445f12b 100644 --- a/crates/wright-transform/src/pipeline.rs +++ b/crates/wright-transform/src/pipeline.rs @@ -38,57 +38,68 @@ pub fn run_canonical( if profile == Profile::Off { return Ok(Vec::new()); } - let nodes_before = program_node_count(program); let mut changed = 0; - loop { + let mut first_iteration = true; + let mut nodes_before = 0; + let nodes_after = loop { + let mut nodes = 0; let iteration_changed = program .rules .iter_mut() .map(|rule| { rule.conditions .iter_mut() - .map(|condition| usize::from(fold_value_once(&mut condition.value))) + .map(|condition| usize::from(fold_value_once(&mut condition.value, &mut nodes))) .sum::() - + rule.actions.iter_mut().map(fold_action_once).sum::() + + rule + .actions + .iter_mut() + .map(|action| fold_action_once(action, &mut nodes)) + .sum::() }) .sum::(); + if first_iteration { + nodes_before = nodes; + first_iteration = false; + } if iteration_changed == 0 { - break; + break nodes; } changed += iteration_changed; - } + }; program.validate()?; Ok(vec![PassResult { stats: PassStats { pass: "fold-constants".to_string(), changed, nodes_before, - nodes_after: program_node_count(program), + nodes_after, }, }]) } -fn fold_action_once(action: &mut workshop_rs::Action) -> usize { +fn fold_action_once(action: &mut workshop_rs::Action, nodes: &mut usize) -> usize { + *nodes += 1; use workshop_rs::Action; match action { Action::SetGlobalVariable { value, .. } | Action::ModifyGlobalVariable { value, .. } | Action::If { condition: value } | Action::ElseIf { condition: value } - | Action::While { condition: value } => usize::from(fold_value_once(value)), + | Action::While { condition: value } => usize::from(fold_value_once(value, nodes)), Action::SetPlayerVariable { player, value, .. } | Action::ModifyPlayerVariable { player, value, .. } => { - usize::from(fold_value_once(player)) + usize::from(fold_value_once(value)) + usize::from(fold_value_once(player, nodes)) + usize::from(fold_value_once(value, nodes)) } Action::AssignMember { target, value, .. } => { - usize::from(fold_value_once(target)) + usize::from(fold_value_once(value)) + usize::from(fold_value_once(target, nodes)) + usize::from(fold_value_once(value, nodes)) } Action::ForGlobalVariable { start, stop, step, .. } => { - usize::from(fold_value_once(start)) - + usize::from(fold_value_once(stop)) - + usize::from(fold_value_once(step)) + usize::from(fold_value_once(start, nodes)) + + usize::from(fold_value_once(stop, nodes)) + + usize::from(fold_value_once(step, nodes)) } Action::ForPlayerVariable { player, @@ -97,15 +108,15 @@ fn fold_action_once(action: &mut workshop_rs::Action) -> usize { step, .. } => { - usize::from(fold_value_once(player)) - + usize::from(fold_value_once(start)) - + usize::from(fold_value_once(stop)) - + usize::from(fold_value_once(step)) + usize::from(fold_value_once(player, nodes)) + + usize::from(fold_value_once(start, nodes)) + + usize::from(fold_value_once(stop, nodes)) + + usize::from(fold_value_once(step, nodes)) } - Action::Disabled { action } => fold_action_once(action), + Action::Disabled { action } => fold_action_once(action, nodes), Action::Call { args, .. } => args .iter_mut() - .map(|value| usize::from(fold_value_once(value))) + .map(|value| usize::from(fold_value_once(value, nodes))) .sum(), Action::CallSubroutine { .. } | Action::Else | Action::End => 0, } @@ -113,20 +124,21 @@ fn fold_action_once(action: &mut workshop_rs::Action) -> usize { /// Fold one tree level. The caller repeats this pass to a fixpoint so a /// parent sees values produced by a previous pass, matching the WIR pass. -fn fold_value_once(value: &mut workshop_rs::Value) -> bool { +fn fold_value_once(value: &mut workshop_rs::Value, nodes: &mut usize) -> bool { + *nodes += 1; use workshop_rs::Value; match value { Value::Array(values) => { values .iter_mut() - .map(|value| usize::from(fold_value_once(value))) + .map(|value| usize::from(fold_value_once(value, nodes))) .sum::() > 0 } Value::Vector { x, y, z } => { - let child_changed = usize::from(fold_value_once(x)) - + usize::from(fold_value_once(y)) - + usize::from(fold_value_once(z)); + let child_changed = usize::from(fold_value_once(x, nodes)) + + usize::from(fold_value_once(y, nodes)) + + usize::from(fold_value_once(z, nodes)); let is_up = matches!((&**x, &**y, &**z), ( Value::Number(x), Value::Number(y), @@ -142,11 +154,11 @@ fn fold_value_once(value: &mut workshop_rs::Value) -> bool { child_changed > 0 } } - Value::PlayerVariable { player, .. } => fold_value_once(player), + Value::PlayerVariable { player, .. } => fold_value_once(player, nodes), Value::Call { name, args } => { let mut changed = args .iter_mut() - .map(|value| usize::from(fold_value_once(value))) + .map(|value| usize::from(fold_value_once(value, nodes))) .sum::() > 0; if args.len() == 2 { @@ -224,69 +236,6 @@ fn fold_value_once(value: &mut workshop_rs::Value) -> bool { } } -fn program_node_count(program: &workshop_rs::Program) -> usize { - program - .rules - .iter() - .map(|rule| { - rule.conditions - .iter() - .map(|condition| value_node_count(&condition.value)) - .sum::() - + rule.actions.iter().map(action_node_count).sum::() - }) - .sum() -} - -fn action_node_count(action: &workshop_rs::Action) -> usize { - use workshop_rs::Action; - 1 + match action { - Action::SetGlobalVariable { value, .. } - | Action::ModifyGlobalVariable { value, .. } - | Action::If { condition: value } - | Action::ElseIf { condition: value } - | Action::While { condition: value } => value_node_count(value), - Action::SetPlayerVariable { player, value, .. } - | Action::ModifyPlayerVariable { player, value, .. } => { - value_node_count(player) + value_node_count(value) - } - Action::AssignMember { target, value, .. } => { - value_node_count(target) + value_node_count(value) - } - Action::ForGlobalVariable { - start, stop, step, .. - } => value_node_count(start) + value_node_count(stop) + value_node_count(step), - Action::ForPlayerVariable { - player, - start, - stop, - step, - .. - } => { - value_node_count(player) - + value_node_count(start) - + value_node_count(stop) - + value_node_count(step) - } - Action::Disabled { action } => action_node_count(action), - Action::Call { args, .. } => args.iter().map(value_node_count).sum(), - Action::CallSubroutine { .. } | Action::Else | Action::End => 0, - } -} - -fn value_node_count(value: &workshop_rs::Value) -> usize { - use workshop_rs::Value; - 1 + match value { - Value::Array(values) => values.iter().map(value_node_count).sum(), - Value::Vector { x, y, z } => { - value_node_count(x) + value_node_count(y) + value_node_count(z) - } - Value::PlayerVariable { player, .. } => value_node_count(player), - Value::Call { args, .. } => args.iter().map(value_node_count).sum(), - _ => 0, - } -} - #[cfg(test)] mod tests { use super::*; From 980c7dfe754942a6bd0e4fbe3914ca5c0a883ac9 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:03:03 +0800 Subject: [PATCH 04/33] refactor: consolidate analyzer and driver logic --- .../wright-analyzer/src/canonical/symbols.rs | 252 ++++------ crates/wright-analyzer/src/registry.rs | 438 ++++++++---------- crates/wright-driver/src/diag.rs | 8 + crates/wright-driver/src/edit.rs | 47 +- crates/wright-driver/src/provider_edit.rs | 35 +- crates/wright-driver/src/session.rs | 344 +++++++------- crates/wright-lpp/src/provider.rs | 38 +- 7 files changed, 485 insertions(+), 677 deletions(-) diff --git a/crates/wright-analyzer/src/canonical/symbols.rs b/crates/wright-analyzer/src/canonical/symbols.rs index 96fc530..5b42b64 100644 --- a/crates/wright-analyzer/src/canonical/symbols.rs +++ b/crates/wright-analyzer/src/canonical/symbols.rs @@ -393,53 +393,50 @@ impl SemanticIndex { program: &Program, ) { let span = program.action_span(rule, action_id); - match action { - Action::SetGlobalVariable { variable, .. } - | Action::ModifyGlobalVariable { variable, .. } => { - if let Some(symbol) = self.find_symbol(SymbolKind::GlobalVariable, variable) { - self.push( - symbol, - ReferenceKind::Write, - Some(rule), - Some(action_id), - None, - action_occurrence(program, span, variable), - ); - if matches!(action, Action::ModifyGlobalVariable { .. }) { - self.push( - symbol, - ReferenceKind::Read, - Some(rule), - Some(action_id), - None, - span, - ); - } - } + let variable = match action { + Action::SetGlobalVariable { variable, .. } => { + Some((SymbolKind::GlobalVariable, variable, false)) + } + Action::ModifyGlobalVariable { variable, .. } => { + Some((SymbolKind::GlobalVariable, variable, true)) } - Action::SetPlayerVariable { variable, .. } - | Action::ModifyPlayerVariable { variable, .. } => { - if let Some(symbol) = self.find_symbol(SymbolKind::PlayerVariable, variable) { + Action::SetPlayerVariable { variable, .. } => { + Some((SymbolKind::PlayerVariable, variable, false)) + } + Action::ModifyPlayerVariable { variable, .. } => { + Some((SymbolKind::PlayerVariable, variable, true)) + } + Action::ForGlobalVariable { variable, .. } => { + Some((SymbolKind::GlobalVariable, variable, false)) + } + Action::ForPlayerVariable { variable, .. } => { + Some((SymbolKind::PlayerVariable, variable, false)) + } + _ => None, + }; + if let Some((kind, name, reads_old_value)) = variable { + if let Some(symbol) = self.find_symbol(kind, name) { + self.push( + symbol, + ReferenceKind::Write, + Some(rule), + Some(action_id), + None, + action_occurrence(program, span, name), + ); + if reads_old_value { self.push( symbol, - ReferenceKind::Write, + ReferenceKind::Read, Some(rule), Some(action_id), None, - action_occurrence(program, span, variable), + span, ); - if matches!(action, Action::ModifyPlayerVariable { .. }) { - self.push( - symbol, - ReferenceKind::Read, - Some(rule), - Some(action_id), - None, - span, - ); - } } } + } + match action { Action::CallSubroutine { subroutine } => { if let Some(symbol) = self.find_symbol(SymbolKind::Subroutine, subroutine) { self.push( @@ -452,30 +449,6 @@ impl SemanticIndex { ); } } - Action::ForGlobalVariable { variable, .. } => { - if let Some(symbol) = self.find_symbol(SymbolKind::GlobalVariable, variable) { - self.push( - symbol, - ReferenceKind::Write, - Some(rule), - Some(action_id), - None, - action_occurrence(program, span, variable), - ); - } - } - Action::ForPlayerVariable { variable, .. } => { - if let Some(symbol) = self.find_symbol(SymbolKind::PlayerVariable, variable) { - self.push( - symbol, - ReferenceKind::Write, - Some(rule), - Some(action_id), - None, - action_occurrence(program, span, variable), - ); - } - } Action::Disabled { action } => { self.walk_action(action, rule, action_id, program); return; @@ -541,15 +514,43 @@ fn declaration_span(program: &Program, prefix: &str, name: &str) -> Option let Some(source) = program.source(file) else { continue; }; - for (line_index, line) in source.text().lines().enumerate() { + if let Some(span) = + declaration_span_in_source(file, source.text(), std::slice::from_ref(&prefix), name) + { + return Some(span); + } + } + None +} + +fn declaration_span_in_sources( + sources: &[(workshop_rs::source::FileId, String)], + prefixes: &[&str], + name: &str, +) -> Option { + for (file, source) in sources { + if let Some(span) = declaration_span_in_source(*file, source, prefixes, name) { + return Some(span); + } + } + None +} + +fn declaration_span_in_source( + file: workshop_rs::source::FileId, + source: &str, + prefixes: &[&str], + name: &str, +) -> Option { + for (line_index, line) in source.lines().enumerate() { + for prefix in prefixes { let Some(rest) = line.strip_prefix(prefix) else { continue; }; let Some(found) = rest.split_whitespace().next() else { continue; }; - let found = found.trim_matches('"'); - if found != name { + if found.trim_matches('"') != name { continue; } let start = prefix.chars().count() as u32 + 1; @@ -566,39 +567,6 @@ fn declaration_span(program: &Program, prefix: &str, name: &str) -> Option None } -fn declaration_span_in_sources( - sources: &[(workshop_rs::source::FileId, String)], - prefixes: &[&str], - name: &str, -) -> Option { - for (file, source) in sources { - for (line_index, line) in source.lines().enumerate() { - for prefix in prefixes { - let Some(rest) = line.strip_prefix(prefix) else { - continue; - }; - let Some(found) = rest.split_whitespace().next() else { - continue; - }; - let found = found.trim_matches('"'); - if found != name { - continue; - } - let start = prefix.chars().count() as u32 + 1; - return Some(Span::new( - *file, - workshop_rs::source::Position::new(line_index as u32 + 1, start), - workshop_rs::source::Position::new( - line_index as u32 + 1, - start + name.chars().count() as u32, - ), - )); - } - } - } - None -} - fn declaration_line_span( sources: &[(workshop_rs::source::FileId, String)], name_span: Span, @@ -631,6 +599,17 @@ fn occurrence_in_sources( .iter() .find(|(file, _)| *file == span.file) .map(|(_, source)| source.as_str())?; + find_occurrence(source, span, name, before_assignment, ordinal, true) +} + +fn find_occurrence( + source: &str, + span: Span, + name: &str, + before_assignment: bool, + ordinal: usize, + skip_non_code: bool, +) -> Option { let name_chars: Vec = name.chars().collect(); let mut found_index = 0; for line_number in span.start.line..=span.end.line { @@ -656,7 +635,7 @@ fn occurrence_in_sources( if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { continue; } - if !is_code_position(&chars, start) { + if skip_non_code && !is_code_position(&chars, start) { continue; } let before = start.checked_sub(1).and_then(|index| chars.get(index)); @@ -702,44 +681,7 @@ fn action_occurrence(program: &Program, span: Option, name: &str) -> Optio let Some(source_doc) = program.source(span.file) else { return Some(span); }; - let source = source_doc.text(); - let name_chars: Vec = name.chars().collect(); - for line_number in span.start.line..=span.end.line { - let line = source.lines().nth(line_number.saturating_sub(1) as usize)?; - let chars: Vec = line.chars().collect(); - let lower = if line_number == span.start.line { - span.start.col.saturating_sub(1) as usize - } else { - 0 - }; - let mut upper = if line_number == span.end.line { - span.end.col.saturating_sub(1) as usize - } else { - chars.len() - }; - if let Some(operator) = line.find('=') { - upper = upper.min(operator); - } - for start in lower.min(chars.len())..=upper.min(chars.len()) { - let end = start.saturating_add(name_chars.len()); - if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { - continue; - } - let before = start.checked_sub(1).and_then(|index| chars.get(index)); - let after = chars.get(end); - if before.is_some_and(|character| character.is_alphanumeric() || *character == '_') - || after.is_some_and(|character| character.is_alphanumeric() || *character == '_') - { - continue; - } - return Some(Span::new( - span.file, - workshop_rs::source::Position::new(line_number, start as u32 + 1), - workshop_rs::source::Position::new(line_number, end as u32 + 1), - )); - } - } - Some(span) + Some(find_occurrence(source_doc.text(), span, name, true, 0, false).unwrap_or(span)) } pub(super) fn value_occurrence(program: &Program, span: Option, name: &str) -> Option { @@ -747,39 +689,5 @@ pub(super) fn value_occurrence(program: &Program, span: Option, name: &str let Some(source_doc) = program.source(span.file) else { return Some(span); }; - let source = source_doc.text(); - let name_chars: Vec = name.chars().collect(); - for line_number in span.start.line..=span.end.line { - let line = source.lines().nth(line_number.saturating_sub(1) as usize)?; - let chars: Vec = line.chars().collect(); - let lower = if line_number == span.start.line { - span.start.col.saturating_sub(1) as usize - } else { - 0 - }; - let upper = if line_number == span.end.line { - span.end.col.saturating_sub(1) as usize - } else { - chars.len() - }; - for start in lower.min(chars.len())..=upper.min(chars.len()) { - let end = start.saturating_add(name_chars.len()); - if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { - continue; - } - let before = start.checked_sub(1).and_then(|index| chars.get(index)); - let after = chars.get(end); - if before.is_some_and(|character| character.is_alphanumeric() || *character == '_') - || after.is_some_and(|character| character.is_alphanumeric() || *character == '_') - { - continue; - } - return Some(Span::new( - span.file, - workshop_rs::source::Position::new(line_number, start as u32 + 1), - workshop_rs::source::Position::new(line_number, end as u32 + 1), - )); - } - } - Some(span) + Some(find_occurrence(source_doc.text(), span, name, false, 0, false).unwrap_or(span)) } diff --git a/crates/wright-analyzer/src/registry.rs b/crates/wright-analyzer/src/registry.rs index 232cef4..1cd5bf6 100644 --- a/crates/wright-analyzer/src/registry.rs +++ b/crates/wright-analyzer/src/registry.rs @@ -202,19 +202,11 @@ impl LintConfig { /// Effective severity for a rule given its metadata and this config. pub fn effective_severity(&self, meta: &RuleMeta) -> Severity { - self.rules - .get(meta.id) - .and_then(|config| config.severity_override) - .and_then(SeverityLabel::severity) - .unwrap_or(meta.default_severity) + self.effective_severity_for(meta.id, meta.default_severity) } pub fn effective_severity_for(&self, id: &str, default: Severity) -> Severity { - self.rules - .get(id) - .and_then(|config| config.severity_override) - .and_then(SeverityLabel::severity) - .unwrap_or(default) + self.severity_override(id).unwrap_or(default) } pub fn severity_override(&self, id: &str) -> Option { @@ -268,10 +260,19 @@ pub struct LintRun { pub skipped: Vec, } -/// One registered rule: its stable metadata and optional declarative rule. -struct RegistryEntry { - meta: Option, - declarative: Option, +/// One registered rule and the implementation that owns it. +enum RegistryEntry { + Native(RuleMeta), + Declarative(Box), +} + +impl RegistryEntry { + fn id(&self) -> &str { + match self { + Self::Native(meta) => meta.id, + Self::Declarative(rule) => rule.id(), + } + } } /// The Wright lint rule registry. @@ -286,178 +287,160 @@ impl Default for LintRegistry { /// `while-without-wait`. fn default() -> Self { let entries = vec![ - RegistryEntry { - meta: Some(RuleMeta { - id: "min-wait-loop", - default_severity: Severity::Warning, - evidence: EvidenceClass::StaticIndicator, - summary: "loop body waits at the workshop minimum rate", - rationale: "Avoid sustained maximum-frequency loop execution.", - documentation: concat!( - "A loop whose body contains a `wait` call at the minimum Workshop ", - "duration (~0.016 s) runs at maximum server frequency. Sustained ", - "high-frequency loops can degrade server performance for all players.", - ), - known_limits: concat!( - "Wait durations that are not statically known (computed at runtime) ", - "are treated as not-minimum and do not trigger this rule.", - ), - tags: &["performance", "stability"], - }), - declarative: None, - }, - RegistryEntry { - meta: Some(RuleMeta { - id: "duplicate-condition", - default_severity: Severity::Warning, - evidence: EvidenceClass::Exact, - summary: "condition is evaluated more than once within one rule", - rationale: "Avoid unreachable or redundant conditional branches.", - documentation: concat!( - "The same condition appears in two or more branches of the same rule. ", - "Because Workshop conditions are evaluated sequentially, a later branch ", - "with an identical condition can never be taken.", - ), - known_limits: concat!( - "Detection is structural (not value-flow) and rule-local: two ", - "structurally identical conditions in different rules are not compared.", - ), - tags: &["correctness"], - }), - declarative: None, - }, - RegistryEntry { - meta: Some(RuleMeta { - id: "expensive-loop-check", - default_severity: Severity::Info, - evidence: EvidenceClass::Heuristic, - summary: "geometry predicate evaluated inside a loop body", - rationale: "Surface expensive per-iteration geometry work.", - documentation: concat!( - "A geometry predicate (`distance`, `raycast`, or `isInLoS`) is called ", - "inside a loop body. These predicates may be expensive per evaluation ", - "and can accumulate significant cost at loop frequency.", - ), - known_limits: concat!( - "The expensive-call list is a fixed heuristic. It may miss unusual ", - "predicates or over-flag predicates that have been made cheap by a ", - "Workshop update.", - ), - tags: &["performance"], - }), - declarative: None, - }, - RegistryEntry { - meta: Some(RuleMeta { - id: "ongoing-condition-hot-path", - default_severity: Severity::Info, - evidence: EvidenceClass::Heuristic, - summary: "geometry predicate evaluated in an ongoing-rule condition", - rationale: "Make high-frequency condition evaluation visible.", - documentation: concat!( - "An `Ongoing - Global` or `Ongoing - Each Player` rule evaluates a ", - "geometry predicate (`distance`, `raycast`, or `isInLoS`) in one of ", - "its conditions. Each server tick evaluates conditions in source order ", - "until one short-circuits the rule, so a predicate in a later condition ", - "is reached only after every preceding condition passes. The finding ", - "identifies the condition's position and any later short-circuit gates. ", - "This concerns condition evaluation, not a claim ", - "that the action block executes every tick while conditions remain true. ", - "Real-project evidence: overpy-cronch's `challenge 1 ", - "finished` rule (Zezombye/overpy commit ", - "`eea67adbcf6926c4004e35e25ab4be072624a44e`, GPL-3.0-only) evaluates ", - "`Distance Between` in an ongoing global condition after its cheap ", - "challenge-state gate.", - ), - known_limits: concat!( - "The geometry-predicate list is a fixed heuristic and may miss other ", - "costly operations or over-flag a predicate made cheap by a Workshop ", - "update. The analysis reports canonical event identity, condition order, ", - "and predicate presence; it does not measure runtime CPU cost, assume a ", - "server population, or infer the selectivity of any condition. ", - "Non-ongoing player events and subroutines are deliberately excluded.", - ), - tags: &["performance", "stability"], - }), - declarative: None, - }, - RegistryEntry { - meta: Some(RuleMeta { - id: "repeated-value", - default_severity: Severity::Warning, - evidence: EvidenceClass::Exact, - summary: "identical value expression evaluated more than once in one loop scope", - rationale: "Avoid repeated evaluation of the same loop-local expression.", - documentation: concat!( - "A structurally identical value expression appears more than once within one ", - "loop scope, so it is re-evaluated every iteration even though one ", - "evaluation would suffice. Within a single atomic evaluation the ", - "re-evaluation is redundant for deterministic expressions; the Workshop ", - "ecosystem has an `Evaluate Once` idiom for exactly this cost. One ", - "finding is reported per distinct duplicated shape per loop scope, at ", - "the shape's first occurrence, with the statically known occurrence ", - "count. Real-project evidence: overpy-santa (workshop lines 108-112; ", - "`santa.opy:72-77`) and overpy-parabola (workshop lines 79-80; ", - "`parabola.opy:45-51`), both pinned at `Zezombye/overpy` commit ", - "`eea67adbcf6926c4004e35e25ab4be072624a44e` (GPL-3.0-only, redistributable).", - ), - known_limits: concat!( - "Detection is rule-local and structural (arena-id-independent call name ", - "plus argument shape) with no value-flow analysis: a duplicate across ", - "separate actions proves re-scheduling, not result-equality, because an ", - "intervening action may mutate a read variable. A duplicated expression ", - "is reported once per loop scope at its maximal shape, so nested ", - "duplicates are subsumed; expressions with fewer than two call nodes, ", - "including bare array reads, are never flagged. Sub-expressions ", - "containing non-deterministic values (e.g. `Random Value`/`Random Real`) ", - "are still guaranteed to be re-evaluated, but the finding may not ", - "indicate a defect. `Evaluate Once`-wrapped inner reads reduce but do ", - "not eliminate the outer recomputation. Loop coverage is `While` + ", - "`For Global Variable` only; `For Player Variable` loops are not modeled.", - ), - tags: &["performance", "stability"], - }), - declarative: None, - }, - RegistryEntry { - meta: Some(RuleMeta { - id: "while-without-wait", - default_severity: Severity::Warning, - evidence: EvidenceClass::StaticIndicator, - summary: "while loop body contains no wait call", - rationale: "Ensure a loop can yield to the Workshop scheduler.", - documentation: concat!( - "A `While` loop whose body contains no `wait` call cannot yield to the ", - "server while its condition holds. Each finding carries the loop's ", - "boundedness evidence (`obviously-unbounded`, `statically-bounded`, or ", - "`unknown`), and the severity is derived from that evidence: `info` for ", - "a statically bounded no-yield loop, `warning` for an obviously ", - "unbounded or unknown one. A bounded no-yield loop is explicitly NOT ", - "treated as equivalent to an unbounded one. Real-consumer evidence: ", - "the agent-lab repro `loop-waitless.opy` (wrightkit/agent-lab#68) — ", - "`globalvar loopCount; rule \"waitless loop\": @Condition ", - "getTotalTimeElapsed() > 5; loopCount = 0; while loopCount < 10: ", - "loopCount += 1` — is classified as `statically-bounded` (a finite ", - "10-iteration counter loop), not as an unbounded hazard; the ", - "Workshop Agent analyzer reports the same construct as ", - "`workshop.performance.waitless-loop`, and Wright deliberately does ", - "not copy that severity semantics (issue #103).", - ), - known_limits: concat!( - "Counter-pattern detection is conservative and structural: only ", - "literal-bound comparisons (`<`, `<=`, `>`, `>=`) are recognized. A ", - "loop is bounded only when: (1) its condition compares a variable to a ", - "literal bound, (2) the body unconditionally increments/decrements that ", - "same variable, and (3) the step moves the variable toward the bound. ", - "Arbitrary step expressions, dynamic bounds, multiple counter ", - "mutations, nested condition resets, and loops that yield via `wait` ", - "are outside this classification. An unclassified loop produces `unknown` ", - "evidence; it is NOT assumed to be unbounded (issue #103).", - ), - tags: &["performance", "stability"], - }), - declarative: None, - }, + RegistryEntry::Native(RuleMeta { + id: "min-wait-loop", + default_severity: Severity::Warning, + evidence: EvidenceClass::StaticIndicator, + summary: "loop body waits at the workshop minimum rate", + rationale: "Avoid sustained maximum-frequency loop execution.", + documentation: concat!( + "A loop whose body contains a `wait` call at the minimum Workshop ", + "duration (~0.016 s) runs at maximum server frequency. Sustained ", + "high-frequency loops can degrade server performance for all players.", + ), + known_limits: concat!( + "Wait durations that are not statically known (computed at runtime) ", + "are treated as not-minimum and do not trigger this rule.", + ), + tags: &["performance", "stability"], + }), + RegistryEntry::Native(RuleMeta { + id: "duplicate-condition", + default_severity: Severity::Warning, + evidence: EvidenceClass::Exact, + summary: "condition is evaluated more than once within one rule", + rationale: "Avoid unreachable or redundant conditional branches.", + documentation: concat!( + "The same condition appears in two or more branches of the same rule. ", + "Because Workshop conditions are evaluated sequentially, a later branch ", + "with an identical condition can never be taken.", + ), + known_limits: concat!( + "Detection is structural (not value-flow) and rule-local: two ", + "structurally identical conditions in different rules are not compared.", + ), + tags: &["correctness"], + }), + RegistryEntry::Native(RuleMeta { + id: "expensive-loop-check", + default_severity: Severity::Info, + evidence: EvidenceClass::Heuristic, + summary: "geometry predicate evaluated inside a loop body", + rationale: "Surface expensive per-iteration geometry work.", + documentation: concat!( + "A geometry predicate (`distance`, `raycast`, or `isInLoS`) is called ", + "inside a loop body. These predicates may be expensive per evaluation ", + "and can accumulate significant cost at loop frequency.", + ), + known_limits: concat!( + "The expensive-call list is a fixed heuristic. It may miss unusual ", + "predicates or over-flag predicates that have been made cheap by a ", + "Workshop update.", + ), + tags: &["performance"], + }), + RegistryEntry::Native(RuleMeta { + id: "ongoing-condition-hot-path", + default_severity: Severity::Info, + evidence: EvidenceClass::Heuristic, + summary: "geometry predicate evaluated in an ongoing-rule condition", + rationale: "Make high-frequency condition evaluation visible.", + documentation: concat!( + "An `Ongoing - Global` or `Ongoing - Each Player` rule evaluates a ", + "geometry predicate (`distance`, `raycast`, or `isInLoS`) in one of ", + "its conditions. Each server tick evaluates conditions in source order ", + "until one short-circuits the rule, so a predicate in a later condition ", + "is reached only after every preceding condition passes. The finding ", + "identifies the condition's position and any later short-circuit gates. ", + "This concerns condition evaluation, not a claim ", + "that the action block executes every tick while conditions remain true. ", + "Real-project evidence: overpy-cronch's `challenge 1 ", + "finished` rule (Zezombye/overpy commit ", + "`eea67adbcf6926c4004e35e25ab4be072624a44e`, GPL-3.0-only) evaluates ", + "`Distance Between` in an ongoing global condition after its cheap ", + "challenge-state gate.", + ), + known_limits: concat!( + "The geometry-predicate list is a fixed heuristic and may miss other ", + "costly operations or over-flag a predicate made cheap by a Workshop ", + "update. The analysis reports canonical event identity, condition order, ", + "and predicate presence; it does not measure runtime CPU cost, assume a ", + "server population, or infer the selectivity of any condition. ", + "Non-ongoing player events and subroutines are deliberately excluded.", + ), + tags: &["performance", "stability"], + }), + RegistryEntry::Native(RuleMeta { + id: "repeated-value", + default_severity: Severity::Warning, + evidence: EvidenceClass::Exact, + summary: "identical value expression evaluated more than once in one loop scope", + rationale: "Avoid repeated evaluation of the same loop-local expression.", + documentation: concat!( + "A structurally identical value expression appears more than once within one ", + "loop scope, so it is re-evaluated every iteration even though one ", + "evaluation would suffice. Within a single atomic evaluation the ", + "re-evaluation is redundant for deterministic expressions; the Workshop ", + "ecosystem has an `Evaluate Once` idiom for exactly this cost. One ", + "finding is reported per distinct duplicated shape per loop scope, at ", + "the shape's first occurrence, with the statically known occurrence ", + "count. Real-project evidence: overpy-santa (workshop lines 108-112; ", + "`santa.opy:72-77`) and overpy-parabola (workshop lines 79-80; ", + "`parabola.opy:45-51`), both pinned at `Zezombye/overpy` commit ", + "`eea67adbcf6926c4004e35e25ab4be072624a44e` (GPL-3.0-only, redistributable).", + ), + known_limits: concat!( + "Detection is rule-local and structural (arena-id-independent call name ", + "plus argument shape) with no value-flow analysis: a duplicate across ", + "separate actions proves re-scheduling, not result-equality, because an ", + "intervening action may mutate a read variable. A duplicated expression ", + "is reported once per loop scope at its maximal shape, so nested ", + "duplicates are subsumed; expressions with fewer than two call nodes, ", + "including bare array reads, are never flagged. Sub-expressions ", + "containing non-deterministic values (e.g. `Random Value`/`Random Real`) ", + "are still guaranteed to be re-evaluated, but the finding may not ", + "indicate a defect. `Evaluate Once`-wrapped inner reads reduce but do ", + "not eliminate the outer recomputation. Loop coverage is `While` + ", + "`For Global Variable` only; `For Player Variable` loops are not modeled.", + ), + tags: &["performance", "stability"], + }), + RegistryEntry::Native(RuleMeta { + id: "while-without-wait", + default_severity: Severity::Warning, + evidence: EvidenceClass::StaticIndicator, + summary: "while loop body contains no wait call", + rationale: "Ensure a loop can yield to the Workshop scheduler.", + documentation: concat!( + "A `While` loop whose body contains no `wait` call cannot yield to the ", + "server while its condition holds. Each finding carries the loop's ", + "boundedness evidence (`obviously-unbounded`, `statically-bounded`, or ", + "`unknown`), and the severity is derived from that evidence: `info` for ", + "a statically bounded no-yield loop, `warning` for an obviously ", + "unbounded or unknown one. A bounded no-yield loop is explicitly NOT ", + "treated as equivalent to an unbounded one. Real-consumer evidence: ", + "the agent-lab repro `loop-waitless.opy` (wrightkit/agent-lab#68) — ", + "`globalvar loopCount; rule \"waitless loop\": @Condition ", + "getTotalTimeElapsed() > 5; loopCount = 0; while loopCount < 10: ", + "loopCount += 1` — is classified as `statically-bounded` (a finite ", + "10-iteration counter loop), not as an unbounded hazard; the ", + "Workshop Agent analyzer reports the same construct as ", + "`workshop.performance.waitless-loop`, and Wright deliberately does ", + "not copy that severity semantics (issue #103).", + ), + known_limits: concat!( + "Counter-pattern detection is conservative and structural: only ", + "literal-bound comparisons (`<`, `<=`, `>`, `>=`) are recognized. A ", + "loop is bounded only when: (1) its condition compares a variable to a ", + "literal bound, (2) the body unconditionally increments/decrements that ", + "same variable, and (3) the step moves the variable toward the bound. ", + "Arbitrary step expressions, dynamic bounds, multiple counter ", + "mutations, nested condition resets, and loops that yield via `wait` ", + "are outside this classification. An unclassified loop produces `unknown` ", + "evidence; it is NOT assumed to be unbounded (issue #103).", + ), + tags: &["performance", "stability"], + }), ]; Self { entries } } @@ -465,7 +448,10 @@ impl Default for LintRegistry { impl LintRegistry { pub fn rules(&self) -> impl Iterator { - self.entries.iter().filter_map(|entry| entry.meta.as_ref()) + self.entries.iter().filter_map(|entry| match entry { + RegistryEntry::Native(meta) => Some(meta), + RegistryEntry::Declarative(_) => None, + }) } /// Load one external declarative rule against the canonical Workshop catalog. @@ -518,19 +504,11 @@ impl LintRegistry { fn insert_declarative(&mut self, rule: DeclarativeRule) -> Result<(), RuleRegistryError> { let id = rule.id().to_string(); - if self.entries.iter().any(|entry| { - entry.meta.as_ref().is_some_and(|meta| meta.id == id) - || entry - .declarative - .as_ref() - .is_some_and(|other| other.id() == id) - }) { + if self.entries.iter().any(|entry| entry.id() == id) { return Err(RuleRegistryError::DuplicateId(id)); } - self.entries.push(RegistryEntry { - meta: None, - declarative: Some(rule), - }); + self.entries + .push(RegistryEntry::Declarative(Box::new(rule))); Ok(()) } @@ -538,26 +516,21 @@ impl LintRegistry { pub fn descriptors(&self, config: &LintConfig) -> Vec { self.entries .iter() - .map(|entry| { - if let Some(meta) = &entry.meta { - RuleDescriptor { - id: meta.id.to_string(), - default_severity: meta.default_severity, - effective_severity: config.effective_severity(meta), - enabled: config.is_enabled(meta.id), - summary: meta.summary.to_string(), - rationale: meta.rationale.to_string(), - documentation: meta.documentation.to_string(), - known_limits: meta.known_limits.to_string(), - evidence: meta.evidence, - tags: meta.tags.iter().map(|tag| (*tag).to_string()).collect(), - kind: "native", - } - } else { - let rule = entry - .declarative - .as_ref() - .expect("registry entry has a rule"); + .map(|entry| match entry { + RegistryEntry::Native(meta) => RuleDescriptor { + id: meta.id.to_string(), + default_severity: meta.default_severity, + effective_severity: config.effective_severity(meta), + enabled: config.is_enabled(meta.id), + summary: meta.summary.to_string(), + rationale: meta.rationale.to_string(), + documentation: meta.documentation.to_string(), + known_limits: meta.known_limits.to_string(), + evidence: meta.evidence, + tags: meta.tags.iter().map(|tag| (*tag).to_string()).collect(), + kind: "native", + }, + RegistryEntry::Declarative(rule) => { let metadata = rule.metadata(); RuleDescriptor { id: rule.id().to_string(), @@ -599,19 +572,9 @@ impl LintRegistry { continue; } for entry in &self.entries { - let id = entry - .meta - .as_ref() - .map(|meta| meta.id.to_string()) - .or_else(|| { - entry - .declarative - .as_ref() - .map(|declarative| declarative.id().to_string()) - }); - if let Some(id) = id.filter(|id| config.is_enabled(id)) { + if config.is_enabled(entry.id()) { report.skipped.push(SkippedRule { - id, + id: entry.id().to_string(), rule: rule_id, reason: "canonical CFG unavailable".to_string(), }); @@ -626,18 +589,7 @@ impl LintRegistry { .entries .iter() .enumerate() - .filter_map(|(index, entry)| { - entry - .meta - .as_ref() - .map(|meta| (meta.id.to_string(), index)) - .or_else(|| { - entry - .declarative - .as_ref() - .map(|rule| (rule.id().to_string(), index)) - }) - }) + .map(|(index, entry)| (entry.id().to_string(), index)) .collect(); findings.sort_by_key(|finding| { ( @@ -660,7 +612,7 @@ impl LintRegistry { let mut findings = Vec::new(); for rule in 0..program.rules.len() { for entry in &self.entries { - let Some(declarative) = &entry.declarative else { + let RegistryEntry::Declarative(declarative) = entry else { continue; }; if !config.is_enabled(declarative.id()) { diff --git a/crates/wright-driver/src/diag.rs b/crates/wright-driver/src/diag.rs index 705fb12..b2d396b 100644 --- a/crates/wright-driver/src/diag.rs +++ b/crates/wright-driver/src/diag.rs @@ -169,3 +169,11 @@ impl Diagnostic { } } } + +pub(crate) fn source_provider_unavailable() -> Diagnostic { + Diagnostic::error( + "source-provider-unavailable", + Stage::Internal, + "the requested source-provider workflow is not currently shipped with Wright", + ) +} diff --git a/crates/wright-driver/src/edit.rs b/crates/wright-driver/src/edit.rs index acaf869..f22812d 100644 --- a/crates/wright-driver/src/edit.rs +++ b/crates/wright-driver/src/edit.rs @@ -7,7 +7,9 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::config::{SessionConfig, SourceKind}; -use crate::diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; +use crate::diag::{ + Diagnostic, Origin, Position, Severity, SourceSpan, Stage, source_provider_unavailable, +}; use crate::input::ResolvedInput; use crate::result::exit_code_from; @@ -218,20 +220,8 @@ pub fn validate_transaction( ) -> EditValidation { let mut diagnostics = Vec::new(); for edit in &transaction.edits { - let Some(current) = sources.get(&edit.source) else { - diagnostics.push(Diagnostic::error( - "edit-unknown-source", - Stage::Discovery, - format!("the edit targets '{}' but no current text was provided for it; supply the current source so the version precondition can be verified", edit.source), - )); - continue; - }; - if crate::input_identity(current) != edit.source_identity { - diagnostics.push(Diagnostic::error( - "edit-stale-source", - Stage::Discovery, - format!("the edit for '{}' targets a different source version (identity mismatch); re-fetch the source and retry", edit.source), - )); + if let Some(diagnostic) = source_precondition(edit, sources) { + diagnostics.push(diagnostic); } } if has_error(&diagnostics) { @@ -785,12 +775,27 @@ fn build_overlay<'a>( overlay } -fn source_provider_unavailable() -> Diagnostic { - Diagnostic::error( - "source-provider-unavailable", - Stage::Internal, - "the requested source-provider workflow is not currently shipped with Wright", - ) +pub(crate) fn source_precondition( + edit: &SourceEdit, + sources: &BTreeMap, +) -> Option { + let Some(current) = sources.get(&edit.source) else { + return Some(Diagnostic::error( + "edit-unknown-source", + Stage::Discovery, + format!( + "the edit targets '{}' but no current text was provided for it; supply the current source so the version precondition can be verified", + edit.source + ), + )); + }; + (crate::input_identity(current) != edit.source_identity).then(|| { + Diagnostic::error( + "edit-stale-source", + Stage::Discovery, + format!("the edit for '{}' targets a different source version (identity mismatch); re-fetch the source and retry", edit.source), + ) + }) } fn apply_transaction( diff --git a/crates/wright-driver/src/provider_edit.rs b/crates/wright-driver/src/provider_edit.rs index 6462645..d68e666 100644 --- a/crates/wright-driver/src/provider_edit.rs +++ b/crates/wright-driver/src/provider_edit.rs @@ -142,8 +142,10 @@ fn finish_transaction( sources: &BTreeMap, project_root: Option<&str>, ) -> ProviderMutation { - if let Err(diag) = check_preconditions(&transaction, sources) { - return refusal(vec![diag], None); + for edit in &transaction.edits { + if let Some(diagnostic) = crate::edit::source_precondition(edit, sources) { + return refusal(vec![diagnostic], None); + } } let previews = match transaction.apply(sources) { Ok(previews) => previews, @@ -164,35 +166,6 @@ fn finish_transaction( } } -fn check_preconditions( - transaction: &EditTransaction, - sources: &BTreeMap, -) -> Result<(), Diagnostic> { - for edit in &transaction.edits { - let Some(current) = sources.get(&edit.source) else { - return Err(Diagnostic::error( - "edit-unknown-source", - Stage::Discovery, - format!( - "the edit targets '{}' but no current text was provided for it; supply the current source so the version precondition can be verified", - edit.source - ), - )); - }; - if crate::input_identity(current) != edit.source_identity { - return Err(Diagnostic::error( - "edit-stale-source", - Stage::Discovery, - format!( - "the edit for '{}' targets a different source version (identity mismatch); re-fetch the source and retry", - edit.source - ), - )); - } - } - Ok(()) -} - fn validate_pipeline( provider: &mut dyn wright_lpp::LanguageProvider, documents: &wright_lpp::DocumentSet, diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 48bdb32..5d6b635 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -13,7 +13,9 @@ use wright_analyzer::service::{Origin as ServiceOrigin, Request}; use crate::WorkshopProvider; use crate::config::{InputSpec, SessionConfig, SourceKind}; -use crate::diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; +use crate::diag::{ + Diagnostic, Origin, Position, Severity, SourceSpan, Stage, source_provider_unavailable, +}; use crate::input::{self, InputTarget, ResolvedInput}; use crate::input_identity; use crate::opy_provider; @@ -609,88 +611,70 @@ impl CompilerSession { } pub fn check(&mut self) -> Envelope { - let loaded = match self.load_with_operation(ProviderOperation::Check) { - Ok(loaded) => loaded, - Err(diagnostic) => { - self.diagnostics.push(diagnostic); - return self.finish("check", CheckResult {}); - } - }; - self.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); - self.attach_workshop_completeness(&loaded); - self.finish("check", CheckResult {}) + self.with_loaded( + "check", + |session| session.load_with_operation(ProviderOperation::Check), + |session, loaded| { + session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); + session.attach_workshop_completeness(&loaded); + CheckResult {} + }, + ) } pub fn analyze(&mut self) -> Envelope { - let loaded = match self.load_with_operation(ProviderOperation::Compile) { - Ok(loaded) => loaded, - Err(diagnostic) => { - self.diagnostics.push(diagnostic); - return self.finish("analyze", AnalyzeResult::default()); - } - }; - let service = match self.service(&loaded) { - Ok(service) => service, - Err(diagnostic) => { - self.diagnostics.push(diagnostic); - return self.finish("analyze", AnalyzeResult::default()); - } - }; - self.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); - let mut program = service_response(&service, &Request::Program); - if let serde_json::Value::Object(object) = &mut program { - object.remove("findings"); - } - let mut facts = semantic_facts(&service); - if loaded.provenance == Provenance::Mapped { - resolve_nested_span_paths(&mut facts, &loaded); - } - self.finish("analyze", AnalyzeResult { program, facts }) + self.with_loaded( + "analyze", + |session| session.load_with_operation(ProviderOperation::Compile), + |session, loaded| { + let service = session.service(&loaded); + session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); + let mut program = service_response(&service, &Request::Program); + if let serde_json::Value::Object(object) = &mut program { + object.remove("findings"); + } + let mut facts = semantic_facts(&service); + if loaded.provenance == Provenance::Mapped { + resolve_nested_span_paths(&mut facts, &loaded); + } + AnalyzeResult { program, facts } + }, + ) } /// `inspect`: load and produce the structural/semantic program model. pub fn inspect(&mut self) -> Envelope { - let loaded = match self.load() { - Ok(loaded) => loaded, - Err(diagnostic) => { - self.diagnostics.push(diagnostic); - return self.finish("inspect", InspectResult::default()); - } - }; - let service = match self.service(&loaded) { - Ok(service) => service, - Err(diagnostic) => { - self.diagnostics.push(diagnostic); - return self.finish("inspect", InspectResult::default()); - } - }; - self.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); - let program = service_response(&service, &Request::Program); - let rules = service_response(&service, &Request::ListRules); - let symbols = service_response(&service, &Request::ListSymbols { kind: None }); - let references = serde_json::Value::Array( - symbols - .as_array() - .map(|list| { - list.iter() - .filter_map(|s| s.get("id").and_then(serde_json::Value::as_u64)) - .map(|id| { - service_response( - &service, - &Request::FindReferences { symbol: id as u32 }, - ) - }) - .collect() - }) - .unwrap_or_default(), - ); - self.finish( + self.with_loaded( "inspect", - InspectResult { - program, - rules, - symbols, - references, + |session| session.load(), + |session, loaded| { + let service = session.service(&loaded); + session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); + let program = service_response(&service, &Request::Program); + let rules = service_response(&service, &Request::ListRules); + let symbols = service_response(&service, &Request::ListSymbols { kind: None }); + let references = serde_json::Value::Array( + symbols + .as_array() + .map(|list| { + list.iter() + .filter_map(|s| s.get("id").and_then(serde_json::Value::as_u64)) + .map(|id| { + service_response( + &service, + &Request::FindReferences { symbol: id as u32 }, + ) + }) + .collect() + }) + .unwrap_or_default(), + ); + InspectResult { + program, + rules, + symbols, + references, + } }, ) } @@ -703,109 +687,92 @@ impl CompilerSession { /// Rule enable/disable and severity come from `self.config.lint`, the same /// configuration the CLI flags and programmatic consumers set. pub fn lint(&mut self) -> Envelope { - let command = "lint"; - let loaded = match self.load_with_operation(ProviderOperation::Compile) { - Ok(loaded) => loaded, - Err(diagnostic) => { - self.diagnostics.push(diagnostic); - return self.finish(command, LintResult::default()); - } - }; - self.attach_workshop_completeness(&loaded); - let service = match self.service_with(&loaded, self.config.lint.clone()) { - Ok(service) => service, - Err(diagnostic) => { - self.diagnostics.push(diagnostic); - return self.finish(command, LintResult::default()); - } - }; - self.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); - let program = service_response(&service, &Request::Program); - let lint_rules = service_response(&service, &Request::LintRules); - let lint_rule_count = lint_rules - .pointer("/rules") - .and_then(serde_json::Value::as_array) - .map_or(0, Vec::len); - self.progress(ProgressEvent::with_count( - ProgressPhase::Linting, - lint_rule_count, - ProgressUnit::Rules, - )); - let mut findings = service_response(&service, &Request::GetFindings); - resolve_finding_span_paths(&mut findings, &loaded); - let (rules, config, skipped) = if let serde_json::Value::Object(mut object) = lint_rules { - ( - object - .remove("rules") - .unwrap_or_else(|| serde_json::json!([])), - object - .remove("config") - .unwrap_or_else(|| serde_json::json!({})), - object - .remove("skipped") - .unwrap_or_else(|| serde_json::json!([])), - ) - } else { - ( - serde_json::json!([]), - serde_json::json!({}), - serde_json::json!([]), - ) - }; - self.finish( + self.with_loaded( "lint", - LintResult { - input_identity: loaded.input.identity.clone(), - program, - rules, - config, - findings, - skipped, + |session| session.load_with_operation(ProviderOperation::Compile), + |session, loaded| { + session.attach_workshop_completeness(&loaded); + let service = session.service_with(&loaded, session.config.lint.clone()); + session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); + let program = service_response(&service, &Request::Program); + let lint_rules = service_response(&service, &Request::LintRules); + let lint_rule_count = lint_rules + .pointer("/rules") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); + session.progress(ProgressEvent::with_count( + ProgressPhase::Linting, + lint_rule_count, + ProgressUnit::Rules, + )); + let mut findings = service_response(&service, &Request::GetFindings); + resolve_finding_span_paths(&mut findings, &loaded); + let (rules, config, skipped) = + if let serde_json::Value::Object(mut object) = lint_rules { + ( + object + .remove("rules") + .unwrap_or_else(|| serde_json::json!([])), + object + .remove("config") + .unwrap_or_else(|| serde_json::json!({})), + object + .remove("skipped") + .unwrap_or_else(|| serde_json::json!([])), + ) + } else { + ( + serde_json::json!([]), + serde_json::json!({}), + serde_json::json!([]), + ) + }; + LintResult { + input_identity: loaded.input.identity.clone(), + program, + rules, + config, + findings, + skipped, + } }, ) } pub fn convert(&mut self, target: ConvertTarget) -> Envelope { - let loaded = match self.load() { - Ok(loaded) => loaded, - Err(diagnostic) => { - self.diagnostics.push(diagnostic); - return self.finish("convert", ConvertResult::default()); - } - }; - if loaded.input.kind != SourceKind::Workshop { - self.diagnostics.push(Diagnostic::error( - "convert-input-kind", - Stage::Discovery, - format!( - "convert reconstructs Workshop input; got '{}' input (the declared conversion surface has no direct OPY ↔ OSTW path)", - loaded.input.kind.as_str() - ), - )); - return self.finish("convert", ConvertResult::default()); - } - self.progress(ProgressEvent::new(ProgressPhase::Conversion)); - let text = match target { - ConvertTarget::Opy => self.convert_opy(&loaded), - ConvertTarget::Ostw => { - self.diagnostics.push(source_provider_unavailable()); - Err(()) - } - }; - match text { - Ok(text) => { - let sha256 = input_identity(&text); - self.finish( - "convert", - ConvertResult { + self.with_loaded( + "convert", + |session| session.load(), + |session, loaded| { + if loaded.input.kind != SourceKind::Workshop { + session.diagnostics.push(Diagnostic::error( + "convert-input-kind", + Stage::Discovery, + format!( + "convert reconstructs Workshop input; got '{}' input (the declared conversion surface has no direct OPY ↔ OSTW path)", + loaded.input.kind.as_str() + ), + )); + return ConvertResult::default(); + } + session.progress(ProgressEvent::new(ProgressPhase::Conversion)); + let text = match target { + ConvertTarget::Opy => session.convert_opy(&loaded), + ConvertTarget::Ostw => { + session.diagnostics.push(source_provider_unavailable()); + Err(()) + } + }; + match text { + Ok(text) => ConvertResult { target, + sha256: input_identity(&text), text, - sha256, }, - ) - } - Err(()) => self.finish("convert", ConvertResult::default()), - } + Err(()) => ConvertResult::default(), + } + }, + ) } fn convert_opy(&mut self, loaded: &Loaded) -> Result { @@ -849,17 +816,13 @@ impl CompilerSession { } /// Build the semantic service over a loaded program. - fn service<'a>(&self, loaded: &'a Loaded) -> Result, Diagnostic> { + fn service<'a>(&self, loaded: &'a Loaded) -> SemanticService<'a> { self.service_with(loaded, LintConfig::default()) } /// Build the semantic service over a loaded program with an explicit lint /// configuration. - fn service_with<'a>( - &self, - loaded: &'a Loaded, - config: LintConfig, - ) -> Result, Diagnostic> { + fn service_with<'a>(&self, loaded: &'a Loaded, config: LintConfig) -> SemanticService<'a> { let origin = ServiceOrigin { kind: if loaded.provenance == Provenance::Unmapped { "provider-artifact".to_string() @@ -868,12 +831,12 @@ impl CompilerSession { }, locale: loaded.origin.locale.clone(), }; - Ok(SemanticService::with_origin_and_config_and_registry( + SemanticService::with_origin_and_config_and_registry( &loaded.program, origin, config, Arc::clone(&self.lint_registry), - )) + ) } /// Structural validation permits source-preserving Workshop fallbacks. @@ -956,6 +919,25 @@ impl CompilerSession { result, } } + + fn with_loaded( + &mut self, + command: &str, + load: impl FnOnce(&mut Self) -> Result, + run: impl FnOnce(&mut Self, Loaded) -> T, + ) -> Envelope + where + T: Default + serde::Serialize, + { + let result = match load(self) { + Ok(loaded) => run(self, loaded), + Err(diagnostic) => { + self.diagnostics.push(diagnostic); + T::default() + } + }; + self.finish(command, result) + } } /// Extract the `result` payload of a semantic-service request as JSON. @@ -1251,11 +1233,3 @@ fn provider_error_diagnostic(error: wright_lpp::ProviderError) -> Diagnostic { error.to_string(), ) } - -fn source_provider_unavailable() -> Diagnostic { - Diagnostic::error( - "source-provider-unavailable", - Stage::Internal, - "the requested source-provider workflow is not currently shipped with Wright", - ) -} diff --git a/crates/wright-lpp/src/provider.rs b/crates/wright-lpp/src/provider.rs index 8547a24..f7c314c 100644 --- a/crates/wright-lpp/src/provider.rs +++ b/crates/wright-lpp/src/provider.rs @@ -132,11 +132,7 @@ pub trait LanguageProvider { locale: Option<&str>, ) -> Result { let _ = (entry, project_root, locale); - Err(ProviderError::lpp( - crate::error::LppErrorKind::CapabilityUnavailable, - json!({ "capability": "projectLoading", "method": "lpp/check" }), - "capability 'projectLoading' is not available in this provider client", - )) + capability_unavailable("projectLoading", "lpp/check") } /// `lpp/check` over an LPP 1.2 file or directory target. @@ -147,11 +143,7 @@ pub trait LanguageProvider { locale: Option<&str>, ) -> Result { let _ = (target, project_root, locale); - Err(ProviderError::lpp( - crate::error::LppErrorKind::CapabilityUnavailable, - json!({ "capability": "projectLoading", "method": "lpp/check" }), - "capability 'projectLoading' is not available in this provider client", - )) + capability_unavailable("projectLoading", "lpp/check") } /// `lpp/compile`: compile a document set into one opaque Workshop @@ -170,11 +162,7 @@ pub trait LanguageProvider { locale: Option<&str>, ) -> Result { let _ = (entry, project_root, locale); - Err(ProviderError::lpp( - crate::error::LppErrorKind::CapabilityUnavailable, - json!({ "capability": "projectLoading", "method": "lpp/compile" }), - "capability 'projectLoading' is not available in this provider client", - )) + capability_unavailable("projectLoading", "lpp/compile") } /// `lpp/compile` over an LPP 1.2 file or directory target. @@ -185,11 +173,7 @@ pub trait LanguageProvider { locale: Option<&str>, ) -> Result { let _ = (target, project_root, locale); - Err(ProviderError::lpp( - crate::error::LppErrorKind::CapabilityUnavailable, - json!({ "capability": "projectLoading", "method": "lpp/compile" }), - "capability 'projectLoading' is not available in this provider client", - )) + capability_unavailable("projectLoading", "lpp/compile") } /// `lpp/compile` over a file or directory target in an LPP 1.4 session, @@ -202,11 +186,7 @@ pub trait LanguageProvider { accepted_artifact_formats: &[&str], ) -> Result { let _ = (target, project_root, locale, accepted_artifact_formats); - Err(ProviderError::lpp( - crate::error::LppErrorKind::CapabilityUnavailable, - json!({ "capability": "projectLoading", "method": "lpp/compile" }), - "capability 'projectLoading' is not available in this provider client", - )) + capability_unavailable("projectLoading", "lpp/compile") } /// `lpp/reconstruct`: reconstruct source from a provider-owned artifact. @@ -642,6 +622,14 @@ fn entry_params(entry: &ProjectEntry, project_root: Option<&str>, locale: Option params } +fn capability_unavailable(capability: &str, method: &str) -> Result { + Err(ProviderError::lpp( + crate::error::LppErrorKind::CapabilityUnavailable, + json!({ "capability": capability, "method": method }), + format!("capability '{capability}' is not available in this provider client"), + )) +} + /// Parse a typed result, converting shape failures into a deterministic /// malformed-response error. fn parse_result(value: Value, method: &str) -> Result { From 568b6f86fbe58815658d71f71c196d75cdde16eb Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:08:20 +0800 Subject: [PATCH 05/33] refactor: trim duplicate input and refusal paths --- crates/wright-driver/src/input.rs | 52 +++++++---------------- crates/wright-driver/src/session.rs | 20 +-------- crates/wright-language/src/service.rs | 59 +++++++++++---------------- 3 files changed, 40 insertions(+), 91 deletions(-) diff --git a/crates/wright-driver/src/input.rs b/crates/wright-driver/src/input.rs index 1689504..b404b08 100644 --- a/crates/wright-driver/src/input.rs +++ b/crates/wright-driver/src/input.rs @@ -43,11 +43,7 @@ fn resolve_path(path: &Path, config: &SessionConfig) -> Result kind_from_extension(path)?, other => other, }; - let root = match &config.root { - Some(r) if r.is_absolute() => r.clone(), - Some(r) => cwd.join(r), - None => path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")), - }; + let root = config + .root + .as_deref() + .map(|root| absolute_from(&cwd, root)) + .unwrap_or_else(|| { + path.parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) + }); let display = display_path(path); let origin = origin_for(kind, config.locale.as_deref()); Ok(ResolvedInput { @@ -109,17 +106,6 @@ fn resolve_directory( ) -> Result { let kind = match config.kind { SourceKind::Auto => detect_directory_kind(path)?, - SourceKind::Workshop => { - let files = direct_source_files(path, SourceKind::Workshop); - if files.len() != 1 { - return Err(directory_source_count_error( - path, - SourceKind::Workshop, - &files, - )); - } - return resolve_file(&files[0], config, cwd); - } SourceKind::Protocol => { return Err(Diagnostic::error( "input-kind-directory-unsupported", @@ -143,16 +129,6 @@ fn resolve_directory( } return resolve_file(&files[0], config, cwd); } - if kind == SourceKind::Auto { - return Err(Diagnostic::error( - "input-kind-unknown", - Stage::Discovery, - format!( - "cannot detect a source owner in directory '{}'; pass `--kind opy|ostw|workshop` or target a file", - path.display() - ), - )); - } let root = config .root .as_ref() @@ -257,10 +233,10 @@ fn directory_source_count_error(path: &Path, kind: SourceKind, files: &[PathBuf] "input-kind-ambiguous", Stage::Discovery, format!( - "Workshop directory '{}' must contain exactly one source file; {detail}; pass an explicit file path", - path.display() - ) - .replace("Workshop directory", &format!("{} directory", kind.as_str())), + "{} directory '{}' must contain exactly one source file; {detail}; pass an explicit file path", + kind.as_str(), + path.display(), + ), ) } diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 5d6b635..300ba8a 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -167,11 +167,7 @@ impl CompilerSession { } .diagnostic()); } - self.load_with_operation(if self.config.source_backend == SourceBackend::Native { - ProviderOperation::Check - } else { - ProviderOperation::Compile - }) + self.load_with_operation(ProviderOperation::Compile) } fn load_with_operation( @@ -275,19 +271,7 @@ impl CompilerSession { resolved: &mut ResolvedInput, operation: ProviderOperation, ) -> Result { - let language = match resolved.kind { - SourceKind::Opy => SourceLanguage::Opy, - other => { - return Err(Diagnostic::error( - "source-provider-kind", - Stage::Discovery, - format!( - "the provider backend currently supports only OPY input; got '{}'", - other.as_str() - ), - )); - } - }; + let language = SourceLanguage::Opy; let entry = resolved .path .clone() diff --git a/crates/wright-language/src/service.rs b/crates/wright-language/src/service.rs index 5b6924c..f60ecb1 100644 --- a/crates/wright-language/src/service.rs +++ b/crates/wright-language/src/service.rs @@ -149,54 +149,43 @@ impl LanguageService { pub fn rename(&self, uri: &str, _position: Position, new_name: &str) -> RenameResult { if new_name.is_empty() { - return RenameResult { - document_version: 0, - ok: false, - edits: Vec::new(), - previews: Vec::new(), - diagnostics: vec![ - "rename-invalid-name: the new name must not be empty".to_string(), - ], - }; + return refused_rename(0, "rename-invalid-name: the new name must not be empty"); } let Some(requesting) = self.store.document(uri) else { - return RenameResult { - document_version: 0, - ok: false, - edits: Vec::new(), - previews: Vec::new(), - diagnostics: vec![format!( + return refused_rename( + 0, + format!( "rename-unresolved: no open document for '{uri}'; the source identity cannot be established" - )], - }; + ), + ); }; if is_source_document(uri) { - return RenameResult { - document_version: requesting.version, - ok: false, - edits: Vec::new(), - previews: Vec::new(), - diagnostics: vec![ - "source-provider-unavailable: OPY language-service capabilities are not currently shipped with the configured provider".to_string(), - ], - }; + return refused_rename( + requesting.version, + "source-provider-unavailable: OPY language-service capabilities are not currently shipped with the configured provider", + ); } - RenameResult { - document_version: requesting.version, - ok: false, - edits: Vec::new(), - previews: Vec::new(), - diagnostics: vec![ - "rename-unresolved: no symbol is resolvable at the requested position".to_string(), - ], - } + refused_rename( + requesting.version, + "rename-unresolved: no symbol is resolvable at the requested position", + ) } pub fn semantic_tokens(&self, _uri: &str) -> Vec { Vec::new() } } +fn refused_rename(document_version: i32, diagnostic: impl Into) -> RenameResult { + RenameResult { + document_version, + ok: false, + edits: Vec::new(), + previews: Vec::new(), + diagnostics: vec![diagnostic.into()], + } +} + fn is_source_document(uri: &str) -> bool { crate::document::uri_to_path(uri) .and_then(|p| p.extension().map(|e| e.to_string_lossy().to_lowercase())) From 5d59994d749316d155a44a572bf52cb553b5d80b Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:18:35 +0800 Subject: [PATCH 06/33] refactor(driver): remove unreachable source edit analysis --- crates/wright-driver/src/edit.rs | 563 ++----------------------------- 1 file changed, 34 insertions(+), 529 deletions(-) diff --git a/crates/wright-driver/src/edit.rs b/crates/wright-driver/src/edit.rs index f22812d..8828a66 100644 --- a/crates/wright-driver/src/edit.rs +++ b/crates/wright-driver/src/edit.rs @@ -1,16 +1,13 @@ //! Tools and agents propose edits as validated, source-oriented //! [`SourceEdit`]s — never as mutations of Wright's internal IR. -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; +use std::collections::BTreeMap; +use std::path::Path; use serde::{Deserialize, Serialize}; use crate::config::{SessionConfig, SourceKind}; -use crate::diag::{ - Diagnostic, Origin, Position, Severity, SourceSpan, Stage, source_provider_unavailable, -}; -use crate::input::ResolvedInput; +use crate::diag::{Diagnostic, Position, SourceSpan, Stage, source_provider_unavailable}; use crate::result::exit_code_from; /// One proposed source edit. @@ -126,90 +123,34 @@ pub struct EditValidation { pub preview: Option>, } -struct ProjectContext { - kind: SourceKind, - main_path: PathBuf, - root: PathBuf, - main_text: String, - overlay: BTreeMap, - resolved: ResolvedInput, -} - -fn project_context( +fn validate_project_input( config: &SessionConfig, sources: &BTreeMap, previews: Option<&[SourcePreview]>, -) -> Result { - let Some(main_path) = config.input.path().cloned() else { +) -> Result<(), Diagnostic> { + let Some(main_path) = config.input.path() else { return Err(Diagnostic::error( "edit-input-stdin", Stage::Discovery, "edit validation requires a path-based input so the edited project's main source identity is established; stdin has no project identity", )); }; - let kind = resolve_kind(config, &main_path)?; - let root = config.root.clone().unwrap_or_else(|| { - main_path - .parent() - .map(Path::to_path_buf) - .unwrap_or_default() - }); + validate_source_kind(config, main_path)?; - let main_text = if let Some(prevs) = previews { - preview_of(prevs, &main_path) - .map(|p| p.new_text.clone()) - .or_else(|| { - sources - .get(&main_path.to_string_lossy().into_owned()) - .cloned() - }) - } else { - sources - .get(&main_path.to_string_lossy().into_owned()) - .cloned() - }; - let main_text = match main_text { - Some(text) => text, - None => std::fs::read_to_string(&main_path).map_err(|e| { - Diagnostic::error( - "input-io", - Stage::Discovery, - format!("cannot read input '{}': {e}", main_path.display()), - ) - })?, - }; - - let overlay = if let Some(prevs) = previews { - build_overlay( - kind, - &root, - &main_path, - prevs - .iter() - .map(|p| (p.source.as_str(), p.new_text.as_str())), - ) - } else { - build_overlay( - kind, - &root, - &main_path, - sources.iter().map(|(s, t)| (s.as_str(), t.as_str())), + let main_source = main_path.to_string_lossy().into_owned(); + if previews + .and_then(|previews| preview_of(previews, main_path)) + .is_some() + || sources.contains_key(&main_source) + { + return Ok(()); + } + std::fs::read_to_string(main_path).map(|_| ()).map_err(|e| { + Diagnostic::error( + "input-io", + Stage::Discovery, + format!("cannot read input '{}': {e}", main_path.display()), ) - }; - let resolved = resolved_input( - kind, - &main_path, - &root, - &main_text, - config.locale.as_deref(), - ); - Ok(ProjectContext { - kind, - main_path, - root, - main_text, - overlay, - resolved, }) } @@ -224,7 +165,10 @@ pub fn validate_transaction( diagnostics.push(diagnostic); } } - if has_error(&diagnostics) { + if diagnostics + .iter() + .any(|d| d.severity == crate::diag::Severity::Error) + { return refusal(diagnostics); } @@ -236,25 +180,11 @@ pub fn validate_transaction( } }; - let ctx = match project_context(config, sources, Some(&previews)) { - Ok(ctx) => ctx, - Err(diagnostic) => { - diagnostics.push(diagnostic); - return refusal(diagnostics); - } - }; - - if let Err(errors) = compile_project(ctx.kind, &ctx.resolved, &ctx.overlay, config.profile) { - diagnostics.extend(errors); - } - - let ok = !has_error(&diagnostics); - EditValidation { - ok, - exit: exit_code_from(&diagnostics), - diagnostics, - preview: if ok { Some(previews) } else { None }, + if let Err(diagnostic) = validate_project_input(config, sources, Some(&previews)) { + diagnostics.push(diagnostic); + return refusal(diagnostics); } + refusal(vec![source_provider_unavailable()]) } fn refusal(diagnostics: Vec) -> EditValidation { @@ -303,411 +233,14 @@ pub fn semantic_rename( )]); } - let ctx = match project_context(config, sources, None) { - Ok(c) => c, - Err(d) => return refuse(vec![d]), - }; - - let (program, files) = - match compile_project(ctx.kind, &ctx.resolved, &ctx.overlay, config.profile) { - Ok(result) => result, - Err(diagnostics) => return refuse(diagnostics), - }; - let source_texts = files - .iter() - .filter_map(|file| { - let source = - source_key_for_file(&files, &ctx.root, &ctx.main_path, sources, file.id as usize)?; - let text = sources - .get(&source) - .cloned() - .or_else(|| (file.id == 0).then_some(ctx.main_text.clone()))?; - Some(( - workshop_rs::source::FileId::from_index(file.id as usize), - text, - )) - }) - .collect::>(); - let index = - wright_analyzer::canonical::SemanticIndex::build_with_sources(&program, &source_texts); - - let Some(file_id) = file_id_for_source(&files, &ctx.root, &target.source) else { - return refuse(vec![Diagnostic::error( - "rename-unresolved", - Stage::Discovery, - format!( - "'{}' is not part of the compiled project; the rename position must name a project source", - target.source - ), - )]); - }; - let declaration_source = - source_key_for_file(&files, &ctx.root, &ctx.main_path, sources, file_id) - .and_then(|s| sources.get(&s).cloned()) - .or_else(|| (file_id == 0).then_some(ctx.main_text.clone())); - let Some(symbol) = symbol_at(&index, file_id, target.line, target.col).or_else(|| { - declaration_source - .as_deref() - .and_then(|source| declaration_symbol_at(&index, target.line, target.col, source)) - }) else { - return refuse(vec![Diagnostic::error( - "rename-unresolved", - Stage::Discovery, - format!( - "no symbol is resolvable at {}:{}:{}", - target.source, target.line, target.col - ), - )]); - }; - - if index - .symbols() - .any(|other| other.id != symbol.id && other.name == target.to) - { - return refuse(vec![Diagnostic::error( - "rename-collision", - Stage::Discovery, - format!( - "'{}' is already declared; the new name would collide", - target.to - ), - )]); - } - - let mut occurrences = Vec::new(); - if let Some(occ) = symbol.occurrence { - occurrences.push(occ); - } - for reference in index.references(symbol.id) { - match (reference.occurrence, reference.span) { - (Some(occ), _) => occurrences.push(occ), - (None, Some(_)) => { - return refuse(vec![Diagnostic::error( - "rename-unresolved-target", - Stage::Discovery, - format!( - "a semantic occurrence in {} has no exact identifier span; refusing the rename rather than broadening to a statement span", - target.source - ), - )]); - } - (None, None) => {} - } - } - for file in &files { - let file_id = file.id as usize; - let Some(source) = source_key_for_file(&files, &ctx.root, &ctx.main_path, sources, file_id) - else { - continue; - }; - let current = sources - .get(&source) - .cloned() - .unwrap_or_else(|| ctx.main_text.clone()); - occurrences.extend(declaration_occurrences( - file_id, - ¤t, - symbol.kind, - &symbol.name, - )); - } - - let mut edits: BTreeMap> = BTreeMap::new(); - for occ in occurrences { - let file = occ.file.index(); - let Some(source) = source_key_for_file(&files, &ctx.root, &ctx.main_path, sources, file) - else { - return refuse(vec![Diagnostic::error( - "edit-unknown-source", - Stage::Discovery, - "no current text was provided for a source the rename would edit; supply the current text of every project source", - )]); - }; - let current = sources - .get(&source) - .cloned() - .unwrap_or_else(|| ctx.main_text.clone()); - for exact in exact_identifier_occurrences(occ, ¤t, &symbol.name) { - edits.entry(source.clone()).or_default().insert(( - exact.start.line, - exact.start.col, - exact.end.line, - exact.end.col, - )); - } - } - - let mut source_edits = Vec::new(); - for (source, ranges) in edits { - let current = sources - .get(&source) - .cloned() - .unwrap_or_else(|| ctx.main_text.clone()); - let identity = crate::input_identity(¤t); - for (start_line, start_col, end_line, end_col) in ranges { - source_edits.push(SourceEdit { - edit_kind: "rename".to_string(), - source: source.clone(), - source_identity: identity.clone(), - range: EditRange { - start_line, - start_col, - end_line, - end_col, - }, - new_text: target.to.clone(), - }); - } + if let Err(diagnostic) = validate_project_input(config, sources, None) { + return refuse(vec![diagnostic]); } - let transaction = match EditTransaction::new(source_edits) { - Ok(tx) => tx, - Err(diagnostic) => return refuse(vec![diagnostic]), - }; - - let validation = validate_transaction(config, sources, &transaction); - if validation.ok { - SemanticRename { - ok: true, - transaction: Some(transaction), - diagnostics: validation.diagnostics, - preview: validation.preview, - } - } else { - SemanticRename { - ok: false, - transaction: None, - diagnostics: validation.diagnostics, - preview: None, - } - } -} - -fn exact_identifier_occurrences( - span: workshop_rs::source::Span, - source: &str, - name: &str, -) -> Vec { - if name.is_empty() { - return Vec::new(); - } - let lines: Vec<&str> = source.lines().collect(); - let mut occurrences = Vec::new(); - for line_num in span.start.line..=span.end.line { - let Some(line) = lines.get(line_num.saturating_sub(1) as usize) else { - continue; - }; - let lower = if line_num == span.start.line { - span.start.col.saturating_sub(1) as usize - } else { - 0 - }; - let upper = if line_num == span.end.line { - span.end.col.saturating_sub(1) as usize - } else { - line.chars().count() - }; - let chars: Vec = line.chars().collect(); - let name_chars: Vec = name.chars().collect(); - for start in lower.min(chars.len())..=upper.min(chars.len()) { - let end = start + name_chars.len(); - if end > upper || chars.get(start..end) != Some(name_chars.as_slice()) { - continue; - } - let before = start.checked_sub(1).and_then(|i| chars.get(i)); - let after = chars.get(end); - if before.is_some_and(|c| c.is_alphanumeric() || *c == '_') - || after.is_some_and(|c| c.is_alphanumeric() || *c == '_') - { - continue; - } - occurrences.push(workshop_rs::source::Span::new( - span.file, - workshop_rs::source::Position::new(line_num, start as u32 + 1), - workshop_rs::source::Position::new(line_num, end as u32 + 1), - )); - } - } - occurrences -} - -fn declaration_occurrences( - file: usize, - source: &str, - kind: wright_analyzer::canonical::SymbolKind, - name: &str, -) -> Vec { - let prefix = kind.declaration_prefix(); - source - .lines() - .enumerate() - .filter_map(|(idx, line)| { - let start = line.find(prefix)? + prefix.len(); - let rest = &line[start..]; - let name_start = rest.find(name)?; - let before = name_start - .checked_sub(1) - .and_then(|p| rest.as_bytes().get(p)); - let after = rest.as_bytes().get(name_start + name.len()); - if before.is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') - || after.is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') - { - return None; - } - Some(workshop_rs::source::Span::new( - workshop_rs::source::FileId::from_index(file), - workshop_rs::source::Position::new( - (idx + 1) as u32, - (start + name_start + 1) as u32, - ), - workshop_rs::source::Position::new( - (idx + 1) as u32, - (start + name_start + name.len() + 1) as u32, - ), - )) - }) - .collect() + refuse(vec![source_provider_unavailable()]) } - -fn file_id_for_source(files: &[SourceFile], root: &Path, source: &str) -> Option { - files.iter().find_map(|file| { - registry_path_matches(source, root, &file.path).then_some(file.id as usize) - }) -} - -fn registry_path_matches(source: &str, root: &Path, registry_path: &str) -> bool { - let path = Path::new(registry_path); - same_file(source, path) || (path.is_relative() && same_file(source, &root.join(path))) -} - -fn source_key_for_file( - files: &[SourceFile], - root: &Path, - main_path: &Path, - sources: &BTreeMap, - file: usize, -) -> Option { - if file == 0 { - return sources - .keys() - .find(|key| same_file(key, main_path)) - .cloned() - .or_else(|| Some(main_path.to_string_lossy().into_owned())); - } - let reg = files.iter().find(|r| r.id as usize == file)?.path.as_str(); - sources - .keys() - .find(|key| registry_path_matches(key, root, reg)) - .cloned() -} - -fn symbol_at( - index: &wright_analyzer::canonical::SemanticIndex, - file_id: usize, - line: u32, - col: u32, -) -> Option { - index - .symbols() - .find(|s| { - s.span - .is_some_and(|sp| sp.file.index() == file_id && span_contains(sp, line, col)) - || index.references(s.id).iter().any(|r| { - r.span.is_some_and(|sp| { - sp.file.index() == file_id && span_contains(sp, line, col) - }) - }) - }) - .cloned() -} - -fn declaration_symbol_at( - index: &wright_analyzer::canonical::SemanticIndex, - line: u32, - col: u32, - source: &str, -) -> Option { - let line_text = source.lines().nth(line.saturating_sub(1) as usize)?; - for kind in wright_analyzer::canonical::SymbolKind::ALL { - let prefix = kind.declaration_prefix(); - let Some(name_start) = line_text.strip_prefix(prefix).map(|_| prefix.len()) else { - continue; - }; - let name = line_text[name_start..] - .split_whitespace() - .next() - .map(|n| n.trim_matches('"'))?; - let start = name_start as u32 + 1; - let end = start + name.chars().count() as u32; - if !(start..end).contains(&col) { - continue; - } - return index - .symbols() - .find(|s| s.kind == kind && s.name == name) - .cloned(); - } - None -} - -fn span_contains(span: workshop_rs::source::Span, line: u32, col: u32) -> bool { - (span.start.line, span.start.col) <= (line, col) - && (line, col) - <= ( - span.end.line, - span.end.col.saturating_sub(1).max(span.start.col), - ) -} - -fn has_error(diagnostics: &[Diagnostic]) -> bool { - diagnostics.iter().any(|d| d.severity == Severity::Error) -} - -fn origin_for(kind: SourceKind, locale: Option<&str>) -> Origin { - Origin { - kind: kind.as_str().to_string(), - locale: locale.map(str::to_string), - } -} - -fn resolved_input( - kind: SourceKind, - main_path: &Path, - root: &Path, - main_text: &str, - locale: Option<&str>, -) -> ResolvedInput { - ResolvedInput { - kind, - text: main_text.to_string(), - path: Some(main_path.to_path_buf()), - target: crate::input::InputTarget::File, - root: root.to_path_buf(), - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - display: crate::input::display_path(main_path), - identity: crate::input_identity(main_text), - origin: origin_for(kind, locale), - } -} - -#[derive(Debug, Clone)] -struct SourceFile { - id: u32, - path: String, -} - -fn compile_project( - kind: SourceKind, - resolved: &ResolvedInput, - overlay: &BTreeMap, - profile: crate::Profile, -) -> Result<(workshop_rs::Program, Vec), Vec> { - let _ = (kind, resolved, overlay, profile); - Err(vec![source_provider_unavailable()]) -} - -fn resolve_kind(config: &SessionConfig, main_path: &Path) -> Result { +fn validate_source_kind(config: &SessionConfig, main_path: &Path) -> Result<(), Diagnostic> { match config.kind { - SourceKind::Opy => Ok(SourceKind::Opy), + SourceKind::Opy => Ok(()), SourceKind::Ostw => Err(source_provider_unavailable()), SourceKind::Auto => match main_path .extension() @@ -715,7 +248,7 @@ fn resolve_kind(config: &SessionConfig, main_path: &Path) -> Result Ok(SourceKind::Opy), + Some("opy") => Ok(()), Some("ostw" | "del") => Err(source_provider_unavailable()), _ => Err(Diagnostic::error( "edit-unsupported-kind", @@ -747,34 +280,6 @@ fn same_file(a: &str, b: &Path) -> bool { || matches!((Path::new(a).canonicalize(), b.canonicalize()), (Ok(ca), Ok(cb)) if ca == cb) } -fn build_overlay<'a>( - kind: SourceKind, - root: &Path, - main_path: &Path, - entries: impl IntoIterator, -) -> BTreeMap { - let mut overlay = BTreeMap::new(); - for (source, text) in entries { - if same_file(source, main_path) { - continue; - } - if kind == SourceKind::Opy { - let path = PathBuf::from(source); - overlay.insert(path.to_string_lossy().into_owned(), text.to_string()); - if let Ok(c) = path.canonicalize() { - overlay.insert(c.to_string_lossy().into_owned(), text.to_string()); - } - if let Ok(r) = path.strip_prefix(root) { - overlay.insert(r.to_string_lossy().into_owned(), text.to_string()); - } - if let Some(n) = path.file_name().and_then(|n| n.to_str()) { - overlay.insert(n.to_string(), text.to_string()); - } - } - } - overlay -} - pub(crate) fn source_precondition( edit: &SourceEdit, sources: &BTreeMap, From f159b186c95226e6a6d71b936040e562246829e8 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:22:21 +0800 Subject: [PATCH 07/33] refactor(driver): reuse source provider error mapping --- crates/wright-driver/src/session.rs | 17 +++-------------- crates/wright-driver/src/source_provider.rs | 2 +- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 300ba8a..fdf06bc 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -287,13 +287,7 @@ impl CompilerSession { let spawn = |session: &Self| { session .language_provider(opy_provider::OPY_LANGUAGE_ID) - .map_err(|error| { - SourceProviderError::Failed { - code: error.code().to_string(), - message: error.to_string(), - } - .diagnostic() - }) + .map_err(|error| crate::source_provider::provider_error(error).diagnostic()) }; let mut provider = spawn(self)?; let client_info = wright_lpp::ClientInfo { @@ -318,13 +312,8 @@ impl CompilerSession { } }; } - initialize.map_err(|error| { - SourceProviderError::Failed { - code: error.code().to_string(), - message: error.to_string(), - } - .diagnostic() - })?; + initialize + .map_err(|error| crate::source_provider::provider_error(error).diagnostic())?; self.source_provider = Some(Box::new(crate::source_provider::LppSourceProvider::new( provider, self.config.locale.clone(), diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs index 883522f..bfe2a5c 100644 --- a/crates/wright-driver/src/source_provider.rs +++ b/crates/wright-driver/src/source_provider.rs @@ -297,7 +297,7 @@ impl SourceProvider for LppSourceProvider { } } -fn provider_error(error: wright_lpp::ProviderError) -> SourceProviderError { +pub(crate) fn provider_error(error: wright_lpp::ProviderError) -> SourceProviderError { SourceProviderError::Failed { code: error.code().to_string(), message: error.to_string(), From 223887bec294e3973e3dc6aad202f22113963ecd Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:24:48 +0800 Subject: [PATCH 08/33] refactor(language): share document path lookup --- crates/wright-language/src/document.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/wright-language/src/document.rs b/crates/wright-language/src/document.rs index d77dafa..4989b40 100644 --- a/crates/wright-language/src/document.rs +++ b/crates/wright-language/src/document.rs @@ -95,21 +95,20 @@ impl DocumentStore { } pub fn text_for_path(&self, path: &PathBuf) -> Option { - for doc in self.documents.values() { - if uri_to_path(&doc.uri).is_some_and(|p| p == *path) { - return Some(doc.text.clone()); - } - } - std::fs::read_to_string(path).ok() + self.document_for_path(path) + .map(|document| document.text.clone()) + .or_else(|| std::fs::read_to_string(path).ok()) } pub fn uri_for_path(&self, path: &PathBuf) -> Option { - for doc in self.documents.values() { - if uri_to_path(&doc.uri).is_some_and(|p| p == *path) { - return Some(doc.uri.clone()); - } - } - None + self.document_for_path(path) + .map(|document| document.uri.clone()) + } + + fn document_for_path(&self, path: &PathBuf) -> Option<&Document> { + self.documents.values().find(|document| { + uri_to_path(&document.uri).is_some_and(|document_path| document_path == *path) + }) } pub fn overlay(&self, root: &PathBuf) -> BTreeMap { From e1958adca4bf8d25d60b70bf0c691329482be23c Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:29:10 +0800 Subject: [PATCH 09/33] refactor(driver): share session result derivation --- crates/wright-driver/src/session.rs | 34 +++++++++++------------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index fdf06bc..033f223 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -564,12 +564,7 @@ impl CompilerSession { fn compile_output(&mut self) -> Result { let loaded = self.load_with_operation(ProviderOperation::Compile)?; - let locale = loaded - .origin - .locale - .as_deref() - .map(workshop_rs::catalog::Locale::new) - .unwrap_or_else(|| workshop_rs::catalog::Locale::new("en-US")); + let locale = Self::locale_for(&loaded); self.progress(ProgressEvent::new(ProgressPhase::Emission)); let text = workshop_rs::emitter::emit(&loaded.program, &self.catalog, &locale) .map_err(|error| workshop_diag(error, &loaded.input))?; @@ -749,12 +744,7 @@ impl CompilerSession { } fn convert_opy(&mut self, loaded: &Loaded) -> Result { - let locale = loaded - .origin - .locale - .as_deref() - .map(workshop_rs::catalog::Locale::new) - .unwrap_or_else(|| workshop_rs::catalog::Locale::new("en-US")); + let locale = Self::locale_for(loaded); let artifact = workshop_rs::emitter::emit(&loaded.program, &self.catalog, &locale) .map_err(|e| { self.diagnostics.push(Diagnostic::error( @@ -788,6 +778,15 @@ impl CompilerSession { Ok(result.source) } + fn locale_for(loaded: &Loaded) -> workshop_rs::catalog::Locale { + loaded + .origin + .locale + .as_deref() + .map(workshop_rs::catalog::Locale::new) + .unwrap_or_else(|| workshop_rs::catalog::Locale::new("en-US")) + } + /// Build the semantic service over a loaded program. fn service<'a>(&self, loaded: &'a Loaded) -> SemanticService<'a> { self.service_with(loaded, LintConfig::default()) @@ -875,18 +874,11 @@ impl CompilerSession { fn finish(&mut self, command: &str, result: T) -> Envelope { let diagnostics = std::mem::take(&mut self.diagnostics); - let has_error = diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == crate::diag::Severity::Error); - let exit = if has_error { - exit_code_from(&diagnostics) - } else { - crate::result::exit::SUCCESS - }; + let exit = exit_code_from(&diagnostics); Envelope { wright: version_info(), command: command.to_string(), - ok: !has_error, + ok: exit == crate::result::exit::SUCCESS, exit, diagnostics, result, From d9c3bba07917ece1357f163c984d021386befedb Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:30:53 +0800 Subject: [PATCH 10/33] refactor(lpp): share capability error detail lookup --- crates/wright-lpp/src/error.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/wright-lpp/src/error.rs b/crates/wright-lpp/src/error.rs index 2d27d21..e26b800 100644 --- a/crates/wright-lpp/src/error.rs +++ b/crates/wright-lpp/src/error.rs @@ -43,16 +43,16 @@ impl LppError { } pub fn capability(&self) -> Option<&str> { - if self.kind == LppErrorKind::CapabilityUnavailable { - self.details.get("capability").and_then(Value::as_str) - } else { - None - } + self.capability_detail("capability") } pub fn method(&self) -> Option<&str> { + self.capability_detail("method") + } + + fn capability_detail(&self, key: &str) -> Option<&str> { if self.kind == LppErrorKind::CapabilityUnavailable { - self.details.get("method").and_then(Value::as_str) + self.details.get(key).and_then(Value::as_str) } else { None } From 9695b64e5a54b558aa820736703be848b5cd967d Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:19:04 +0800 Subject: [PATCH 11/33] refactor: simplify CLI and driver control flow --- crates/wright-cli/src/completion.rs | 44 +++++--------- crates/wright-cli/src/main.rs | 89 ++++++++++------------------ crates/wright-cli/src/present.rs | 10 +--- crates/wright-cli/src/update.rs | 25 +++----- crates/wright-driver/src/config.rs | 30 +++------- crates/wright-driver/src/input.rs | 5 +- crates/wright-driver/src/service.rs | 91 +++++++++++++---------------- crates/wright-driver/src/session.rs | 20 ++++--- 8 files changed, 113 insertions(+), 201 deletions(-) diff --git a/crates/wright-cli/src/completion.rs b/crates/wright-cli/src/completion.rs index f0f9288..83a3be7 100644 --- a/crates/wright-cli/src/completion.rs +++ b/crates/wright-cli/src/completion.rs @@ -55,18 +55,6 @@ pub(crate) enum InstallStatus { DryRun(PathBuf), } -impl InstallStatus { - #[allow(dead_code)] - pub(crate) fn path(&self) -> &Path { - match self { - InstallStatus::Created(p) - | InstallStatus::Updated(p) - | InstallStatus::UpToDate(p) - | InstallStatus::DryRun(p) => p, - } - } -} - pub(crate) fn generate_script(shell: ShellArg) -> Vec { let mut command = Cli::command(); let mut buffer = Vec::new(); @@ -161,13 +149,19 @@ fn user_home_dir() -> Result { }) } -fn xdg_dir(var: &str, default_sub: &str) -> Result { - if let Ok(dir) = std::env::var(var) { - if !dir.is_empty() { - return Ok(PathBuf::from(dir)); - } - } - Ok(user_home_dir()?.join(default_sub)) +fn xdg_dir(home: &Path, var: &str, default_sub: &str) -> PathBuf { + std::env::var(var) + .ok() + .filter(|dir| !dir.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(default_sub)) +} + +fn completion_dirs() -> Result<(PathBuf, PathBuf, PathBuf), CompletionError> { + let home = user_home_dir()?; + let data_home = xdg_dir(&home, "XDG_DATA_HOME", ".local/share"); + let config_home = xdg_dir(&home, "XDG_CONFIG_HOME", ".config"); + Ok((home, data_home, config_home)) } pub(crate) fn default_dir_for(shell: ShellArg) -> Result { @@ -177,9 +171,7 @@ pub(crate) fn default_dir_for(shell: ShellArg) -> Result { @@ -247,13 +239,7 @@ pub(crate) fn candidate_dirs_for(shell: ShellArg) -> Vec { } } - let Ok(home) = user_home_dir() else { - return Vec::new(); - }; - let Ok(data_home) = xdg_dir("XDG_DATA_HOME", ".local/share") else { - return Vec::new(); - }; - let Ok(config_home) = xdg_dir("XDG_CONFIG_HOME", ".config") else { + let Ok((home, data_home, config_home)) = completion_dirs() else { return Vec::new(); }; diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index b658c74..ba81328 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -116,34 +116,36 @@ fn main() -> ExitCode { } fn run_workflow(command: Command) -> ExitCode { - let (name, config, presentation, convert_target) = match command { + match command { Command::Compile(args) => { let mut config = config_from_common(&args.common, true); config.output = args.output; - ( - "compile", + run_configured( config, present::Presentation::from_common(&args.common), - None, + wright_driver::CompilerSession::compile, ) } - Command::Convert(args) => ( - "convert", - config_from_common(&args.common, false), - present::Presentation::from_common(&args.common), - Some(args.target), - ), - Command::Check(args) => ( - "check", + Command::Convert(args) => { + let target = match args.target { + ConvertTargetArg::Opy => wright_driver::ConvertTarget::Opy, + ConvertTargetArg::Ostw => wright_driver::ConvertTarget::Ostw, + }; + run_configured( + config_from_common(&args.common, false), + present::Presentation::from_common(&args.common), + move |session: &mut wright_driver::CompilerSession| session.convert(target), + ) + } + Command::Check(args) => run_configured( config_from_common(&args, true), present::Presentation::from_common(&args), - None, + wright_driver::CompilerSession::check, ), - Command::Analyze(args) => ( - "analyze", + Command::Analyze(args) => run_configured( config_from_common(&args, true), present::Presentation::from_common(&args), - None, + wright_driver::CompilerSession::analyze, ), Command::Lint(args) => { let mut config = config_from_common(&args.common, true); @@ -178,18 +180,16 @@ fn run_workflow(command: Command) -> ExitCode { return ExitCode::from(exit::USAGE); } } - ( - "lint", + run_configured( config, present::Presentation::from_common(&args.common), - None, + wright_driver::CompilerSession::lint, ) } - Command::Inspect(args) => ( - "inspect", + Command::Inspect(args) => run_configured( config_from_common(&args, false), present::Presentation::from_common(&args), - None, + wright_driver::CompilerSession::inspect, ), Command::Completion(_) | Command::Update(_) @@ -199,8 +199,14 @@ fn run_workflow(command: Command) -> ExitCode { | Command::SemanticCompare(_) => { unreachable!("non-workflow command handled before run_workflow") } - }; + } +} +fn run_configured( + config: SessionConfig, + presentation: present::Presentation, + run: impl FnOnce(&mut wright_driver::CompilerSession) -> wright_driver::Envelope, +) -> ExitCode { let mut session = match wright_driver::CompilerSession::new(config) { Ok(session) => session, Err(diagnostic) => { @@ -209,42 +215,7 @@ fn run_workflow(command: Command) -> ExitCode { } }; - let code = match name { - "compile" => run_command( - &mut session, - wright_driver::CompilerSession::compile, - presentation, - ), - "check" => run_command( - &mut session, - wright_driver::CompilerSession::check, - presentation, - ), - "analyze" => run_command( - &mut session, - wright_driver::CompilerSession::analyze, - presentation, - ), - "lint" => run_command( - &mut session, - wright_driver::CompilerSession::lint, - presentation, - ), - "inspect" => run_command( - &mut session, - wright_driver::CompilerSession::inspect, - presentation, - ), - "convert" => { - let target = match convert_target.expect("convert target is required") { - ConvertTargetArg::Opy => wright_driver::ConvertTarget::Opy, - ConvertTargetArg::Ostw => wright_driver::ConvertTarget::Ostw, - }; - run_command(&mut session, |s| s.convert(target), presentation) - } - _ => unreachable!("all workflow commands are mapped"), - }; - ExitCode::from(code) + ExitCode::from(run_command(&mut session, run, presentation)) } fn run_semantic_compare(args: cli::SemanticCompareArgs) -> ExitCode { diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index 09cba67..0fcc2e1 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -273,9 +273,6 @@ fn render_text(envelope: &Envelope, for diag in &envelope.diagnostics { render_diagnostic(diag, color); } - if envelope.command == "check" { - envelope.result.render_check_summary(); - } if !envelope.ok { if envelope.diagnostics.is_empty() { eprintln!("{}: failed", envelope.command); @@ -438,7 +435,6 @@ pub(crate) trait ResultPresentation { None } fn render_body(&self); - fn render_check_summary(&self) {} fn render_github_findings(&self) {} fn update_summary_status(&self, _status: &mut SummaryStatus) {} } @@ -745,11 +741,7 @@ fn render_inspect(result: &InspectResult) { } fn render_diagnostic(diagnostic: &wright_driver::Diagnostic, color: bool) { - let sev = match diagnostic.severity { - Severity::Error => "error", - Severity::Warning => "warning", - Severity::Info => "info", - }; + let sev = diagnostic.severity.as_str(); let label = if color { let code = match diagnostic.severity { Severity::Error => "31", diff --git a/crates/wright-cli/src/update.rs b/crates/wright-cli/src/update.rs index 75497de..ee923dc 100644 --- a/crates/wright-cli/src/update.rs +++ b/crates/wright-cli/src/update.rs @@ -67,21 +67,12 @@ enum Provenance { } impl Provenance { - fn guidance(self) -> &'static str { + fn manager(self) -> Option<(&'static str, &'static str)> { match self { - Self::Standalone => unreachable!(), - Self::Homebrew => "brew upgrade wrightkit/tap/wright", - Self::Scoop => "scoop update wright", - Self::WinGet => "winget upgrade WrightKit.Wright", - } - } - - fn channel(self) -> &'static str { - match self { - Self::Standalone => unreachable!(), - Self::Homebrew => "Homebrew", - Self::Scoop => "Scoop", - Self::WinGet => "WinGet", + Self::Standalone => None, + Self::Homebrew => Some(("Homebrew", "brew upgrade wrightkit/tap/wright")), + Self::Scoop => Some(("Scoop", "scoop update wright")), + Self::WinGet => Some(("WinGet", "winget upgrade WrightKit.Wright")), } } } @@ -91,12 +82,12 @@ pub(crate) fn run(check_only: bool, requested: Option<&str>) -> Result Self { - SessionConfig { - input: InputSpec::Stdin, - kind: SourceKind::Auto, - source_backend: SourceBackend::Native, - locale: None, - root: None, - output: None, - format: OutputFormat::Text, - profile: wright_transform::Profile::Off, - lint: LintConfig::default(), - lint_rule_paths: Vec::new(), - providers: wright_lpp::ProviderRegistry::default(), - opy_provider: crate::opy_provider::OpyProviderConfig::default(), - } - } -} - impl SessionConfig { pub fn from_path(path: impl Into) -> SessionConfig { SessionConfig { diff --git a/crates/wright-driver/src/input.rs b/crates/wright-driver/src/input.rs index b404b08..deb13cd 100644 --- a/crates/wright-driver/src/input.rs +++ b/crates/wright-driver/src/input.rs @@ -283,10 +283,7 @@ fn resolve_stdin(config: &SessionConfig) -> Result { other => other, }; let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let root = config - .root - .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let root = config.root.clone().unwrap_or_else(|| cwd.clone()); let origin = origin_for(kind, config.locale.as_deref()); Ok(ResolvedInput { kind, diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index 55410dd..33c4911 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -382,20 +382,16 @@ impl<'a> ToolService<'a> { /// `analyze`/`lint` workflows, so one file identity holds per finding /// across every surface (#102). fn findings(&self) -> ToolResponse { - let response = self.semantic_query(Request::GetFindings); - match response { - ToolResponse::Ok { mut result } => { - crate::session::resolve_finding_span_paths(&mut result, &self.loaded); - ToolResponse::Ok { result } - } - other => other, - } + self.semantic_query_with_resolved_span_paths(Request::GetFindings) } /// `persistentObjects`: persistent-object facts with resolved source paths. fn persistent_objects(&self) -> ToolResponse { - let response = self.semantic_query(Request::GetPersistentObjects); - match response { + self.semantic_query_with_resolved_span_paths(Request::GetPersistentObjects) + } + + fn semantic_query_with_resolved_span_paths(&self, request: Request) -> ToolResponse { + match self.semantic_query(request) { ToolResponse::Ok { mut result } => { crate::session::resolve_finding_span_paths(&mut result, &self.loaded); ToolResponse::Ok { result } @@ -416,17 +412,7 @@ impl<'a> ToolService<'a> { request: Request, config: wright_analyzer::registry::LintConfig, ) -> ToolResponse { - let origin = Origin { - kind: self.loaded.origin.kind.clone(), - locale: self.loaded.origin.locale.clone(), - }; - let service = SemanticService::with_origin_and_config_and_registry( - &self.loaded.program, - origin, - config, - std::sync::Arc::clone(self.session.lint_registry()), - ); - match service.handle(&request) { + match self.semantic_service(config).handle(&request) { Response::Ok { result } => ToolResponse::Ok { result }, Response::Error { error } => ToolResponse::Error { error: ToolErrorInfo { @@ -437,39 +423,44 @@ impl<'a> ToolService<'a> { } } + fn semantic_service( + &self, + config: wright_analyzer::registry::LintConfig, + ) -> SemanticService<'_> { + let origin = Origin { + kind: self.loaded.origin.kind.clone(), + locale: self.loaded.origin.locale.clone(), + }; + SemanticService::with_origin_and_config_and_registry( + &self.loaded.program, + origin, + config, + std::sync::Arc::clone(self.session.lint_registry()), + ) + } + /// `lint`: rule metadata, effective configuration, and findings over the /// loaded program through the same semantic-service path as the CLI /// `lint` workflow (no duplicated rule execution, #98). fn lint(&self) -> ToolResponse { let config = self.session.config.lint.clone(); - let origin = Origin { - kind: self.loaded.origin.kind.clone(), - locale: self.loaded.origin.locale.clone(), + let service = self.semantic_service(config); + let lint_rules = match service.handle(&Request::LintRules) { + Response::Ok { result } => result, + Response::Error { .. } => serde_json::json!({}), }; - { - let service = SemanticService::with_origin_and_config_and_registry( - &self.loaded.program, - origin, - config, - std::sync::Arc::clone(self.session.lint_registry()), - ); - let lint_rules = match service.handle(&Request::LintRules) { - Response::Ok { result } => result, - Response::Error { .. } => serde_json::json!({}), - }; - let mut findings = match service.handle(&Request::GetFindings) { - Response::Ok { result } => result, - Response::Error { .. } => serde_json::json!([]), - }; - crate::session::resolve_finding_span_paths(&mut findings, &self.loaded); - self.ok(json!({ - "inputIdentity": self.loaded.input.identity, - "rules": lint_rules.get("rules").cloned().unwrap_or_else(|| json!([])), - "config": lint_rules.get("config").cloned().unwrap_or_else(|| json!({})), - "findings": findings, - "skipped": lint_rules.get("skipped").cloned().unwrap_or_else(|| json!([])), - })) - } + let mut findings = match service.handle(&Request::GetFindings) { + Response::Ok { result } => result, + Response::Error { .. } => serde_json::json!([]), + }; + crate::session::resolve_finding_span_paths(&mut findings, &self.loaded); + self.ok(json!({ + "inputIdentity": self.loaded.input.identity, + "rules": lint_rules.get("rules").cloned().unwrap_or_else(|| json!([])), + "config": lint_rules.get("config").cloned().unwrap_or_else(|| json!({})), + "findings": findings, + "skipped": lint_rules.get("skipped").cloned().unwrap_or_else(|| json!([])), + })) } /// Program summary with origin and source identity. @@ -554,9 +545,7 @@ impl<'a> ToolService<'a> { } fn target_metadata(&self) -> serde_json::Value { - let Ok(catalog) = workshop_rs::catalog::Catalog::builtin() else { - return json!({ "error": "catalog load failed" }); - }; + let catalog = self.session.catalog(); json!({ "catalogVersion": catalog.catalog_version(), "locales": catalog.locales().iter().map(|l| l.to_string()).collect::>(), diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 033f223..8181dea 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -147,6 +147,10 @@ impl CompilerSession { &self.lint_registry } + pub(crate) fn catalog(&self) -> &workshop_rs::catalog::Catalog { + &self.catalog + } + fn progress(&self, event: ProgressEvent) { if let Some(observer) = &self.progress_observer { observer.on_progress(event); @@ -200,7 +204,7 @@ impl CompilerSession { SourceBackend::Provider => true, SourceBackend::Auto => resolved.kind == SourceKind::Opy, }; - if provider_backend && resolved.kind == SourceKind::Ostw { + if resolved.kind == SourceKind::Ostw { return Err(source_provider_unavailable()); } if self.config.source_backend == SourceBackend::Provider && resolved.kind != SourceKind::Opy @@ -214,9 +218,6 @@ impl CompilerSession { ), )); } - if resolved.kind == SourceKind::Ostw { - return Err(source_provider_unavailable()); - } if provider_backend { return self.load_from_source_provider(&mut resolved, provider_operation); } @@ -1004,7 +1005,8 @@ pub(crate) fn resolve_finding_span_paths(findings: &mut serde_json::Value, loade continue; }; if span.is_object() { - span["path"] = serde_json::Value::String(span_path(span, loaded)); + let file = span.get("file").and_then(serde_json::Value::as_u64); + span["path"] = serde_json::Value::String(span_path(file, loaded)); } } } @@ -1017,7 +1019,8 @@ fn resolve_nested_span_paths(value: &mut serde_json::Value, loaded: &Loaded) { .iter() .all(|key| object.contains_key(*key)) { - let path = span_path(&serde_json::Value::Object(object.clone()), loaded); + let file = object.get("file").and_then(serde_json::Value::as_u64); + let path = span_path(file, loaded); object.insert("path".to_string(), serde_json::Value::String(path)); } else { object @@ -1032,18 +1035,17 @@ fn resolve_nested_span_paths(value: &mut serde_json::Value, loaded: &Loaded) { } } -fn span_path(span: &serde_json::Value, loaded: &Loaded) -> String { +fn span_path(file: Option, loaded: &Loaded) -> String { if loaded.provenance == Provenance::Unmapped { "".to_string() } else if loaded.provenance == Provenance::Mapped { // Every mapped file is an authored source; file 0 is not the input. - let file = span.get("file").and_then(serde_json::Value::as_u64); match file.and_then(|file| loaded.source_files.get(file as usize)) { Some(source) => root_relative(Some(Path::new(source)), &loaded.input.root) .unwrap_or_else(|| source.clone()), None => "".to_string(), } - } else if let Some(file) = span.get("file").and_then(serde_json::Value::as_u64) { + } else if let Some(file) = file { if let Some(source) = loaded.source_files.get(file as usize) { let p = if file == 0 { loaded From 22d8e8298801a3e30976be91c36debaed819aca4 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:46:29 +0800 Subject: [PATCH 12/33] refactor: share driver semantic models --- crates/wright-analyzer/src/service.rs | 2 +- crates/wright-cli/src/present.rs | 20 +++++----- crates/wright-driver/src/diag.rs | 56 +++++++-------------------- crates/wright-driver/src/provider.rs | 10 +---- crates/wright-driver/src/service.rs | 29 +++----------- crates/wright-driver/src/session.rs | 19 +-------- crates/wright-lsp/src/main.rs | 2 +- 7 files changed, 34 insertions(+), 104 deletions(-) diff --git a/crates/wright-analyzer/src/service.rs b/crates/wright-analyzer/src/service.rs index 681f8bb..6b93fc0 100644 --- a/crates/wright-analyzer/src/service.rs +++ b/crates/wright-analyzer/src/service.rs @@ -61,7 +61,7 @@ pub const SERVICE_NAME: &str = "wright-tool"; pub const SERVICE_VERSION: &str = env!("CARGO_PKG_VERSION"); /// The origin of a compiled program, carried in tool responses. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Origin { /// `workshop` (native localized Workshop text) or `protocol` /// (`wright/opy-hir` bridge JSON). diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index 0fcc2e1..5de00dd 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -329,11 +329,7 @@ fn render_github(envelope: &Envelope "error", - Severity::Warning => "warning", - Severity::Info => "notice", - }; + let kind = workflow_severity(diagnostic.severity); let mut props = vec![format!( "title={}", escape_workflow_property(&diagnostic.code) @@ -365,11 +361,7 @@ fn emit_finding_annotation(finding: &serde_json::Value) { .and_then(serde_json::Value::as_str) .filter(|path| is_real_source_path(path)); let Some(path) = path else { return }; - let kind = match severity { - Severity::Error => "error", - Severity::Warning => "warning", - Severity::Info => "notice", - }; + let kind = workflow_severity(severity); let line = span_position(span, "start", "line").unwrap_or(1); let col = span_position(span, "start", "col").unwrap_or(1); let end_line = span_position(span, "end", "line").unwrap_or(line); @@ -404,6 +396,14 @@ fn emit_workflow_annotation(kind: &str, properties: &[String], message: &str) { ); } +fn workflow_severity(severity: Severity) -> &'static str { + match severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "notice", + } +} + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub(crate) enum SummaryStatus { Pass, diff --git a/crates/wright-driver/src/diag.rs b/crates/wright-driver/src/diag.rs index b2d396b..0faf71e 100644 --- a/crates/wright-driver/src/diag.rs +++ b/crates/wright-driver/src/diag.rs @@ -5,28 +5,10 @@ //! model, so automation never needs to scrape terminal text. use serde::{Deserialize, Serialize}; - /// The severity of a diagnostic. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Severity { - /// Blocks the workflow; the result is not usable. - Error, - /// Does not block the workflow, but should be reviewed. - Warning, - /// Informational. - Info, -} - -impl Severity { - pub fn as_str(self) -> &'static str { - match self { - Self::Error => "error", - Self::Warning => "warning", - Self::Info => "info", - } - } -} +pub use wright_analyzer::analysis::Severity; +/// The origin of a loaded program. +pub use wright_analyzer::service::Origin; /// The pipeline stage that produced a diagnostic. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -83,16 +65,6 @@ pub struct SourceSpan { pub end: Position, } -/// The origin of a loaded program (mirrors the semantic-service origin). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Origin { - /// `workshop`, `protocol`, or `opy` (provider bridge). - pub kind: String, - /// The Workshop client locale for workshop-origin programs. - #[serde(skip_serializing_if = "Option::is_none")] - pub locale: Option, -} - /// One structured diagnostic. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Diagnostic { @@ -123,8 +95,12 @@ pub fn span_from_ir( .get(file) .map(|source_file| source_file.path.clone()) .unwrap_or_else(|| format!("")); - Some(SourceSpan { - file, + Some(source_span(span, path)) +} + +pub(crate) fn source_span(span: workshop_rs::source::Span, path: String) -> SourceSpan { + SourceSpan { + file: span.file.index(), path, start: Position { line: span.start.line, @@ -134,7 +110,7 @@ pub fn span_from_ir( line: span.end.line, col: span.end.col, }, - }) + } } /// Convenience constructors for internal diagnostics. @@ -158,15 +134,9 @@ impl Diagnostic { stage: Stage, message: impl Into, ) -> Diagnostic { - Diagnostic { - code: code.into(), - stage, - severity: Severity::Warning, - message: message.into(), - status: None, - span: None, - source: None, - } + let mut diagnostic = Self::error(code, stage, message); + diagnostic.severity = Severity::Warning; + diagnostic } } diff --git a/crates/wright-driver/src/provider.rs b/crates/wright-driver/src/provider.rs index ec12da0..5f0daa6 100644 --- a/crates/wright-driver/src/provider.rs +++ b/crates/wright-driver/src/provider.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::path::{Path, PathBuf}; +pub use crate::diag::Severity; + pub type Result = std::result::Result; #[derive(Debug, Clone, PartialEq, Eq)] @@ -31,14 +33,6 @@ pub trait LanguageProvider { fn check(&self, source: &str, path: &Path) -> Result>; } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Severity { - Error, - Warning, - Info, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Status { diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index 33c4911..e9d3834 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -17,6 +17,10 @@ use crate::diag::Diagnostic; use crate::result::{AnalyzeResult, CheckResult, CompileResult, Envelope, InspectResult}; use crate::{CompilerSession, Loaded, RESULT_CONTRACT}; use wright_analyzer::canonical::{SemanticIndex, SemanticService}; +/// A structured tool error. +pub use wright_analyzer::service::ErrorInfo as ToolErrorInfo; +/// A tool response: a structured owned result or a structured error. +pub use wright_analyzer::service::Response as ToolResponse; use wright_analyzer::service::{Origin, Request, Response}; /// The tool-service name and version. @@ -134,21 +138,6 @@ pub enum ToolRequest { }, } -/// A tool response: a structured owned result or a structured error. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum ToolResponse { - Ok { result: serde_json::Value }, - Error { error: ToolErrorInfo }, -} - -/// A structured tool error. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolErrorInfo { - pub code: String, - pub message: String, -} - /// The capability/version contract of the service. #[derive(Debug, Clone, Serialize)] pub struct Capabilities { @@ -412,15 +401,7 @@ impl<'a> ToolService<'a> { request: Request, config: wright_analyzer::registry::LintConfig, ) -> ToolResponse { - match self.semantic_service(config).handle(&request) { - Response::Ok { result } => ToolResponse::Ok { result }, - Response::Error { error } => ToolResponse::Error { - error: ToolErrorInfo { - code: error.code, - message: error.message, - }, - }, - } + self.semantic_service(config).handle(&request) } fn semantic_service( diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 8181dea..fa4e658 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -842,11 +842,7 @@ impl CompilerSession { self.diagnostics.push(Diagnostic { code: d.code, stage: Stage::Analysis, - severity: match d.severity { - crate::provider::Severity::Error => Severity::Error, - crate::provider::Severity::Warning => Severity::Warning, - crate::provider::Severity::Info => Severity::Info, - }, + severity: d.severity, message: d.message, span: Some(SourceSpan { file: 0, @@ -1097,18 +1093,7 @@ pub(crate) fn workshop_diag( resolved: &ResolvedInput, ) -> Diagnostic { let to_span = |s: Option| { - s.map(|span| SourceSpan { - file: span.file.index(), - path: resolved.display.clone(), - start: Position { - line: span.start.line, - col: span.start.col, - }, - end: Position { - line: span.end.line, - col: span.end.col, - }, - }) + s.map(|span| crate::diag::source_span(span, resolved.display.clone())) }; let (code, stage, span) = match &error { workshop_rs::WorkshopError::Catalog(catalog) => { diff --git a/crates/wright-lsp/src/main.rs b/crates/wright-lsp/src/main.rs index a19b70c..47e7b15 100644 --- a/crates/wright-lsp/src/main.rs +++ b/crates/wright-lsp/src/main.rs @@ -436,7 +436,7 @@ fn publish_to( diagnostics: Vec, ) -> Result<(), String> { let params = PublishDiagnosticsParams { - uri: Uri::from_str(&publication_uri(source)).unwrap_or_else(|_| fallback_uri()), + uri: source_to_uri(source), diagnostics, version, }; From cc0c47d7d65fe701aa347bb1fba5616d59854b5d Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:50:53 +0800 Subject: [PATCH 13/33] refactor(lpp): centralize protocol violations --- crates/wright-lpp/src/client.rs | 78 +++++++++++---------------------- 1 file changed, 25 insertions(+), 53 deletions(-) diff --git a/crates/wright-lpp/src/client.rs b/crates/wright-lpp/src/client.rs index 2d4f3ab..2942e97 100644 --- a/crates/wright-lpp/src/client.rs +++ b/crates/wright-lpp/src/client.rs @@ -117,9 +117,7 @@ impl JsonRpcClient { self.require_ready("lpp/shutdown")?; let value = self.send("lpp/shutdown", json!({}))?; if !value.is_null() { - return Err(ProviderError::Malformed { - detail: "lpp/shutdown result must be null".to_string(), - }); + return Err(malformed("lpp/shutdown result must be null")); } self.phase = ClientPhase::ShutDown; Ok(()) @@ -227,47 +225,20 @@ fn dispatch_line(shared: &Arc>, line: &str) -> bool { let parsed: Value = match serde_json::from_str(line) { Ok(val) => val, Err(e) => { - terminate_shared( - shared, - true, - ProviderError::Malformed { - detail: format!("line is not valid JSON: {e}"), - }, - ); - return false; + return protocol_violation(shared, format!("line is not valid JSON: {e}")); } }; let Some(object) = parsed.as_object() else { - terminate_shared( - shared, - true, - ProviderError::Malformed { - detail: "provider sent a batch or non-object message".into(), - }, - ); - return false; + return protocol_violation(shared, "provider sent a batch or non-object message"); }; if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { - terminate_shared( - shared, - true, - ProviderError::Malformed { - detail: "provider message lacks jsonrpc \"2.0\"".into(), - }, - ); - return false; + return protocol_violation(shared, "provider message lacks jsonrpc \"2.0\""); } let Some(id) = object.get("id").and_then(Value::as_i64) else { - terminate_shared( + return protocol_violation( shared, - true, - ProviderError::Malformed { - detail: - "provider message has no integer request id (LPP v1 defines no notifications)" - .into(), - }, + "provider message has no integer request id (LPP v1 defines no notifications)", ); - return false; }; let sender = { @@ -276,14 +247,10 @@ fn dispatch_line(shared: &Arc>, line: &str) -> bool { Some(s) => s, None => { drop(guard); - terminate_shared( + return protocol_violation( shared, - true, - ProviderError::Malformed { - detail: format!("provider response id {id} matches no pending request"), - }, + format!("provider response id {id} matches no pending request"), ); - return false; } } }; @@ -292,18 +259,27 @@ fn dispatch_line(shared: &Arc>, line: &str) -> bool { true } +fn protocol_violation(shared: &Arc>, detail: impl Into) -> bool { + terminate_shared(shared, true, malformed(detail)); + false +} + +fn malformed(detail: impl Into) -> ProviderError { + ProviderError::Malformed { + detail: detail.into(), + } +} + fn response_outcome(object: &Map) -> Result { match (object.contains_key("result"), object.contains_key("error")) { (true, false) => Ok(object.get("result").cloned().unwrap_or(Value::Null)), (false, true) => Err(error_from_response( object.get("error").cloned().unwrap_or(Value::Null), )), - (true, true) => Err(ProviderError::Malformed { - detail: "provider response carries both result and error".into(), - }), - (false, false) => Err(ProviderError::Malformed { - detail: "provider response carries neither result nor error".into(), - }), + (true, true) => Err(malformed("provider response carries both result and error")), + (false, false) => Err(malformed( + "provider response carries neither result nor error", + )), } } @@ -328,14 +304,10 @@ fn error_from_response(error: Value) -> ProviderError { message, }) } - None => ProviderError::Malformed { - detail: format!("LPP error code -32000 without data.lpp: {message}"), - }, + None => malformed(format!("LPP error code -32000 without data.lpp: {message}")), }, (Some(code), Some(message)) => ProviderError::JsonRpc { code, message }, - _ => ProviderError::Malformed { - detail: format!("provider error response is malformed: {error}"), - }, + _ => malformed(format!("provider error response is malformed: {error}")), } } From d33ac54641616ec7f5bc79b8916f717cb5e538cb Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:18:29 +0800 Subject: [PATCH 14/33] refactor: deduplicate analyzer and CLI code paths --- .../wright-analyzer/src/canonical/analysis.rs | 7 +- .../wright-analyzer/src/canonical/symbols.rs | 81 +++++++++---------- crates/wright-cli/src/bin/wright-serve.rs | 33 +++----- crates/wright-cli/src/cli.rs | 27 ++++--- crates/wright-cli/src/completion.rs | 53 +++++------- 5 files changed, 87 insertions(+), 114 deletions(-) diff --git a/crates/wright-analyzer/src/canonical/analysis.rs b/crates/wright-analyzer/src/canonical/analysis.rs index 0179118..d63d64e 100644 --- a/crates/wright-analyzer/src/canonical/analysis.rs +++ b/crates/wright-analyzer/src/canonical/analysis.rs @@ -4,7 +4,7 @@ use workshop_rs::source::Span; use workshop_rs::{Action, Event, ModifyOp, Program, Rule, Value}; use super::cfg::{is_wait, matching_end}; -use super::symbols::{ActionId, RuleId, ValueId, value_identity_map, value_occurrence}; +use super::symbols::{ActionId, RuleId, ValueId, source_occurrence, value_identity_map}; use super::traversal::{visit_action_roots, visit_value_tree}; use crate::analysis::{Boundedness, EvidenceClass, Severity}; use crate::registry::LintConfig; @@ -58,10 +58,11 @@ pub fn analyze(program: &Program, config: &LintConfig) -> Vec { severity: Severity::Info, message: "geometry predicate evaluated inside a loop body may be expensive per iteration" .into(), - span: value_occurrence( + span: source_occurrence( program, program.action_span(rule_id, start + offset), name, + false, ), rule: rule_id, action: Some(action_id), @@ -231,7 +232,7 @@ fn ongoing_condition_findings( "geometry predicate in an ongoing-rule condition {} of {condition_count} {evaluation}{later_gates}; its cost is heuristic, not measured runtime load", index + 1, ), - span: value_occurrence(program, span, name).or(span), + span: source_occurrence(program, span, name, false), rule: rule_id, action: None, value: value_ids.get(&(value as *const Value as usize)).copied(), diff --git a/crates/wright-analyzer/src/canonical/symbols.rs b/crates/wright-analyzer/src/canonical/symbols.rs index 5b42b64..b1836fa 100644 --- a/crates/wright-analyzer/src/canonical/symbols.rs +++ b/crates/wright-analyzer/src/canonical/symbols.rs @@ -25,11 +25,10 @@ impl SymbolId { fn append_named_symbols<'a>( symbols: &mut Vec, program: &Program, - kind: SymbolKind, - names: impl Iterator, + named: impl Iterator, ) { - let prefix = kind.declaration_prefix(); - for name in names { + for (kind, name) in named { + let prefix = kind.declaration_prefix(); let id = SymbolId::from_index(symbols.len()); let occurrence = declaration_span(program, prefix, name); symbols.push(Symbol { @@ -152,29 +151,22 @@ impl SemanticIndex { append_named_symbols( &mut symbols, program, - SymbolKind::GlobalVariable, program .global_variables .iter() - .map(|variable| variable.name.as_str()), - ); - append_named_symbols( - &mut symbols, - program, - SymbolKind::PlayerVariable, - program - .player_variables - .iter() - .map(|variable| variable.name.as_str()), - ); - append_named_symbols( - &mut symbols, - program, - SymbolKind::Subroutine, - program - .subroutines - .iter() - .map(|subroutine| subroutine.name.as_str()), + .map(|variable| (SymbolKind::GlobalVariable, variable.name.as_str())) + .chain( + program + .player_variables + .iter() + .map(|variable| (SymbolKind::PlayerVariable, variable.name.as_str())), + ) + .chain( + program + .subroutines + .iter() + .map(|subroutine| (SymbolKind::Subroutine, subroutine.name.as_str())), + ), ); for (rule, data) in program.rules.iter().enumerate() { let id = SymbolId::from_index(symbols.len()); @@ -228,13 +220,13 @@ impl SemanticIndex { ) -> Self { let mut index = Self::build(program); for symbol in &mut index.symbols { - let prefixes = match symbol.kind { - SymbolKind::GlobalVariable => &["globalvar "][..], - SymbolKind::PlayerVariable => &["playervar "][..], - SymbolKind::Subroutine => &["subroutine "][..], - SymbolKind::Rule => &[][..], - }; - if let Some(span) = declaration_span_in_sources(sources, prefixes, &symbol.name) { + if symbol.kind != SymbolKind::Rule + && let Some(span) = declaration_span_in_sources( + sources, + &[symbol.kind.declaration_prefix()], + &symbol.name, + ) + { symbol.span = Some(declaration_line_span(sources, span)); symbol.occurrence = Some(span); } @@ -380,7 +372,7 @@ impl SemanticIndex { Some(rule), None, None, - action_occurrence(program, program.rule_span(rule), name), + source_occurrence(program, program.rule_span(rule), name, true), ); } } @@ -422,7 +414,7 @@ impl SemanticIndex { Some(rule), Some(action_id), None, - action_occurrence(program, span, name), + source_occurrence(program, span, name, true), ); if reads_old_value { self.push( @@ -485,7 +477,7 @@ impl SemanticIndex { Some(rule), action, Some(value_id), - value_occurrence(program, span, name).or(span), + source_occurrence(program, span, name, false), ); } } @@ -497,7 +489,7 @@ impl SemanticIndex { Some(rule), action, Some(value_id), - value_occurrence(program, span, variable).or(span), + source_occurrence(program, span, variable, false), ); } } @@ -676,18 +668,17 @@ fn is_code_position(chars: &[char], position: usize) -> bool { !quoted } -fn action_occurrence(program: &Program, span: Option, name: &str) -> Option { - let span = span?; - let Some(source_doc) = program.source(span.file) else { - return Some(span); - }; - Some(find_occurrence(source_doc.text(), span, name, true, 0, false).unwrap_or(span)) -} - -pub(super) fn value_occurrence(program: &Program, span: Option, name: &str) -> Option { +pub(super) fn source_occurrence( + program: &Program, + span: Option, + name: &str, + before_assignment: bool, +) -> Option { let span = span?; let Some(source_doc) = program.source(span.file) else { return Some(span); }; - Some(find_occurrence(source_doc.text(), span, name, false, 0, false).unwrap_or(span)) + Some( + find_occurrence(source_doc.text(), span, name, before_assignment, 0, false).unwrap_or(span), + ) } diff --git a/crates/wright-cli/src/bin/wright-serve.rs b/crates/wright-cli/src/bin/wright-serve.rs index fdc30d5..fc60b99 100644 --- a/crates/wright-cli/src/bin/wright-serve.rs +++ b/crates/wright-cli/src/bin/wright-serve.rs @@ -70,35 +70,28 @@ fn main() -> ExitCode { } fn serve_stdio(service: &mut ToolService<'_>) -> ExitCode { - let stdout = std::io::stdout(); - let mut out = stdout.lock(); - for line in std::io::stdin().lock().lines().map_while(Result::ok) { - if line.trim().is_empty() { - continue; - } - let resp = dispatch(service, &line); - if writeln!(out, "{resp}").is_err() { - break; - } - } - ExitCode::SUCCESS + serve_lines(|line| Some(dispatch(service, line))) } fn serve_jsonrpc(service: &mut ToolService<'_>) -> ExitCode { + serve_lines(|line| { + match serde_json::from_str(line) { + Ok(value) => jsonrpc_dispatch(service, value), + Err(_) => Some(jsonrpc_error(Value::Null, -32700, "Parse error")), + } + .map(|response| response.to_string()) + }) +} + +fn serve_lines(mut dispatch: impl FnMut(&str) -> Option) -> ExitCode { let stdout = std::io::stdout(); let mut out = stdout.lock(); for line in std::io::stdin().lock().lines().map_while(Result::ok) { if line.trim().is_empty() { continue; } - let resp = match serde_json::from_str(&line) { - Ok(val) => jsonrpc_dispatch(service, val), - Err(_) => Some(jsonrpc_error(Value::Null, -32700, "Parse error")), - }; - if let Some(r) = resp { - if writeln!(out, "{r}").is_err() { - break; - } + if dispatch(&line).is_some_and(|response| writeln!(out, "{response}").is_err()) { + break; } } ExitCode::SUCCESS diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index a48f858..c787e49 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -305,21 +305,26 @@ pub(crate) enum ShellArg { } impl ShellArg { - pub(crate) fn as_str(&self) -> &'static str { + pub(crate) const ALL: [Self; 4] = [Self::Bash, Self::Zsh, Self::Fish, Self::PowerShell]; + + pub(crate) fn metadata(self) -> (&'static str, &'static str, clap_complete::Shell) { match self { - ShellArg::Bash => "bash", - ShellArg::Zsh => "zsh", - ShellArg::Fish => "fish", - ShellArg::PowerShell => "powershell", + Self::Bash => ("bash", "wright", clap_complete::Shell::Bash), + Self::Zsh => ("zsh", "_wright", clap_complete::Shell::Zsh), + Self::Fish => ("fish", "wright.fish", clap_complete::Shell::Fish), + Self::PowerShell => ( + "powershell", + "_wright.ps1", + clap_complete::Shell::PowerShell, + ), } } + pub(crate) fn as_str(&self) -> &'static str { + self.metadata().0 + } + pub(crate) fn to_clap_shell(self) -> clap_complete::Shell { - match self { - ShellArg::Bash => clap_complete::Shell::Bash, - ShellArg::Zsh => clap_complete::Shell::Zsh, - ShellArg::Fish => clap_complete::Shell::Fish, - ShellArg::PowerShell => clap_complete::Shell::PowerShell, - } + self.metadata().2 } } diff --git a/crates/wright-cli/src/completion.rs b/crates/wright-cli/src/completion.rs index 83a3be7..13baaa1 100644 --- a/crates/wright-cli/src/completion.rs +++ b/crates/wright-cli/src/completion.rs @@ -63,12 +63,7 @@ pub(crate) fn generate_script(shell: ShellArg) -> Vec { } pub(crate) fn filename_for(shell: ShellArg) -> &'static str { - match shell { - ShellArg::Bash => "wright", - ShellArg::Zsh => "_wright", - ShellArg::Fish => "wright.fish", - ShellArg::PowerShell => "_wright.ps1", - } + shell.metadata().1 } fn env_var_non_empty(key: &str) -> Option { @@ -119,17 +114,13 @@ pub(crate) fn detect_shell() -> Result { fn parse_shell_name(name: &str) -> Option { let lower = name.to_ascii_lowercase(); let name = lower.trim(); - if name == "zsh" || name.starts_with("zsh") { + if name.starts_with("zsh") { Some(ShellArg::Zsh) - } else if name == "bash" || name.starts_with("bash") { + } else if name.starts_with("bash") { Some(ShellArg::Bash) - } else if name == "fish" || name.starts_with("fish") { + } else if name.starts_with("fish") { Some(ShellArg::Fish) - } else if name == "pwsh" - || name == "powershell" - || name.starts_with("pwsh") - || name.starts_with("powershell") - { + } else if name.starts_with("pwsh") || name.starts_with("powershell") { Some(ShellArg::PowerShell) } else { None @@ -157,6 +148,13 @@ fn xdg_dir(home: &Path, var: &str, default_sub: &str) -> PathBuf { .unwrap_or_else(|| home.join(default_sub)) } +fn completion_override_dir() -> Option { + std::env::var("WRIGHT_COMPLETION_DIR") + .ok() + .filter(|dir| !dir.is_empty()) + .map(PathBuf::from) +} + fn completion_dirs() -> Result<(PathBuf, PathBuf, PathBuf), CompletionError> { let home = user_home_dir()?; let data_home = xdg_dir(&home, "XDG_DATA_HOME", ".local/share"); @@ -165,10 +163,8 @@ fn completion_dirs() -> Result<(PathBuf, PathBuf, PathBuf), CompletionError> { } pub(crate) fn default_dir_for(shell: ShellArg) -> Result { - if let Ok(override_dir) = std::env::var("WRIGHT_COMPLETION_DIR") { - if !override_dir.is_empty() { - return Ok(PathBuf::from(override_dir)); - } + if let Some(override_dir) = completion_override_dir() { + return Ok(override_dir); } let (home, data_home, config_home) = completion_dirs()?; @@ -233,10 +229,8 @@ pub(crate) fn default_dir_for(shell: ShellArg) -> Result Vec { - if let Ok(override_dir) = std::env::var("WRIGHT_COMPLETION_DIR") { - if !override_dir.is_empty() { - return vec![PathBuf::from(override_dir)]; - } + if let Some(override_dir) = completion_override_dir() { + return vec![override_dir]; } let Ok((home, data_home, config_home)) = completion_dirs() else { @@ -398,12 +392,7 @@ fn print_guidance(shell: ShellArg, target_file: &Path) { pub(crate) fn run_install(args: &CompletionInstallArgs) -> Result { let shells = if args.all { - vec![ - ShellArg::Bash, - ShellArg::Zsh, - ShellArg::Fish, - ShellArg::PowerShell, - ] + ShellArg::ALL.to_vec() } else { vec![match args.effective_shell() { Some(s) => s, @@ -419,14 +408,8 @@ pub(crate) fn run_install(args: &CompletionInstallArgs) -> Result Result { - let shells = [ - ShellArg::Bash, - ShellArg::Zsh, - ShellArg::Fish, - ShellArg::PowerShell, - ]; let mut refreshed = 0; - for shell in shells { + for shell in ShellArg::ALL { let filename = filename_for(shell); for dir in candidate_dirs_for(shell) { let target_file = dir.join(filename); From 46a7a5d0c3ee2d0fac16f9c5c9436a309350cc97 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:32:39 +0800 Subject: [PATCH 15/33] refactor(lsp): share request parameter decoding --- Cargo.lock | 1 + crates/wright-lsp/Cargo.toml | 1 + crates/wright-lsp/src/main.rs | 31 +++++++++++++------------------ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d8dc808..c9d03d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2184,6 +2184,7 @@ name = "wright-lsp" version = "0.2.40" dependencies = [ "lsp-types", + "serde", "serde_json", "wright-language", ] diff --git a/crates/wright-lsp/Cargo.toml b/crates/wright-lsp/Cargo.toml index ea1c1e7..744e745 100644 --- a/crates/wright-lsp/Cargo.toml +++ b/crates/wright-lsp/Cargo.toml @@ -16,5 +16,6 @@ path = "src/main.rs" [dependencies] lsp-types = "0.97" +serde.workspace = true serde_json = "1" wright-language = { path = "../wright-language" } diff --git a/crates/wright-lsp/src/main.rs b/crates/wright-lsp/src/main.rs index 47e7b15..5eb1c42 100644 --- a/crates/wright-lsp/src/main.rs +++ b/crates/wright-lsp/src/main.rs @@ -76,8 +76,7 @@ fn run() -> Result<(), String> { "shutdown" => write_response(&mut writer, id, Value::Null)?, "exit" => break, "textDocument/didOpen" => { - let params: DidOpenTextDocumentParams = - serde_json::from_value(params.unwrap()).map_err(|e| e.to_string())?; + let params: DidOpenTextDocumentParams = parse_params(params)?; let uri = params.text_document.uri.to_string(); let document = Document::with_version( uri.clone(), @@ -89,8 +88,7 @@ fn run() -> Result<(), String> { publish_affected_diagnostics(&mut writer, &service, &mut ownership, &uri)?; } "textDocument/didChange" => { - let params: DidChangeTextDocumentParams = - serde_json::from_value(params.unwrap()).map_err(|e| e.to_string())?; + let params: DidChangeTextDocumentParams = parse_params(params)?; let uri = params.text_document.uri.to_string(); if let Some(change) = params.content_changes.last() { service @@ -100,8 +98,7 @@ fn run() -> Result<(), String> { publish_affected_diagnostics(&mut writer, &service, &mut ownership, &uri)?; } "textDocument/didClose" => { - let params: DidCloseTextDocumentParams = - serde_json::from_value(params.unwrap()).map_err(|e| e.to_string())?; + let params: DidCloseTextDocumentParams = parse_params(params)?; let uri = params.text_document.uri.to_string(); let owned = ownership.remove(&uri).unwrap_or_default(); service.store.close(&uri); @@ -119,8 +116,7 @@ fn run() -> Result<(), String> { } "textDocument/didSave" => {} "textDocument/hover" => { - let params: TextDocumentPositionParams = - serde_json::from_value(params.unwrap()).map_err(|e| e.to_string())?; + let params: TextDocumentPositionParams = parse_params(params)?; let position = convert_position(params.position); let result = service .hover(¶ms.text_document.uri.to_string(), position) @@ -134,8 +130,7 @@ fn run() -> Result<(), String> { write_response(&mut writer, id, serde_json::to_value(result).unwrap())?; } "textDocument/definition" => { - let params: GotoDefinitionParams = - serde_json::from_value(params.unwrap()).map_err(|e| e.to_string())?; + let params: GotoDefinitionParams = parse_params(params)?; let position = convert_position(params.text_document_position_params.position); let result = service .definition( @@ -155,8 +150,7 @@ fn run() -> Result<(), String> { write_response(&mut writer, id, serde_json::to_value(result).unwrap())?; } "textDocument/references" => { - let params: ReferenceParams = - serde_json::from_value(params.unwrap()).map_err(|e| e.to_string())?; + let params: ReferenceParams = parse_params(params)?; let position = convert_position(params.text_document_position.position); let uri = params.text_document_position.text_document.uri; let result: Vec = service @@ -170,8 +164,7 @@ fn run() -> Result<(), String> { write_response(&mut writer, id, serde_json::to_value(result).unwrap())?; } "textDocument/completion" => { - let params: CompletionParams = - serde_json::from_value(params.unwrap()).map_err(|e| e.to_string())?; + let params: CompletionParams = parse_params(params)?; let position = convert_position(params.text_document_position.position); let uri = params.text_document_position.text_document.uri; let items: Vec = service @@ -187,8 +180,7 @@ fn run() -> Result<(), String> { write_response(&mut writer, id, serde_json::to_value(items).unwrap())?; } "textDocument/rename" => { - let params: RenameParams = - serde_json::from_value(params.unwrap()).map_err(|e| e.to_string())?; + let params: RenameParams = parse_params(params)?; let position = convert_position(params.text_document_position.position); let uri = params.text_document_position.text_document.uri.to_string(); let rename = service.rename(&uri, position, ¶ms.new_name); @@ -238,8 +230,7 @@ fn run() -> Result<(), String> { } } "textDocument/semanticTokens/full" => { - let params: SemanticTokensParams = - serde_json::from_value(params.unwrap()).map_err(|e| e.to_string())?; + let params: SemanticTokensParams = parse_params(params)?; let uri = params.text_document.uri; let tokens = service.semantic_tokens(&uri.to_string()); let result = SemanticTokens { @@ -346,6 +337,10 @@ fn write_response(writer: &mut impl Write, id: Option, result: Value) -> ) } +fn parse_params(params: Option) -> Result { + serde_json::from_value(params.unwrap()).map_err(|error| error.to_string()) +} + fn write_error( writer: &mut impl Write, id: Option, From c7897f57c7cd0e669093364c2fba3e67f84bd0a5 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:37:23 +0800 Subject: [PATCH 16/33] refactor(driver): share profile application --- crates/wright-driver/src/session.rs | 36 ++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index fa4e658..8da6489 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -245,15 +245,7 @@ impl CompilerSession { } SourceKind::Ostw => unreachable!(), }; - if self.config.profile != wright_transform::Profile::Off { - wright_transform::run_canonical(&mut program, self.config.profile).map_err(|e| { - Diagnostic::error( - "transform-error", - Stage::Internal, - format!("Workshop transformation failed: {e}"), - ) - })?; - } + apply_profile(&mut program, self.config.profile)?; let loaded = Loaded { program: Arc::new(program), origin: resolved.origin.clone(), @@ -431,16 +423,8 @@ impl CompilerSession { })?; if self.config.profile != wright_transform::Profile::Off { self.progress(ProgressEvent::new(ProgressPhase::Lowering)); - wright_transform::run_canonical(&mut program, self.config.profile).map_err( - |error| { - Diagnostic::error( - "transform-error", - Stage::Internal, - format!("Workshop transformation failed: {error}"), - ) - }, - )?; } + apply_profile(&mut program, self.config.profile)?; resolved.origin.locale = Some(locale.to_string()); let loaded = Loaded { program: Arc::new(program), @@ -902,6 +886,22 @@ impl CompilerSession { } } +fn apply_profile( + program: &mut Program, + profile: wright_transform::Profile, +) -> Result<(), Diagnostic> { + if profile != wright_transform::Profile::Off { + wright_transform::run_canonical(program, profile).map_err(|error| { + Diagnostic::error( + "transform-error", + Stage::Internal, + format!("Workshop transformation failed: {error}"), + ) + })?; + } + Ok(()) +} + /// Extract the `result` payload of a semantic-service request as JSON. fn service_response(service: &SemanticService<'_>, request: &Request) -> serde_json::Value { match service.handle(request) { From e5175e3cb12b250ff898632fa855a49392e13aa3 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:42:00 +0800 Subject: [PATCH 17/33] refactor(cli): share completion file writes --- crates/wright-cli/src/completion.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/wright-cli/src/completion.rs b/crates/wright-cli/src/completion.rs index 13baaa1..5dd13b8 100644 --- a/crates/wright-cli/src/completion.rs +++ b/crates/wright-cli/src/completion.rs @@ -285,7 +285,8 @@ pub(crate) fn install_for_shell( return Ok(InstallStatus::DryRun(target_file)); } - if target_file.is_file() { + let updating = target_file.is_file(); + if updating { let existing = std::fs::read(&target_file).map_err(|e| { CompletionError::failed(format!( "could not read existing completion file {}: {e}", @@ -295,28 +296,26 @@ pub(crate) fn install_for_shell( if existing == content && !force { return Ok(InstallStatus::UpToDate(target_file)); } - std::fs::write(&target_file, &content).map_err(|e| { + } else { + std::fs::create_dir_all(&target_dir).map_err(|e| { CompletionError::failed(format!( - "could not update completion file {}: {e}", - target_file.display() + "could not create completion directory {}: {e}", + target_dir.display() )) })?; - return Ok(InstallStatus::Updated(target_file)); } - - std::fs::create_dir_all(&target_dir).map_err(|e| { - CompletionError::failed(format!( - "could not create completion directory {}: {e}", - target_dir.display() - )) - })?; std::fs::write(&target_file, &content).map_err(|e| { CompletionError::failed(format!( - "could not write completion file {}: {e}", + "could not {} completion file {}: {e}", + if updating { "update" } else { "write" }, target_file.display() )) })?; - Ok(InstallStatus::Created(target_file)) + Ok(if updating { + InstallStatus::Updated(target_file) + } else { + InstallStatus::Created(target_file) + }) } fn print_status(status: &InstallStatus, shell: ShellArg, guidance: bool) { From ba6033ea82ecb549bef1b08f97e458cf85f69b72 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:51:27 +0800 Subject: [PATCH 18/33] refactor(cli): share completion search paths --- crates/wright-cli/src/completion.rs | 96 ++++++++++++++--------------- 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/crates/wright-cli/src/completion.rs b/crates/wright-cli/src/completion.rs index 5dd13b8..bf0323a 100644 --- a/crates/wright-cli/src/completion.rs +++ b/crates/wright-cli/src/completion.rs @@ -168,75 +168,59 @@ pub(crate) fn default_dir_for(shell: ShellArg) -> Result { - let config = config_home.join("fish/completions"); - let vendor = data_home.join("fish/vendor_completions.d"); - Ok(if vendor.is_dir() && !config.is_dir() { - vendor - } else { - config - }) - } - ShellArg::Bash => { - let xdg = data_home.join("bash-completion/completions"); - let legacy = home.join(".bash_completion.d"); - Ok(if legacy.is_dir() && !xdg.is_dir() { - legacy - } else { - xdg - }) - } + ShellArg::Fish | ShellArg::Bash => Ok(candidates + .iter() + .find(|dir| dir.is_dir()) + .cloned() + .unwrap_or_else(|| candidates[0].clone())), ShellArg::Zsh => { - if let Ok(custom) = std::env::var("ZSH_CUSTOM") { - if !custom.is_empty() { - let custom_path = PathBuf::from(custom); - let completions = custom_path.join("completions"); - if completions.is_dir() || custom_path.is_dir() { - return Ok(completions); - } + let custom = std::env::var("ZSH_CUSTOM") + .ok() + .filter(|custom| !custom.is_empty()); + let custom_index = if let Some(custom) = custom { + if candidates[0].is_dir() || PathBuf::from(custom).is_dir() { + return Ok(candidates[0].clone()); } - } - let oh_my_zsh_custom = home.join(".oh-my-zsh/custom/completions"); - if oh_my_zsh_custom.is_dir() { - return Ok(oh_my_zsh_custom); + 1 + } else { + 0 + }; + if candidates[custom_index].is_dir() { + return Ok(candidates[custom_index].clone()); } if home.join(".oh-my-zsh").is_dir() { - return Ok(home.join(".oh-my-zsh/custom/completions")); - } - for candidate in [home.join(".zfunc"), home.join(".zsh/completions")] { - if candidate.is_dir() { - return Ok(candidate); - } + return Ok(candidates[custom_index].clone()); } - Ok(data_home.join("zsh/site-functions")) + Ok(candidates + .iter() + .skip(custom_index + 1) + .find(|dir| dir.is_dir()) + .cloned() + .unwrap_or_else(|| candidates.last().expect("zsh has a fallback").clone())) } ShellArg::PowerShell => { if cfg!(windows) { - let ps_docs = home.join("Documents/PowerShell/Scripts"); - let win_ps_docs = home.join("Documents/WindowsPowerShell/Scripts"); - Ok(if win_ps_docs.is_dir() && !ps_docs.is_dir() { - win_ps_docs + Ok(if candidates[1].is_dir() && !candidates[0].is_dir() { + candidates[1].clone() } else { - ps_docs + candidates[0].clone() }) } else { - Ok(config_home.join("powershell/completions")) + Ok(candidates[2].clone()) } } } } -pub(crate) fn candidate_dirs_for(shell: ShellArg) -> Vec { - if let Some(override_dir) = completion_override_dir() { - return vec![override_dir]; - } - - let Ok((home, data_home, config_home)) = completion_dirs() else { - return Vec::new(); - }; - +fn completion_candidates( + shell: ShellArg, + home: &Path, + data_home: &Path, + config_home: &Path, +) -> Vec { let mut dirs = Vec::new(); match shell { ShellArg::Fish => { @@ -268,6 +252,16 @@ pub(crate) fn candidate_dirs_for(shell: ShellArg) -> Vec { dirs } +pub(crate) fn candidate_dirs_for(shell: ShellArg) -> Vec { + if let Some(override_dir) = completion_override_dir() { + return vec![override_dir]; + } + let Ok((home, data_home, config_home)) = completion_dirs() else { + return Vec::new(); + }; + completion_candidates(shell, &home, &data_home, &config_home) +} + pub(crate) fn install_for_shell( shell: ShellArg, explicit_dir: Option<&Path>, From 3766d09cec397fc214226723fadc74faf7f35404 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:59:36 +0800 Subject: [PATCH 19/33] refactor(driver): reuse session semantic service --- crates/wright-driver/src/service.rs | 13 ++----------- crates/wright-driver/src/session.rs | 10 +++++----- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index e9d3834..3f09af5 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -21,7 +21,7 @@ use wright_analyzer::canonical::{SemanticIndex, SemanticService}; pub use wright_analyzer::service::ErrorInfo as ToolErrorInfo; /// A tool response: a structured owned result or a structured error. pub use wright_analyzer::service::Response as ToolResponse; -use wright_analyzer::service::{Origin, Request, Response}; +use wright_analyzer::service::{Request, Response}; /// The tool-service name and version. pub const SERVICE_NAME: &str = "wright-tool-service"; @@ -408,16 +408,7 @@ impl<'a> ToolService<'a> { &self, config: wright_analyzer::registry::LintConfig, ) -> SemanticService<'_> { - let origin = Origin { - kind: self.loaded.origin.kind.clone(), - locale: self.loaded.origin.locale.clone(), - }; - SemanticService::with_origin_and_config_and_registry( - &self.loaded.program, - origin, - config, - std::sync::Arc::clone(self.session.lint_registry()), - ) + self.session.service_with(&self.loaded, config) } /// `lint`: rule metadata, effective configuration, and findings over the diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 8da6489..ee5b90f 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -143,10 +143,6 @@ impl CompilerSession { self.progress_observer = None; } - pub(crate) fn lint_registry(&self) -> &Arc { - &self.lint_registry - } - pub(crate) fn catalog(&self) -> &workshop_rs::catalog::Catalog { &self.catalog } @@ -779,7 +775,11 @@ impl CompilerSession { /// Build the semantic service over a loaded program with an explicit lint /// configuration. - fn service_with<'a>(&self, loaded: &'a Loaded, config: LintConfig) -> SemanticService<'a> { + pub(crate) fn service_with<'a>( + &self, + loaded: &'a Loaded, + config: LintConfig, + ) -> SemanticService<'a> { let origin = ServiceOrigin { kind: if loaded.provenance == Provenance::Unmapped { "provider-artifact".to_string() From 82f2a82b67592ba2ded96d011a92cc109ccc1af5 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:26:41 +0800 Subject: [PATCH 20/33] refactor(analyzer): discard raw matcher after compilation --- crates/wright-analyzer/src/declarative.rs | 101 +++++++++++++--------- 1 file changed, 58 insertions(+), 43 deletions(-) diff --git a/crates/wright-analyzer/src/declarative.rs b/crates/wright-analyzer/src/declarative.rs index 5286f42..1b6ed2e 100644 --- a/crates/wright-analyzer/src/declarative.rs +++ b/crates/wright-analyzer/src/declarative.rs @@ -157,7 +157,9 @@ pub struct EnumPattern { #[derive(Debug, Clone)] pub struct DeclarativeRule { - definition: RuleDefinition, + id: String, + metadata: RuleMetadata, + scope: Scope, event: Option, conditions: Option, actions: Vec, @@ -210,31 +212,38 @@ impl DeclarativeRule { definition: RuleDefinition, catalog: &Catalog, ) -> Result { - validate_identity(&definition.id)?; - let locale = Locale::new(&definition.locale); + let RuleDefinition { + id, + locale: locale_name, + metadata, + matcher, + } = definition; + validate_identity(&id)?; + let locale = Locale::new(&locale_name); if !catalog.supports(&locale) { - return Err(RuleError::UnsupportedLocale(definition.locale)); + return Err(RuleError::UnsupportedLocale(locale_name)); } - let event = definition - .matcher - .event + let RuleMatcher { + event, + scope, + conditions, + actions, + } = matcher; + let event = event .as_deref() .map(|value| resolve(catalog, Kind::Event, &locale, value)) .transpose()?; - let conditions = definition - .matcher - .conditions - .as_ref() + let conditions = conditions .map(|pattern| canonical_node(pattern, catalog, &locale)) .transpose()?; - let actions = definition - .matcher - .actions - .iter() + let actions = actions + .into_iter() .map(|pattern| canonical_action(pattern, catalog, &locale)) .collect::, _>>()?; Ok(Self { - definition, + id, + metadata, + scope, event, conditions, actions, @@ -242,11 +251,11 @@ impl DeclarativeRule { } pub fn id(&self) -> &str { - &self.definition.id + &self.id } pub fn metadata(&self) -> &RuleMetadata { - &self.definition.metadata + &self.metadata } /// Declarative matchers only report exact structural facts from canonical @@ -289,7 +298,7 @@ impl DeclarativeRule { { return Vec::new(); } - let scopes = public_scopes(program, rule, rule_data, self.definition.matcher.scope); + let scopes = public_scopes(program, rule, rule_data, self.scope); let mut findings = Vec::new(); for (scope_id, actions, values, anchor) in scopes { if let Some(pattern) = &self.conditions { @@ -547,23 +556,30 @@ pub(crate) fn public_event_id(event: &workshop_rs::Event) -> &str { } fn canonical_node( - pattern: &NodePattern, + pattern: NodePattern, catalog: &Catalog, locale: &Locale, ) -> Result { Ok(CanonicalNodePattern { - value: canonical_value(&pattern.value, catalog, locale)?, - count: pattern.count.clone(), + value: canonical_value(pattern.value, catalog, locale)?, + count: pattern.count, }) } fn canonical_action( - pattern: &ActionPattern, + pattern: ActionPattern, catalog: &Catalog, locale: &Locale, ) -> Result { - let name = pattern - .name + let ActionPattern { + kind, + name, + args, + parameters: named_parameters, + count, + present, + } = pattern; + let name = name .as_deref() .map(|name| resolve(catalog, Kind::Action, locale, name)) .transpose()?; @@ -571,30 +587,29 @@ fn canonical_action( .as_deref() .and_then(|id| catalog.entry(Kind::Action, id)); let mut parameters = Vec::new(); - for param in &pattern.parameters { + for param in named_parameters { let Some(entry) = action_entry else { return Err(RuleError::NamedParameterRequiresAction); }; let Some(index) = resolve_parameter(entry, locale, ¶m.name) else { return Err(RuleError::UnknownParameter { action: entry.id.clone(), - parameter: param.name.clone(), + parameter: param.name, locale: locale.to_string(), }); }; - parameters.push((index, canonical_value(¶m.value, catalog, locale)?)); + parameters.push((index, canonical_value(param.value, catalog, locale)?)); } Ok(CanonicalActionPattern { - kind: pattern.kind, + kind, name, - args: pattern - .args - .iter() + args: args + .into_iter() .map(|value| canonical_value(value, catalog, locale)) .collect::>()?, parameters, - count: pattern.count.clone(), - present: pattern.present, + count, + present, }) } @@ -607,7 +622,7 @@ fn resolve_parameter( } fn canonical_value( - pattern: &ValuePattern, + pattern: ValuePattern, catalog: &Catalog, locale: &Locale, ) -> Result { @@ -623,36 +638,36 @@ fn canonical_value( if let Some(number) = pattern.number { return Ok(CanonicalValuePattern::Number(number)); } - if let Some(string) = &pattern.string { - return Ok(CanonicalValuePattern::String(string.clone())); + if let Some(string) = pattern.string { + return Ok(CanonicalValuePattern::String(string)); } if let Some(boolean) = pattern.boolean { return Ok(CanonicalValuePattern::Boolean(boolean)); } - if let Some(call) = &pattern.call { + if let Some(call) = pattern.call { return Ok(CanonicalValuePattern::Call { name: resolve_value_or_operator(catalog, locale, &call.name)?, args: call .args - .iter() + .into_iter() .map(|value| canonical_value(value, catalog, locale)) .collect::>()?, }); } - if let Some(en) = &pattern.enum_value { + if let Some(en) = pattern.enum_value { let domain = resolve_enum_domain(catalog, locale, &en.domain)?; let member = catalog .resolve_enum_member(&domain, locale, &en.member) .ok_or_else(|| RuleError::UnknownSpelling { kind: "enum member", - spelling: en.member.clone(), + spelling: en.member, locale: locale.to_string(), })? .1; return Ok(CanonicalValuePattern::Enum { domain, member }); } - if let Some(comparison) = &pattern.comparison { - let value = canonical_value(&comparison.value, catalog, locale)?; + if let Some(comparison) = pattern.comparison { + let value = canonical_value(*comparison.value, catalog, locale)?; if !matches!(value, CanonicalValuePattern::Number(_)) { return Err(RuleError::ComparisonNeedsNumber); } From 697c7a35c266371d7c8b45c84bb39f3a3a538f4f Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:30:47 +0800 Subject: [PATCH 21/33] refactor(analyzer): avoid symbol index clones --- .../wright-analyzer/src/canonical/symbols.rs | 56 +++++++++---------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/crates/wright-analyzer/src/canonical/symbols.rs b/crates/wright-analyzer/src/canonical/symbols.rs index b1836fa..998b7fa 100644 --- a/crates/wright-analyzer/src/canonical/symbols.rs +++ b/crates/wright-analyzer/src/canonical/symbols.rs @@ -184,18 +184,7 @@ impl SemanticIndex { references: Vec::new(), next_value_id: 0, }; - for symbol in index.symbols.clone() { - if let Some(span) = symbol.occurrence { - index.push( - symbol.id, - ReferenceKind::Declaration, - symbol.rule, - None, - None, - Some(span), - ); - } - } + index.add_missing_declarations(); for (rule, data) in program.rules.iter().enumerate() { index.walk_event(&data.event, rule, program); for (condition, value) in data.conditions.iter().enumerate() { @@ -231,28 +220,12 @@ impl SemanticIndex { symbol.occurrence = Some(span); } } - for symbol in index.symbols.clone() { - if let Some(span) = symbol.occurrence { - if !index.references.iter().any(|reference| { - reference.symbol == symbol.id && reference.kind == ReferenceKind::Declaration - }) { - index.push( - symbol.id, - ReferenceKind::Declaration, - symbol.rule, - None, - None, - Some(span), - ); - } - } - } - let symbols = index.symbols.clone(); + index.add_missing_declarations(); + let (symbols, references) = (&index.symbols, &mut index.references); let mut read_occurrences = HashMap::new(); - for reference in &mut index.references { + for reference in references { let symbol = symbols .get(reference.symbol.index()) - .cloned() .expect("reference symbol exists"); let span = match reference.kind { ReferenceKind::Declaration => symbol.occurrence, @@ -357,6 +330,27 @@ impl SemanticIndex { value, }); } + fn add_missing_declarations(&mut self) { + let declared = self + .references + .iter() + .filter(|reference| reference.kind == ReferenceKind::Declaration) + .map(|reference| reference.symbol) + .collect::>(); + self.references + .extend(self.symbols.iter().filter_map(|symbol| { + let span = symbol.occurrence?; + (!declared.contains(&symbol.id)).then_some(Reference { + symbol: symbol.id, + kind: ReferenceKind::Declaration, + span: Some(span), + occurrence: Some(span), + rule: symbol.rule, + action: None, + value: None, + }) + })); + } fn find_symbol(&self, kind: SymbolKind, name: &str) -> Option { self.symbols .iter() From 5bde6bce71372bc5eab0b93cf004d1129b98fbd4 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:36:30 +0800 Subject: [PATCH 22/33] refactor(analyzer): unify persistent object metadata --- crates/wright-analyzer/src/canonical/facts.rs | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/crates/wright-analyzer/src/canonical/facts.rs b/crates/wright-analyzer/src/canonical/facts.rs index e6aefdf..c60de31 100644 --- a/crates/wright-analyzer/src/canonical/facts.rs +++ b/crates/wright-analyzer/src/canonical/facts.rs @@ -9,27 +9,18 @@ pub(super) fn persistent_objects(program: &Program) -> Vec { let Action::Call { name, args } = action_data else { continue; }; - let Some(kind) = persistent_object_kind(name) else { - continue; - }; - let cleanup = match kind { - "hud-text" => "destroyHudText", - "in-world-text" => "destroyInWorldText", - "effect" => "destroyEffect", + let (kind, cleanup, reevaluation_index, identity) = match name.as_str() { + "createHudText" => ("hud-text", "destroyHudText", 9, "lastTextId"), + "createInWorldText" => ("in-world-text", "destroyInWorldText", 5, "lastTextId"), + "createEffect" => ("effect", "destroyEffect", 5, "lastCreatedEntity"), _ => continue, }; - let reevaluation_index = if kind == "hud-text" { 9 } else { 5 }; let reevaluation = args.get(reevaluation_index).and_then(|value| match value { Value::Enum { value_type, value } => { Some(json!({"domain": value_type, "mode": value})) } _ => None, }); - let identity = match kind { - "hud-text" | "in-world-text" => "lastTextId", - "effect" => "lastCreatedEntity", - _ => unreachable!(), - }; let identity_retained = data .actions .get(action + 1) @@ -51,15 +42,6 @@ pub(super) fn persistent_objects(program: &Program) -> Vec { output } -fn persistent_object_kind(name: &str) -> Option<&'static str> { - match name { - "createHudText" => Some("hud-text"), - "createInWorldText" => Some("in-world-text"), - "createEffect" => Some("effect"), - _ => None, - } -} - fn execution_scope(event: &Event) -> &'static str { match event { Event::Global => "global", From e8b7111bc79878846b41edb8a37be076af58ac02 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:41:26 +0800 Subject: [PATCH 23/33] refactor(cli): share completion status output --- crates/wright-cli/src/completion.rs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/crates/wright-cli/src/completion.rs b/crates/wright-cli/src/completion.rs index bf0323a..c2fb573 100644 --- a/crates/wright-cli/src/completion.rs +++ b/crates/wright-cli/src/completion.rs @@ -314,19 +314,14 @@ pub(crate) fn install_for_shell( fn print_status(status: &InstallStatus, shell: ShellArg, guidance: bool) { match status { - InstallStatus::Created(path) => { - println!( - "==> installed {} completion to {}", - shell.as_str(), - path.display() - ); - if guidance { - print_guidance(shell, path); - } - } - InstallStatus::Updated(path) => { + InstallStatus::Created(path) | InstallStatus::Updated(path) => { + let (verb, destination) = if matches!(status, InstallStatus::Created(_)) { + ("installed", "to") + } else { + ("updated", "in") + }; println!( - "==> updated {} completion in {}", + "==> {verb} {} completion {destination} {}", shell.as_str(), path.display() ); From e502f47c767e387db98b731b72e26134d5bdb01b Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:52:29 +0800 Subject: [PATCH 24/33] refactor(driver): share UTF-16 coordinate conversion --- crates/wright-driver/src/edit.rs | 19 ++++++++++++++ crates/wright-driver/src/provider_edit.rs | 31 ++++++----------------- crates/wright-language/src/document.rs | 19 ++------------ 3 files changed, 29 insertions(+), 40 deletions(-) diff --git a/crates/wright-driver/src/edit.rs b/crates/wright-driver/src/edit.rs index 8828a66..dd1631a 100644 --- a/crates/wright-driver/src/edit.rs +++ b/crates/wright-driver/src/edit.rs @@ -380,6 +380,25 @@ fn char_count(line: &str) -> usize { line.chars().count() } +/// Convert a UTF-16 column to a Rust character offset, rounding up within a surrogate pair. +pub fn utf16_offset_to_char(line: &str, utf16_offset: usize) -> usize { + let mut chars = 0usize; + let mut utf16 = 0usize; + for c in line.chars() { + if utf16 >= utf16_offset { + break; + } + utf16 += c.len_utf16(); + chars += 1; + } + chars +} + +/// Convert a Rust character offset to a UTF-16 column, clamping at the line end. +pub fn char_offset_to_utf16(line: &str, char_offset: usize) -> usize { + line.chars().take(char_offset).map(char::len_utf16).sum() +} + fn char_col(line: &str, col: u32) -> usize { let skip = col.saturating_sub(1) as usize; line.char_indices() diff --git a/crates/wright-driver/src/provider_edit.rs b/crates/wright-driver/src/provider_edit.rs index d68e666..ca59a1a 100644 --- a/crates/wright-driver/src/provider_edit.rs +++ b/crates/wright-driver/src/provider_edit.rs @@ -3,7 +3,10 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::diag::{Diagnostic, Stage}; -use crate::edit::{EditRange, EditTransaction, SourceEdit, SourcePreview}; +use crate::edit::{ + EditRange, EditTransaction, SourceEdit, SourcePreview, char_offset_to_utf16, + utf16_offset_to_char, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderMutation { @@ -394,31 +397,13 @@ fn column_to_position( } fn utf16_to_char_column(line: &str, units: u32) -> Option { - let mut acc = 0u32; - let mut col = 1u32; - for ch in line.chars() { - if acc == units { - return Some(col); - } - let len = ch.len_utf16() as u32; - if acc + len > units { - return None; - } - acc += len; - col += 1; - } - (acc == units).then_some(col) + let offset = utf16_offset_to_char(line, units as usize); + (char_offset_to_utf16(line, offset) == units as usize).then_some(offset as u32 + 1) } fn char_column_to_utf16(line: &str, column: u32) -> Option { - let mut acc = 0u32; - for (idx, ch) in line.chars().enumerate() { - if (idx as u32) + 1 == column { - return Some(acc); - } - acc += ch.len_utf16() as u32; - } - (column as usize == line.chars().count() + 1).then_some(acc) + let offset = column.checked_sub(1)? as usize; + (offset <= line.chars().count()).then(|| char_offset_to_utf16(line, offset) as u32) } fn edit_invalid_range(range: wright_lpp::Range) -> Diagnostic { diff --git a/crates/wright-language/src/document.rs b/crates/wright-language/src/document.rs index 4989b40..63a5542 100644 --- a/crates/wright-language/src/document.rs +++ b/crates/wright-language/src/document.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +pub use wright_driver::edit::{char_offset_to_utf16, utf16_offset_to_char}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Position { pub line: u32, @@ -154,23 +156,6 @@ pub fn utf16_len(s: &str) -> usize { s.chars().map(|c| c.len_utf16()).sum() } -pub fn utf16_offset_to_char(line: &str, utf16_offset: usize) -> usize { - let mut chars = 0usize; - let mut utf16 = 0usize; - for c in line.chars() { - if utf16 >= utf16_offset { - break; - } - utf16 += c.len_utf16(); - chars += 1; - } - chars -} - -pub fn char_offset_to_utf16(line: &str, char_offset: usize) -> usize { - line.chars().take(char_offset).map(|c| c.len_utf16()).sum() -} - pub fn span_to_range(span: &workshop_rs::source::Span, source: &str) -> Range { let sl = span.start.line.saturating_sub(1) as usize; let el = span.end.line.saturating_sub(1) as usize; From 741ae161bd2e23549175b70380fdaa669aff9851 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:55:33 +0800 Subject: [PATCH 25/33] refactor(driver): unify semantic span path mapping --- crates/wright-driver/src/service.rs | 4 +-- crates/wright-driver/src/session.rs | 48 ++++++++++------------------- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index 3f09af5..40543cb 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -382,7 +382,7 @@ impl<'a> ToolService<'a> { fn semantic_query_with_resolved_span_paths(&self, request: Request) -> ToolResponse { match self.semantic_query(request) { ToolResponse::Ok { mut result } => { - crate::session::resolve_finding_span_paths(&mut result, &self.loaded); + crate::session::resolve_span_paths(&mut result, &self.loaded); ToolResponse::Ok { result } } other => other, @@ -425,7 +425,7 @@ impl<'a> ToolService<'a> { Response::Ok { result } => result, Response::Error { .. } => serde_json::json!([]), }; - crate::session::resolve_finding_span_paths(&mut findings, &self.loaded); + crate::session::resolve_span_paths(&mut findings, &self.loaded); self.ok(json!({ "inputIdentity": self.loaded.input.identity, "rules": lint_rules.get("rules").cloned().unwrap_or_else(|| json!([])), diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index ee5b90f..ba4bdb4 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -584,7 +584,7 @@ impl CompilerSession { } let mut facts = semantic_facts(&service); if loaded.provenance == Provenance::Mapped { - resolve_nested_span_paths(&mut facts, &loaded); + resolve_span_paths(&mut facts, &loaded); } AnalyzeResult { program, facts } }, @@ -655,7 +655,7 @@ impl CompilerSession { ProgressUnit::Rules, )); let mut findings = service_response(&service, &Request::GetFindings); - resolve_finding_span_paths(&mut findings, &loaded); + resolve_span_paths(&mut findings, &loaded); let (rules, config, skipped) = if let serde_json::Value::Object(mut object) = lint_rules { ( @@ -985,48 +985,34 @@ fn semantic_facts(service: &SemanticService<'_>) -> serde_json::Value { }) } -/// Add the resolved `path` to every finding span. +/// Add the resolved `path` to every semantic `span` in a JSON result. /// /// File 0 is the main input and resolves root-relative to the include root /// (`--root`, defaulting to the input's directory); other files resolve from /// the retained frontend file registry. `` is the fallback when no /// registry entry resolves (matching the [`span_from_json`] convention), and /// stdin inputs fall back to their display identity (``). -pub(crate) fn resolve_finding_span_paths(findings: &mut serde_json::Value, loaded: &Loaded) { - let Some(list) = findings.as_array_mut() else { - return; - }; - for finding in list { - let Some(span) = finding.get_mut("span") else { - continue; - }; - if span.is_object() { - let file = span.get("file").and_then(serde_json::Value::as_u64); - span["path"] = serde_json::Value::String(span_path(file, loaded)); - } - } -} - -/// Add the resolved `path` to every span object nested anywhere in `value`. -fn resolve_nested_span_paths(value: &mut serde_json::Value, loaded: &Loaded) { +/// Add the resolved `path` to every `span` object nested anywhere in `value`. +pub(crate) fn resolve_span_paths(value: &mut serde_json::Value, loaded: &Loaded) { match value { serde_json::Value::Object(object) => { - if ["file", "start", "end"] - .iter() - .all(|key| object.contains_key(*key)) + if let Some(span) = object + .get_mut("span") + .and_then(serde_json::Value::as_object_mut) { - let file = object.get("file").and_then(serde_json::Value::as_u64); - let path = span_path(file, loaded); - object.insert("path".to_string(), serde_json::Value::String(path)); - } else { - object - .values_mut() - .for_each(|v| resolve_nested_span_paths(v, loaded)); + let file = span.get("file").and_then(serde_json::Value::as_u64); + span.insert( + "path".to_string(), + serde_json::Value::String(span_path(file, loaded)), + ); } + object + .values_mut() + .for_each(|value| resolve_span_paths(value, loaded)); } serde_json::Value::Array(items) => items .iter_mut() - .for_each(|v| resolve_nested_span_paths(v, loaded)), + .for_each(|value| resolve_span_paths(value, loaded)), _ => {} } } From 45028d87499a4c4831e5d7ea5957c394428f76ef Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:03:38 +0800 Subject: [PATCH 26/33] refactor(driver): unify input and provider target kinds --- crates/wright-driver/src/session.rs | 8 +++----- crates/wright-driver/src/source_provider.rs | 13 +++++-------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index ba4bdb4..e7cd87d 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -265,11 +265,9 @@ impl CompilerSession { .path .clone() .unwrap_or_else(|| Path::new("").to_path_buf()); - let target = match resolved.target { - InputTarget::File => SourceTarget::new(language, entry, resolved.cwd.clone()), - InputTarget::Directory => { - SourceTarget::directory(language, entry, resolved.cwd.clone()) - } + let target = SourceTarget { + kind: resolved.target, + ..SourceTarget::new(language, entry, resolved.cwd.clone()) } .with_project_root(resolved.root.clone()); if self.source_provider.is_none() { diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs index bfe2a5c..2b950d6 100644 --- a/crates/wright-driver/src/source_provider.rs +++ b/crates/wright-driver/src/source_provider.rs @@ -5,17 +5,14 @@ use workshop_rs::program::{MAPPED_TEXT_V1, TEXT_V1}; use workshop_rs::{MappedText, SourceMap}; use crate::diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; +use crate::input::InputTarget; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SourceLanguage { Opy, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SourceTargetKind { - File, - Directory, -} +pub type SourceTargetKind = InputTarget; impl SourceLanguage { pub const fn as_str(self) -> &'static str { @@ -68,7 +65,7 @@ impl SourceTarget { Self { language, entry, - kind: SourceTargetKind::File, + kind: InputTarget::File, cwd, project_root: None, } @@ -80,7 +77,7 @@ impl SourceTarget { cwd: impl Into, ) -> Self { let mut target = Self::new(language, directory, cwd); - target.kind = SourceTargetKind::Directory; + target.kind = InputTarget::Directory; target } @@ -94,7 +91,7 @@ impl SourceTarget { } pub fn is_directory(&self) -> bool { - self.kind == SourceTargetKind::Directory + self.kind == InputTarget::Directory } } From 6490e70f1fcdfa44d27de5fe3701aff5565a0699 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:29:55 +0800 Subject: [PATCH 27/33] refactor: remove redundant transaction and transform wrappers --- crates/wright-driver/src/edit.rs | 45 +++++++++++-------------- crates/wright-transform/src/pipeline.rs | 11 ++---- 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/crates/wright-driver/src/edit.rs b/crates/wright-driver/src/edit.rs index dd1631a..31b4137 100644 --- a/crates/wright-driver/src/edit.rs +++ b/crates/wright-driver/src/edit.rs @@ -97,7 +97,24 @@ impl EditTransaction { &self, sources: &BTreeMap, ) -> Result, Diagnostic> { - apply_transaction(sources, self) + let mut grouped: BTreeMap<&str, Vec<&SourceEdit>> = BTreeMap::new(); + for edit in &self.edits { + grouped.entry(&edit.source).or_default().push(edit); + } + let mut previews = Vec::new(); + for (source, edits) in grouped { + let original = sources.get(source).expect("precondition verified"); + let mut new_text = original.clone(); + for edit in edits.iter().rev() { + new_text = apply_edit(&new_text, edit)?; + } + previews.push(SourcePreview { + source: source.to_string(), + source_identity: crate::input_identity(&new_text), + new_text, + }); + } + Ok(previews) } } @@ -172,7 +189,7 @@ pub fn validate_transaction( return refusal(diagnostics); } - let previews = match apply_transaction(sources, transaction) { + let previews = match transaction.apply(sources) { Ok(previews) => previews, Err(diagnostic) => { diagnostics.push(diagnostic); @@ -303,30 +320,6 @@ pub(crate) fn source_precondition( }) } -fn apply_transaction( - sources: &BTreeMap, - transaction: &EditTransaction, -) -> Result, Diagnostic> { - let mut grouped: BTreeMap<&str, Vec<&SourceEdit>> = BTreeMap::new(); - for edit in &transaction.edits { - grouped.entry(&edit.source).or_default().push(edit); - } - let mut previews = Vec::new(); - for (source, edits) in grouped { - let original = sources.get(source).expect("precondition verified"); - let mut new_text = original.clone(); - for edit in edits.iter().rev() { - new_text = apply_edit(&new_text, edit)?; - } - previews.push(SourcePreview { - source: source.to_string(), - source_identity: crate::input_identity(&new_text), - new_text, - }); - } - Ok(previews) -} - fn apply_edit(source: &str, edit: &SourceEdit) -> Result { let lines: Vec<&str> = source.split('\n').collect(); let (sl, sc, el, ec) = ( diff --git a/crates/wright-transform/src/pipeline.rs b/crates/wright-transform/src/pipeline.rs index 445f12b..ea2f03c 100644 --- a/crates/wright-transform/src/pipeline.rs +++ b/crates/wright-transform/src/pipeline.rs @@ -24,15 +24,6 @@ pub struct PassResult { pub fn run( program: &mut workshop_rs::Program, profile: Profile, -) -> Result, workshop_rs::WorkshopError> { - run_canonical(program, profile) -} - -/// Run the semantics-preserving transform profile over the canonical public -/// Workshop program model. -pub fn run_canonical( - program: &mut workshop_rs::Program, - profile: Profile, ) -> Result, workshop_rs::WorkshopError> { program.validate()?; if profile == Profile::Off { @@ -78,6 +69,8 @@ pub fn run_canonical( }]) } +pub use self::run as run_canonical; + fn fold_action_once(action: &mut workshop_rs::Action, nodes: &mut usize) -> usize { *nodes += 1; use workshop_rs::Action; From 236f3bb5b6d67623e2974dc818a70fefe8e6390d Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:47:45 +0800 Subject: [PATCH 28/33] refactor(driver): reuse loaded locale resolution --- crates/wright-driver/src/service.rs | 8 +------- crates/wright-driver/src/session.rs | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index 40543cb..a3da464 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -475,13 +475,7 @@ impl<'a> ToolService<'a> { fn cost_estimate(&self) -> serde_json::Value { let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); - let locale = self - .loaded - .origin - .locale - .as_deref() - .map(workshop_rs::catalog::Locale::new) - .unwrap_or_else(|| workshop_rs::catalog::Locale::new("en-US")); + let locale = CompilerSession::locale_for(&self.loaded); let text = workshop_rs::emitter::emit(&self.loaded.program, &catalog, &locale).unwrap_or_default(); let waits = self diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index e7cd87d..27cacaa 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -757,7 +757,7 @@ impl CompilerSession { Ok(result.source) } - fn locale_for(loaded: &Loaded) -> workshop_rs::catalog::Locale { + pub(crate) fn locale_for(loaded: &Loaded) -> workshop_rs::catalog::Locale { loaded .origin .locale From 284da18a7ee253b0b3844dcffbfe21678dd6046f Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:55:03 +0800 Subject: [PATCH 29/33] refactor(lpp): name unused capability inputs --- crates/wright-lpp/src/provider.rs | 40 +++++++++++++------------------ 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/crates/wright-lpp/src/provider.rs b/crates/wright-lpp/src/provider.rs index f7c314c..3d34c91 100644 --- a/crates/wright-lpp/src/provider.rs +++ b/crates/wright-lpp/src/provider.rs @@ -104,9 +104,8 @@ pub trait LanguageProvider { /// Initialize with LPP 1.4 for `lpp/compile` artifact format negotiation. fn initialize_artifact_negotiation( &mut self, - client_info: Option<&ClientInfo>, + _client_info: Option<&ClientInfo>, ) -> Result { - let _ = client_info; Err(ProviderError::ProtocolVersionMismatch { supported: Vec::new(), message: "the provider client does not support LPP 1.4 artifact negotiation" @@ -127,22 +126,20 @@ pub trait LanguageProvider { /// `lpp/check` over a provider-owned filesystem project entry. fn check_entry( &mut self, - entry: &ProjectEntry, - project_root: Option<&str>, - locale: Option<&str>, + _entry: &ProjectEntry, + _project_root: Option<&str>, + _locale: Option<&str>, ) -> Result { - let _ = (entry, project_root, locale); capability_unavailable("projectLoading", "lpp/check") } /// `lpp/check` over an LPP 1.2 file or directory target. fn check_target( &mut self, - target: &ProjectEntry, - project_root: Option<&str>, - locale: Option<&str>, + _target: &ProjectEntry, + _project_root: Option<&str>, + _locale: Option<&str>, ) -> Result { - let _ = (target, project_root, locale); capability_unavailable("projectLoading", "lpp/check") } @@ -157,22 +154,20 @@ pub trait LanguageProvider { /// `lpp/compile` over a provider-owned filesystem project entry. fn compile_entry( &mut self, - entry: &ProjectEntry, - project_root: Option<&str>, - locale: Option<&str>, + _entry: &ProjectEntry, + _project_root: Option<&str>, + _locale: Option<&str>, ) -> Result { - let _ = (entry, project_root, locale); capability_unavailable("projectLoading", "lpp/compile") } /// `lpp/compile` over an LPP 1.2 file or directory target. fn compile_target( &mut self, - target: &ProjectEntry, - project_root: Option<&str>, - locale: Option<&str>, + _target: &ProjectEntry, + _project_root: Option<&str>, + _locale: Option<&str>, ) -> Result { - let _ = (target, project_root, locale); capability_unavailable("projectLoading", "lpp/compile") } @@ -180,12 +175,11 @@ pub trait LanguageProvider { /// stating the artifact formats the client accepts, most preferred first. fn compile_target_accepting( &mut self, - target: &ProjectEntry, - project_root: Option<&str>, - locale: Option<&str>, - accepted_artifact_formats: &[&str], + _target: &ProjectEntry, + _project_root: Option<&str>, + _locale: Option<&str>, + _accepted_artifact_formats: &[&str], ) -> Result { - let _ = (target, project_root, locale, accepted_artifact_formats); capability_unavailable("projectLoading", "lpp/compile") } From 1b23f7b4eacd14eb4f57de80023e6b242a51df6f Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:08:48 +0800 Subject: [PATCH 30/33] refactor(driver): reuse session catalog for estimates --- crates/wright-driver/src/service.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/wright-driver/src/service.rs b/crates/wright-driver/src/service.rs index a3da464..6bd060e 100644 --- a/crates/wright-driver/src/service.rs +++ b/crates/wright-driver/src/service.rs @@ -474,10 +474,10 @@ impl<'a> ToolService<'a> { } fn cost_estimate(&self) -> serde_json::Value { - let catalog = workshop_rs::catalog::Catalog::builtin().expect("catalog loads"); let locale = CompilerSession::locale_for(&self.loaded); let text = - workshop_rs::emitter::emit(&self.loaded.program, &catalog, &locale).unwrap_or_default(); + workshop_rs::emitter::emit(&self.loaded.program, self.session.catalog(), &locale) + .unwrap_or_default(); let waits = self .loaded .program From b7eb970254c9b029e43f290faf4214e76f23fbdf Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:22:15 +0800 Subject: [PATCH 31/33] refactor(analyzer): share serde boolean default --- crates/wright-analyzer/src/declarative.rs | 2 +- crates/wright-analyzer/src/registry.rs | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/wright-analyzer/src/declarative.rs b/crates/wright-analyzer/src/declarative.rs index 1b6ed2e..f3a3dfd 100644 --- a/crates/wright-analyzer/src/declarative.rs +++ b/crates/wright-analyzer/src/declarative.rs @@ -748,7 +748,7 @@ fn default_locale() -> String { DEFAULT_LOCALE.to_string() } -fn default_true() -> bool { +pub(crate) fn default_true() -> bool { true } diff --git a/crates/wright-analyzer/src/registry.rs b/crates/wright-analyzer/src/registry.rs index 1cd5bf6..0e46ef9 100644 --- a/crates/wright-analyzer/src/registry.rs +++ b/crates/wright-analyzer/src/registry.rs @@ -54,7 +54,7 @@ pub struct RuleMeta { #[serde(deny_unknown_fields)] pub struct RuleConfig { /// When `false` the rule is skipped entirely and produces no findings. - #[serde(default = "default_true")] + #[serde(default = "crate::declarative::default_true")] pub enabled: bool, /// When `Some`, replaces the rule's [`RuleMeta::default_severity`] in /// every finding produced during this run. @@ -80,10 +80,6 @@ impl Default for RuleConfig { } } -fn default_true() -> bool { - true -} - /// The intentionally small option surface shared by declarative rules. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] From ffcb9a3e6364c5ca346a6d75ea550a7e1642c8b0 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:38:13 +0800 Subject: [PATCH 32/33] refactor(driver): isolate semantic workflows --- crates/wright-driver/src/session.rs | 276 +------------------ crates/wright-driver/src/session/semantic.rs | 273 ++++++++++++++++++ 2 files changed, 280 insertions(+), 269 deletions(-) create mode 100644 crates/wright-driver/src/session/semantic.rs diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 27cacaa..b3f6d92 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -3,13 +3,16 @@ //! without changing callers. Every workflow returns a typed [`Envelope`] //! whose JSON serialization is the machine-readable CLI contract. +mod semantic; + +pub(crate) use semantic::resolve_span_paths; use std::path::Path; use std::sync::Arc; use workshop_rs::Program; use wright_analyzer::canonical::SemanticService; use wright_analyzer::registry::{LintConfig, LintRegistry}; -use wright_analyzer::service::{Origin as ServiceOrigin, Request}; +use wright_analyzer::service::Origin as ServiceOrigin; use crate::WorkshopProvider; use crate::config::{InputSpec, SessionConfig, SourceKind}; @@ -19,10 +22,10 @@ use crate::diag::{ use crate::input::{self, InputTarget, ResolvedInput}; use crate::input_identity; use crate::opy_provider; -use crate::progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; +use crate::progress::{ProgressEvent, ProgressObserver, ProgressPhase}; use crate::result::{ - AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, - Envelope, InspectResult, LintResult, exit_code_from, version_info, + CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, Envelope, + exit_code_from, version_info, }; use crate::source_provider::{ SourceBackend, SourceLanguage, SourceProvenance, SourceProvider, SourceProviderError, @@ -569,123 +572,6 @@ impl CompilerSession { ) } - pub fn analyze(&mut self) -> Envelope { - self.with_loaded( - "analyze", - |session| session.load_with_operation(ProviderOperation::Compile), - |session, loaded| { - let service = session.service(&loaded); - session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); - let mut program = service_response(&service, &Request::Program); - if let serde_json::Value::Object(object) = &mut program { - object.remove("findings"); - } - let mut facts = semantic_facts(&service); - if loaded.provenance == Provenance::Mapped { - resolve_span_paths(&mut facts, &loaded); - } - AnalyzeResult { program, facts } - }, - ) - } - - /// `inspect`: load and produce the structural/semantic program model. - pub fn inspect(&mut self) -> Envelope { - self.with_loaded( - "inspect", - |session| session.load(), - |session, loaded| { - let service = session.service(&loaded); - session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); - let program = service_response(&service, &Request::Program); - let rules = service_response(&service, &Request::ListRules); - let symbols = service_response(&service, &Request::ListSymbols { kind: None }); - let references = serde_json::Value::Array( - symbols - .as_array() - .map(|list| { - list.iter() - .filter_map(|s| s.get("id").and_then(serde_json::Value::as_u64)) - .map(|id| { - service_response( - &service, - &Request::FindReferences { symbol: id as u32 }, - ) - }) - .collect() - }) - .unwrap_or_default(), - ); - InspectResult { - program, - rules, - symbols, - references, - } - }, - ) - } - - /// `lint`: load and produce the source identity, program summary, rule - /// metadata, effective configuration, and findings (#98). - /// - /// Lint rule findings are reported in `result.findings`; frontend and - /// Workshop semantic-completeness diagnostics remain in the envelope. - /// Rule enable/disable and severity come from `self.config.lint`, the same - /// configuration the CLI flags and programmatic consumers set. - pub fn lint(&mut self) -> Envelope { - self.with_loaded( - "lint", - |session| session.load_with_operation(ProviderOperation::Compile), - |session, loaded| { - session.attach_workshop_completeness(&loaded); - let service = session.service_with(&loaded, session.config.lint.clone()); - session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); - let program = service_response(&service, &Request::Program); - let lint_rules = service_response(&service, &Request::LintRules); - let lint_rule_count = lint_rules - .pointer("/rules") - .and_then(serde_json::Value::as_array) - .map_or(0, Vec::len); - session.progress(ProgressEvent::with_count( - ProgressPhase::Linting, - lint_rule_count, - ProgressUnit::Rules, - )); - let mut findings = service_response(&service, &Request::GetFindings); - resolve_span_paths(&mut findings, &loaded); - let (rules, config, skipped) = - if let serde_json::Value::Object(mut object) = lint_rules { - ( - object - .remove("rules") - .unwrap_or_else(|| serde_json::json!([])), - object - .remove("config") - .unwrap_or_else(|| serde_json::json!({})), - object - .remove("skipped") - .unwrap_or_else(|| serde_json::json!([])), - ) - } else { - ( - serde_json::json!([]), - serde_json::json!({}), - serde_json::json!([]), - ) - }; - LintResult { - input_identity: loaded.input.identity.clone(), - program, - rules, - config, - findings, - skipped, - } - }, - ) - } - pub fn convert(&mut self, target: ConvertTarget) -> Envelope { self.with_loaded( "convert", @@ -900,154 +786,6 @@ fn apply_profile( Ok(()) } -/// Extract the `result` payload of a semantic-service request as JSON. -fn service_response(service: &SemanticService<'_>, request: &Request) -> serde_json::Value { - match service.handle(request) { - wright_analyzer::service::Response::Ok { result } => result, - wright_analyzer::service::Response::Error { .. } => serde_json::Value::Null, - } -} - -/// Build the initial `analyze` report from existing semantic query surfaces. -/// -/// Keeping this composition here makes the product boundary explicit: the -/// report contains symbol usage and CFG measurements, while lint rules remain -/// owned by `LintRegistry` and are only exposed by `lint`/`findings` queries. -fn semantic_facts(service: &SemanticService<'_>) -> serde_json::Value { - let symbols = service_response(service, &Request::ListSymbols { kind: None }) - .as_array() - .map(|symbols| { - symbols - .iter() - .map(|s| { - let id = s - .get("id") - .and_then(serde_json::Value::as_u64) - .unwrap_or_default() as u32; - let usage = service_response(service, &Request::GetUsage { symbol: id }); - serde_json::json!({ - "id": s["id"], - "kind": s["kind"], - "name": s["name"], - "span": s.get("span").cloned().unwrap_or(serde_json::Value::Null), - "usage": usage, - }) - }) - .collect::>() - }) - .unwrap_or_default(); - - let rules = service_response(service, &Request::ListRules) - .as_array() - .map(|rules| { - rules - .iter() - .map(|r| { - let id = r - .get("id") - .and_then(serde_json::Value::as_u64) - .unwrap_or_default() as u32; - let cfg = service_response(service, &Request::GetCfg { rule: id }); - let blocks = cfg["blocks"].as_array().cloned().unwrap_or_default(); - let edge_count = blocks - .iter() - .map(|b| b["successors"].as_array().map_or(0, Vec::len)) - .sum::(); - let wait_blocks = blocks - .iter() - .filter(|b| b["waits"].as_bool().unwrap_or(false)) - .count(); - let loop_blocks = blocks - .iter() - .filter(|b| matches!(b["kind"].as_str(), Some("while" | "for"))) - .count(); - serde_json::json!({ - "id": r["id"], - "name": r["name"], - "span": r.get("span").cloned().unwrap_or(serde_json::Value::Null), - "controlFlow": { - "blocks": blocks.len(), - "edges": edge_count, - "loopBlocks": loop_blocks, - "waitBlocks": wait_blocks, - }, - }) - }) - .collect::>() - }) - .unwrap_or_default(); - - serde_json::json!({ - "symbols": symbols, - "rules": rules, - }) -} - -/// Add the resolved `path` to every semantic `span` in a JSON result. -/// -/// File 0 is the main input and resolves root-relative to the include root -/// (`--root`, defaulting to the input's directory); other files resolve from -/// the retained frontend file registry. `` is the fallback when no -/// registry entry resolves (matching the [`span_from_json`] convention), and -/// stdin inputs fall back to their display identity (``). -/// Add the resolved `path` to every `span` object nested anywhere in `value`. -pub(crate) fn resolve_span_paths(value: &mut serde_json::Value, loaded: &Loaded) { - match value { - serde_json::Value::Object(object) => { - if let Some(span) = object - .get_mut("span") - .and_then(serde_json::Value::as_object_mut) - { - let file = span.get("file").and_then(serde_json::Value::as_u64); - span.insert( - "path".to_string(), - serde_json::Value::String(span_path(file, loaded)), - ); - } - object - .values_mut() - .for_each(|value| resolve_span_paths(value, loaded)); - } - serde_json::Value::Array(items) => items - .iter_mut() - .for_each(|value| resolve_span_paths(value, loaded)), - _ => {} - } -} - -fn span_path(file: Option, loaded: &Loaded) -> String { - if loaded.provenance == Provenance::Unmapped { - "".to_string() - } else if loaded.provenance == Provenance::Mapped { - // Every mapped file is an authored source; file 0 is not the input. - match file.and_then(|file| loaded.source_files.get(file as usize)) { - Some(source) => root_relative(Some(Path::new(source)), &loaded.input.root) - .unwrap_or_else(|| source.clone()), - None => "".to_string(), - } - } else if let Some(file) = file { - if let Some(source) = loaded.source_files.get(file as usize) { - let p = if file == 0 { - loaded - .input - .path - .as_deref() - .or_else(|| Some(Path::new(source))) - } else if Path::new(source).is_absolute() { - Some(Path::new(source)) - } else { - None - }; - p.and_then(|path| root_relative(Some(path), &loaded.input.root)) - .unwrap_or_else(|| source.clone()) - } else { - format!("") - } - } else { - loaded.input.display.clone() - } -} - /// The root-relative form of `path` when it sits under `root`. /// /// `canonicalize` makes both paths absolute and resolves symlinks, so diff --git a/crates/wright-driver/src/session/semantic.rs b/crates/wright-driver/src/session/semantic.rs new file mode 100644 index 0000000..8f38901 --- /dev/null +++ b/crates/wright-driver/src/session/semantic.rs @@ -0,0 +1,273 @@ +use std::path::Path; + +use super::{CompilerSession, Loaded, Provenance, ProviderOperation, root_relative}; +use wright_analyzer::canonical::SemanticService; +use wright_analyzer::service::Request; + +use crate::progress::{ProgressEvent, ProgressPhase, ProgressUnit}; +use crate::result::{AnalyzeResult, Envelope, InspectResult, LintResult}; + +/// Extract the `result` payload of a semantic-service request as JSON. +fn service_response(service: &SemanticService<'_>, request: &Request) -> serde_json::Value { + match service.handle(request) { + wright_analyzer::service::Response::Ok { result } => result, + wright_analyzer::service::Response::Error { .. } => serde_json::Value::Null, + } +} + +/// Build the initial `analyze` report from existing semantic query surfaces. +/// +/// Keeping this composition here makes the product boundary explicit: the +/// report contains symbol usage and CFG measurements, while lint rules remain +/// owned by `LintRegistry` and are only exposed by `lint`/`findings` queries. +fn semantic_facts(service: &SemanticService<'_>) -> serde_json::Value { + let symbols = service_response(service, &Request::ListSymbols { kind: None }) + .as_array() + .map(|symbols| { + symbols + .iter() + .map(|s| { + let id = s + .get("id") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as u32; + let usage = service_response(service, &Request::GetUsage { symbol: id }); + serde_json::json!({ + "id": s["id"], + "kind": s["kind"], + "name": s["name"], + "span": s.get("span").cloned().unwrap_or(serde_json::Value::Null), + "usage": usage, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + + let rules = service_response(service, &Request::ListRules) + .as_array() + .map(|rules| { + rules + .iter() + .map(|r| { + let id = r + .get("id") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as u32; + let cfg = service_response(service, &Request::GetCfg { rule: id }); + let blocks = cfg["blocks"].as_array().cloned().unwrap_or_default(); + let edge_count = blocks + .iter() + .map(|b| b["successors"].as_array().map_or(0, Vec::len)) + .sum::(); + let wait_blocks = blocks + .iter() + .filter(|b| b["waits"].as_bool().unwrap_or(false)) + .count(); + let loop_blocks = blocks + .iter() + .filter(|b| matches!(b["kind"].as_str(), Some("while" | "for"))) + .count(); + serde_json::json!({ + "id": r["id"], + "name": r["name"], + "span": r.get("span").cloned().unwrap_or(serde_json::Value::Null), + "controlFlow": { + "blocks": blocks.len(), + "edges": edge_count, + "loopBlocks": loop_blocks, + "waitBlocks": wait_blocks, + }, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + + serde_json::json!({ + "symbols": symbols, + "rules": rules, + }) +} + +/// Add the resolved `path` to every semantic `span` in a JSON result. +/// +/// File 0 is the main input and resolves root-relative to the include root +/// (`--root`, defaulting to the input's directory); other files resolve from +/// the retained frontend file registry. `` is the fallback when no +/// registry entry resolves, and stdin inputs fall back to ``. +pub(crate) fn resolve_span_paths(value: &mut serde_json::Value, loaded: &Loaded) { + match value { + serde_json::Value::Object(object) => { + if let Some(span) = object + .get_mut("span") + .and_then(serde_json::Value::as_object_mut) + { + let file = span.get("file").and_then(serde_json::Value::as_u64); + span.insert( + "path".to_string(), + serde_json::Value::String(span_path(file, loaded)), + ); + } + object + .values_mut() + .for_each(|value| resolve_span_paths(value, loaded)); + } + serde_json::Value::Array(items) => items + .iter_mut() + .for_each(|value| resolve_span_paths(value, loaded)), + _ => {} + } +} + +fn span_path(file: Option, loaded: &Loaded) -> String { + if loaded.provenance == Provenance::Unmapped { + "".to_string() + } else if loaded.provenance == Provenance::Mapped { + // Every mapped file is an authored source; file 0 is not the input. + match file.and_then(|file| loaded.source_files.get(file as usize)) { + Some(source) => root_relative(Some(Path::new(source)), &loaded.input.root) + .unwrap_or_else(|| source.clone()), + None => "".to_string(), + } + } else if let Some(file) = file { + if let Some(source) = loaded.source_files.get(file as usize) { + let p = if file == 0 { + loaded + .input + .path + .as_deref() + .or_else(|| Some(Path::new(source))) + } else if Path::new(source).is_absolute() { + Some(Path::new(source)) + } else { + None + }; + p.and_then(|path| root_relative(Some(path), &loaded.input.root)) + .unwrap_or_else(|| source.clone()) + } else { + format!("") + } + } else { + loaded.input.display.clone() + } +} + +impl CompilerSession { + pub fn analyze(&mut self) -> Envelope { + self.with_loaded( + "analyze", + |session| session.load_with_operation(ProviderOperation::Compile), + |session, loaded| { + let service = session.service(&loaded); + session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); + let mut program = service_response(&service, &Request::Program); + if let serde_json::Value::Object(object) = &mut program { + object.remove("findings"); + } + let mut facts = semantic_facts(&service); + if loaded.provenance == Provenance::Mapped { + resolve_span_paths(&mut facts, &loaded); + } + AnalyzeResult { program, facts } + }, + ) + } + + /// `inspect`: load and produce the structural/semantic program model. + pub fn inspect(&mut self) -> Envelope { + self.with_loaded( + "inspect", + |session| session.load(), + |session, loaded| { + let service = session.service(&loaded); + session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); + let program = service_response(&service, &Request::Program); + let rules = service_response(&service, &Request::ListRules); + let symbols = service_response(&service, &Request::ListSymbols { kind: None }); + let references = serde_json::Value::Array( + symbols + .as_array() + .map(|list| { + list.iter() + .filter_map(|s| s.get("id").and_then(serde_json::Value::as_u64)) + .map(|id| { + service_response( + &service, + &Request::FindReferences { symbol: id as u32 }, + ) + }) + .collect() + }) + .unwrap_or_default(), + ); + InspectResult { + program, + rules, + symbols, + references, + } + }, + ) + } + + /// `lint`: load and produce the source identity, program summary, rule + /// metadata, effective configuration, and findings (#98). + /// + /// Lint rule findings are reported in `result.findings`; frontend and + /// Workshop semantic-completeness diagnostics remain in the envelope. + /// Rule enable/disable and severity come from `self.config.lint`, the same + /// configuration the CLI flags and programmatic consumers set. + pub fn lint(&mut self) -> Envelope { + self.with_loaded( + "lint", + |session| session.load_with_operation(ProviderOperation::Compile), + |session, loaded| { + session.attach_workshop_completeness(&loaded); + let service = session.service_with(&loaded, session.config.lint.clone()); + session.progress(ProgressEvent::new(ProgressPhase::SemanticAnalysis)); + let program = service_response(&service, &Request::Program); + let lint_rules = service_response(&service, &Request::LintRules); + let lint_rule_count = lint_rules + .pointer("/rules") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); + session.progress(ProgressEvent::with_count( + ProgressPhase::Linting, + lint_rule_count, + ProgressUnit::Rules, + )); + let mut findings = service_response(&service, &Request::GetFindings); + resolve_span_paths(&mut findings, &loaded); + let (rules, config, skipped) = + if let serde_json::Value::Object(mut object) = lint_rules { + ( + object + .remove("rules") + .unwrap_or_else(|| serde_json::json!([])), + object + .remove("config") + .unwrap_or_else(|| serde_json::json!({})), + object + .remove("skipped") + .unwrap_or_else(|| serde_json::json!([])), + ) + } else { + ( + serde_json::json!([]), + serde_json::json!({}), + serde_json::json!([]), + ) + }; + LintResult { + input_identity: loaded.input.identity.clone(), + program, + rules, + config, + findings, + skipped, + } + }, + ) + } +} From bce3c7feee3dd7ff465a56a909185d7754300b5b Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:37:38 +0800 Subject: [PATCH 33/33] fix(analyzer): support Rust 1.85 syntax --- crates/wright-analyzer/src/canonical/analysis.rs | 8 ++++---- crates/wright-analyzer/src/canonical/symbols.rs | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/wright-analyzer/src/canonical/analysis.rs b/crates/wright-analyzer/src/canonical/analysis.rs index d63d64e..a795709 100644 --- a/crates/wright-analyzer/src/canonical/analysis.rs +++ b/crates/wright-analyzer/src/canonical/analysis.rs @@ -246,10 +246,10 @@ fn ongoing_condition_findings( fn collect_expensive_values<'a>(value: &'a Value, out: &mut Vec<&'a Value>) { visit_value_tree(value, None, &mut |value, _| { - if let Value::Call { name, .. } = value - && ["distance", "raycast", "isInLoS"].contains(&name.as_str()) - { - out.push(value); + if let Value::Call { name, .. } = value { + if ["distance", "raycast", "isInLoS"].contains(&name.as_str()) { + out.push(value); + } } 0 }); diff --git a/crates/wright-analyzer/src/canonical/symbols.rs b/crates/wright-analyzer/src/canonical/symbols.rs index 998b7fa..edc081d 100644 --- a/crates/wright-analyzer/src/canonical/symbols.rs +++ b/crates/wright-analyzer/src/canonical/symbols.rs @@ -209,15 +209,15 @@ impl SemanticIndex { ) -> Self { let mut index = Self::build(program); for symbol in &mut index.symbols { - if symbol.kind != SymbolKind::Rule - && let Some(span) = declaration_span_in_sources( + if symbol.kind != SymbolKind::Rule { + if let Some(span) = declaration_span_in_sources( sources, &[symbol.kind.declaration_prefix()], &symbol.name, - ) - { - symbol.span = Some(declaration_line_span(sources, span)); - symbol.occurrence = Some(span); + ) { + symbol.span = Some(declaration_line_span(sources, span)); + symbol.occurrence = Some(span); + } } } index.add_missing_declarations();