Skip to main content

bynk_lsp/
organise.rs

1//! #2100 slice 5: the `source.organizeImports` code action — organise a unit
2//! header's `uses` and `consumes` clauses.
3//!
4//! The action sorts the `uses` clauses by target, then the `consumes` clauses
5//! by target (the order `bynk-fmt` prints them in: every `uses` before every
6//! `consumes`), drops exact duplicates, sorts and deduplicates the names in a
7//! braced `consumes U { … }` selection, and drops what the unit never uses:
8//!
9//! - a `uses U` or whole-unit `consumes U` (or `consumes U as A`) whose target
10//!   no reference in the unit resolves into;
11//! - a name in a braced selection that no reference in the unit names, and the
12//!   whole braced clause once every name in it is gone.
13//!
14//! "Unused" is read from the committed binding index (ADR 0053), over every
15//! file of the unit (a `uses` is unit-wide: the driver merges the clauses of
16//! all of a unit's files, and a suite resolves through its target's), not
17//! just the one being organised. A reference inside a `uses`/`consumes`
18//! clause is not a use — the braced form records one for every name it
19//! selects. A target that is not a project unit (the
20//! first-party `bynk` surface, a `bynk.list`-style commons) has no index
21//! symbols to test against, so a whole-unit clause naming one is never
22//! dropped; a braced selection from one is pruned against the index's
23//! first-party capability references, matched on their source text.
24//!
25//! The edit is one replacement of the header's clause run. Each clause moves
26//! with its own comments (the comment lines directly above it and a trailing
27//! comment on its line). A clause carrying a comment is never dropped: an
28//! unused or duplicate one is kept while the rest are still organised. The
29//! action declines (offers nothing) when the file doesn't parse cleanly, when
30//! the clauses don't form one run (something other than whitespace between
31//! two of them), when a clause shares its line with other code, or when the
32//! file is `bynk-fmt`-canonical but the organised text would not be. It is
33//! also never an empty edit: an already-organised header offers nothing.
34//!
35//! The pure computation lives here; the `code_action` handler adds DECISION
36//! B's safety gate (re-analyse the edited project, offer the edit only if it
37//! introduces no diagnostic), falling back from [`Mode::Full`] to
38//! [`Mode::SortOnly`] when a drop would break something.
39
40use 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/// What the organise pass may change.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Mode {
53    /// Sort, deduplicate, and drop unused clauses and selected names.
54    Full,
55    /// Sort and deduplicate only — the fallback when [`Mode::Full`]'s drops
56    /// fail the re-check.
57    SortOnly,
58}
59
60/// The project facts the unused test reads: the committed binding index, the
61/// round's snapshots (project-relative path → text), and each unit's source
62/// files.
63pub 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
69/// The organised header for `text` (the snapshot of `rel`), as one edit:
70/// `(span, replacement)`. `None` when there is nothing to change or the action
71/// declines (see the module docs).
72pub 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    // Every clause's extent: its whole line(s), with the comment lines above.
84    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    // Render, prune, sort, dedupe.
104    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    // Among duplicates a commented copy sorts first and is the one kept; a
111    // second commented copy is kept too, so no comment is ever dropped.
112    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        // Everything went: take the clauses' trailing blank line with them,
124        // or (at end of file) the blank line before them.
125        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    // Formatting is preserved: a canonical file stays canonical.
148    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
156/// The versioned `source.organizeImports` action for one organise edit.
157pub 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
185/// The text with one organise edit applied.
186pub 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
198/// The unit's name and its header clauses. A suite has none to organise.
199fn 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
208/// The spans of every `uses`/`consumes` clause in `text`'s header.
209fn 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
222/// What the unit's references resolve into, header clauses excluded.
223struct Usage {
224    /// The project units some reference resolves into.
225    units: HashSet<String>,
226    /// `(unit, capability)` pairs some reference names (a capability or one
227    /// of its operations).
228    caps: HashSet<(String, String)>,
229    /// Names of first-party capabilities some reference names.
230    foreign_caps: HashSet<String>,
231    /// The project's units.
232    project_units: HashSet<String>,
233}
234
235impl Usage {
236    fn of(unit_name: &str, rel: &Path, text: &str, project: &Project) -> Self {
237        // The unit's files, each with its own header spans to exclude. The
238        // file being organised reads from `text` (the caller's snapshot).
239        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    /// A whole-unit clause on `target` is used. Never false for a target
301    /// outside the project: nothing in the index can show it unused.
302    fn unit_used(&self, target: &str) -> bool {
303        !self.project_units.contains(target) || self.units.contains(target)
304    }
305
306    /// The capability `cap`, selected from `target`, is used.
307    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
321/// One header clause and the text that moves with it.
322struct Clause<'a> {
323    kind: Kind<'a>,
324    span: Span,
325    /// From the start of its first comment line (or its own line) to the end
326    /// of its line, newline excluded.
327    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; // shares its line with code before it
335        }
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; // shares its line with code after it
342        }
343        // Comment lines directly above (no blank line between) move with it.
344        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    /// The clause as it will be printed, or `None` when it is dropped. An
361    /// unused clause carrying a comment is kept as it is.
362    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                            // Kept for its comment: prune nothing.
384                            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                        // Rewrite only a selection that changes, so an
394                        // organised clause keeps its own spacing.
395                        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
425/// A kept clause: its sort/dedupe key (`0` for `uses`, `1` for `consumes`,
426/// then the normalised clause) and its printed extent.
427struct Rendered {
428    key: (u8, String),
429    text: String,
430    has_comment: bool,
431}