1use std::collections::{HashMap, HashSet};
41use std::path::{Path, PathBuf};
42
43use bynk_check::index::{ProjectIndex, SymbolKind};
44use bynk_syntax::ast::{ConsumesDecl, SourceUnit, UsesDecl};
45use bynk_syntax::lexer::tokenize;
46use bynk_syntax::parser::parse_unit_with_recovery;
47use bynk_syntax::span::Span;
48use tower_lsp::lsp_types::*;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Mode {
53 Full,
55 SortOnly,
58}
59
60pub struct Project<'a> {
64 pub index: &'a ProjectIndex,
65 pub snapshots: &'a HashMap<PathBuf, String>,
66 pub unit_sources: &'a HashMap<String, Vec<PathBuf>>,
67}
68
69pub fn organise(text: &str, rel: &Path, mode: Mode, project: &Project) -> Option<(Span, String)> {
73 let unit = parse_clean(text)?;
74 let (unit_name, uses, consumes) = header(&unit)?;
75 if uses.is_empty() && consumes.is_empty() {
76 return None;
77 }
78 let usage = match mode {
79 Mode::Full => Some(Usage::of(&unit_name, rel, text, project)),
80 Mode::SortOnly => None,
81 };
82
83 let mut clauses: Vec<Clause> = Vec::new();
85 for u in uses {
86 clauses.push(Clause::new(text, u.span, Kind::Uses(u))?);
87 }
88 for c in consumes {
89 clauses.push(Clause::new(text, c.span, Kind::Consumes(c))?);
90 }
91 clauses.sort_by_key(|c| c.extent.start);
92 for pair in clauses.windows(2) {
93 if pair[1].extent.start < pair[0].extent.end
94 || !text[pair[0].extent.end..pair[1].extent.start]
95 .chars()
96 .all(char::is_whitespace)
97 {
98 return None;
99 }
100 }
101 let region = Span::new(clauses.first()?.extent.start, clauses.last()?.extent.end);
102
103 let mut kept: Vec<Rendered> = Vec::new();
105 for c in &clauses {
106 if let Some(r) = c.render(text, usage.as_ref()) {
107 kept.push(r);
108 }
109 }
110 kept.sort_by(|a, b| (&a.key, !a.has_comment).cmp(&(&b.key, !b.has_comment)));
113 let mut seen: HashSet<(u8, String)> = HashSet::new();
114 let mut lines: Vec<String> = Vec::new();
115 for r in kept {
116 if !seen.insert(r.key.clone()) && !r.has_comment {
117 continue;
118 }
119 lines.push(r.text);
120 }
121
122 let (span, replacement) = if lines.is_empty() {
123 let after = &text[region.end..];
126 let ws = after.len() - after.trim_start().len();
127 if region.end + ws == text.len() {
128 let start = text[..region.start].trim_end().len();
129 (Span::new(start, text.len()), "\n".to_string())
130 } else {
131 let end = region.end + after[..ws].rfind('\n').map_or(0, |i| i + 1);
132 (Span::new(region.start, end), String::new())
133 }
134 } else {
135 (region, lines.join("\n"))
136 };
137
138 let organised = format!(
139 "{}{}{}",
140 &text[..span.start],
141 replacement,
142 &text[span.end..]
143 );
144 if organised == text {
145 return None;
146 }
147 let opts = bynk_fmt::FormatOptions::default();
149 let canonical = |s: &str| bynk_fmt::format_source(s, &opts).is_ok_and(|f| f == s);
150 if canonical(text) && !canonical(&organised) {
151 return None;
152 }
153 Some((span, replacement))
154}
155
156pub fn action(
158 text: &str,
159 edit: (Span, String),
160 uri: &Url,
161 version: Option<i32>,
162) -> CodeActionOrCommand {
163 let (span, new_text) = edit;
164 CodeActionOrCommand::CodeAction(CodeAction {
165 title: "Organise `uses` and `consumes`".to_string(),
166 kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS),
167 edit: Some(WorkspaceEdit {
168 changes: None,
169 document_changes: Some(DocumentChanges::Edits(vec![TextDocumentEdit {
170 text_document: OptionalVersionedTextDocumentIdentifier {
171 uri: uri.clone(),
172 version,
173 },
174 edits: vec![OneOf::Left(TextEdit {
175 range: crate::position::span_to_range(text, span),
176 new_text,
177 })],
178 }])),
179 change_annotations: None,
180 }),
181 ..Default::default()
182 })
183}
184
185pub fn apply(text: &str, edit: &(Span, String)) -> String {
187 format!("{}{}{}", &text[..edit.0.start], edit.1, &text[edit.0.end..])
188}
189
190fn parse_clean(text: &str) -> Option<SourceUnit> {
191 let tokens = tokenize(text).ok()?;
192 match parse_unit_with_recovery(&tokens, text) {
193 (Some(unit), errs) if errs.is_empty() => Some(unit),
194 _ => None,
195 }
196}
197
198fn header(unit: &SourceUnit) -> Option<(String, &[UsesDecl], &[ConsumesDecl])> {
200 match unit {
201 SourceUnit::Commons(c) => Some((c.name.joined(), &c.uses, &[])),
202 SourceUnit::Context(c) => Some((c.name.joined(), &c.uses, &c.consumes)),
203 SourceUnit::Adapter(a) => Some((a.name.joined(), &a.uses, &a.consumes)),
204 SourceUnit::Suite(_) => None,
205 }
206}
207
208fn header_spans(text: &str) -> Vec<Span> {
210 let Some(unit) = parse_clean(text) else {
211 return Vec::new();
212 };
213 let Some((_, uses, consumes)) = header(&unit) else {
214 return Vec::new();
215 };
216 uses.iter()
217 .map(|u| u.span)
218 .chain(consumes.iter().map(|c| c.span))
219 .collect()
220}
221
222struct Usage {
224 units: HashSet<String>,
226 caps: HashSet<(String, String)>,
229 foreign_caps: HashSet<String>,
231 project_units: HashSet<String>,
233}
234
235impl Usage {
236 fn of(unit_name: &str, rel: &Path, text: &str, project: &Project) -> Self {
237 let mut files: Vec<PathBuf> = project
240 .unit_sources
241 .get(unit_name)
242 .cloned()
243 .unwrap_or_default();
244 if !files.iter().any(|f| f == rel) {
245 files.push(rel.to_path_buf());
246 }
247 let file_text = |f: &Path| -> Option<&str> {
248 if f == rel {
249 Some(text)
250 } else {
251 project.snapshots.get(f).map(String::as_str)
252 }
253 };
254 let headers: HashMap<&Path, Vec<Span>> = files
255 .iter()
256 .map(|f| {
257 (
258 f.as_path(),
259 file_text(f).map(header_spans).unwrap_or_default(),
260 )
261 })
262 .collect();
263 let counts = |path: &Path, span: Span| {
264 headers.get(path).is_some_and(|hs| {
265 !hs.iter()
266 .any(|h| h.start <= span.start && span.end <= h.end)
267 })
268 };
269
270 let mut units = HashSet::new();
271 let mut caps = HashSet::new();
272 for (key, entry) in &project.index.symbols {
273 if !entry.refs.iter().any(|r| counts(&r.path, r.span)) {
274 continue;
275 }
276 units.insert(key.unit.clone());
277 if matches!(key.kind, SymbolKind::Capability | SymbolKind::CapabilityOp) {
278 let cap = key.name.split('.').next().unwrap_or(&key.name);
279 caps.insert((key.unit.clone(), cap.to_string()));
280 }
281 }
282 let mut foreign_caps = HashSet::new();
283 for fr in &project.index.foreign_refs {
284 if fr.kind == SymbolKind::Capability
285 && counts(&fr.site.path, fr.site.span)
286 && let Some(name) = file_text(&fr.site.path)
287 .and_then(|t| t.get(fr.site.span.start..fr.site.span.end))
288 {
289 foreign_caps.insert(name.to_string());
290 }
291 }
292 Usage {
293 units,
294 caps,
295 foreign_caps,
296 project_units: project.unit_sources.keys().cloned().collect(),
297 }
298 }
299
300 fn unit_used(&self, target: &str) -> bool {
303 !self.project_units.contains(target) || self.units.contains(target)
304 }
305
306 fn cap_used(&self, target: &str, cap: &str) -> bool {
308 if self.project_units.contains(target) {
309 self.caps.contains(&(target.to_string(), cap.to_string()))
310 } else {
311 self.foreign_caps.contains(cap)
312 }
313 }
314}
315
316enum Kind<'a> {
317 Uses(&'a UsesDecl),
318 Consumes(&'a ConsumesDecl),
319}
320
321struct Clause<'a> {
323 kind: Kind<'a>,
324 span: Span,
325 extent: Span,
328}
329
330impl<'a> Clause<'a> {
331 fn new(text: &str, span: Span, kind: Kind<'a>) -> Option<Self> {
332 let line_start = text[..span.start].rfind('\n').map_or(0, |i| i + 1);
333 if !text[line_start..span.start].trim().is_empty() {
334 return None; }
336 let line_end = text[span.end..]
337 .find('\n')
338 .map_or(text.len(), |i| span.end + i);
339 let rest = text[span.end..line_end].trim();
340 if !rest.is_empty() && !rest.starts_with("//") {
341 return None; }
343 let mut start = line_start;
345 while start > 0 {
346 let prev_start = text[..start - 1].rfind('\n').map_or(0, |i| i + 1);
347 if text[prev_start..start - 1].trim_start().starts_with("//") {
348 start = prev_start;
349 } else {
350 break;
351 }
352 }
353 Some(Clause {
354 kind,
355 span,
356 extent: Span::new(start, line_end),
357 })
358 }
359
360 fn render(&self, text: &str, usage: Option<&Usage>) -> Option<Rendered> {
363 let has_comment = text[self.extent.start..self.extent.end].contains("//");
364 let mut clause = text[self.span.start..self.span.end].to_string();
365 let key = match self.kind {
366 Kind::Uses(u) => {
367 let target = u.target.joined();
368 if !has_comment && usage.is_some_and(|us| !us.unit_used(&target)) {
369 return None;
370 }
371 (0, target)
372 }
373 Kind::Consumes(c) => {
374 let target = c.target.joined();
375 match (&c.alias, &c.selected) {
376 (_, Some(selected)) if !selected.is_empty() && !clause.contains("//") => {
377 let mut names: Vec<&str> = selected
378 .iter()
379 .map(|i| i.name.as_str())
380 .filter(|n| usage.is_none_or(|us| us.cap_used(&target, n)))
381 .collect();
382 if names.is_empty() && has_comment {
383 names = selected.iter().map(|i| i.name.as_str()).collect();
385 }
386 names.sort_unstable();
387 names.dedup();
388 if names.is_empty() {
389 return None;
390 }
391 let original: Vec<&str> =
392 selected.iter().map(|i| i.name.as_str()).collect();
393 if names != original {
396 clause = format!("consumes {target} {{ {} }}", names.join(", "));
397 }
398 (1, format!("{target} {{ {} }}", names.join(", ")))
399 }
400 (_, Some(_)) => (1, format!("{target} {{ ... }}#{}", self.span.start)),
401 (alias, None) => {
402 if !has_comment && usage.is_some_and(|us| !us.unit_used(&target)) {
403 return None;
404 }
405 match alias {
406 Some(a) => (1, format!("{target} as {}", a.name)),
407 None => (1, target),
408 }
409 }
410 }
411 }
412 };
413 let mut out = String::new();
414 out.push_str(&text[self.extent.start..self.span.start]);
415 out.push_str(&clause);
416 out.push_str(&text[self.span.end..self.extent.end]);
417 Some(Rendered {
418 key,
419 text: out,
420 has_comment,
421 })
422 }
423}
424
425struct Rendered {
428 key: (u8, String),
429 text: String,
430 has_comment: bool,
431}