Skip to main content

sofie_cli/upgrade/
mod.rs

1//! Codemods for the framework's breaking changes. Each rewrites only the
2//! old form of an API, so running it on code already upgraded changes nothing
3//! and an app needs no record of which ones ran.
4
5mod content_security;
6mod features;
7mod password;
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use proc_macro2::LineColumn;
13use sofie_check::Finding;
14use sofie_model::Crate;
15
16use crate::check_command::check_app;
17use crate::gen_command::{GenError, io_error, regenerate};
18use crate::new_command::{dependency_line_start, workspace_declaration};
19use crate::source_edits::{DEPENDENCIES, Journal, read, toml_section, with_dependency_line};
20
21struct Codemod {
22    name: &'static str,
23    /// The commit whose breaking change it carries apps across.
24    change: &'static str,
25    edits: fn(&str, &syn::File) -> Vec<Edit>,
26    /// The framework package the rewritten code names, which the crate then depends on.
27    needs: Option<&'static str>,
28}
29
30const CODEMODS: &[Codemod] = &[
31    Codemod {
32        name: "content-security-on-serving",
33        change: "e586a89: content security sources are set on `Serving`, not `App`",
34        edits: content_security::edits,
35        needs: None,
36    },
37    Codemod {
38        name: "password-from-identity",
39        change: "1eb6a1d: `Password` and `PasswordHash` come from sofie_identity",
40        edits: password::edits,
41        needs: Some("sofie-identity"),
42    },
43];
44
45/// Replaces `start..end` of a file's text.
46pub(crate) struct Edit {
47    start: LineColumn,
48    end: LineColumn,
49    text: String,
50}
51
52impl Edit {
53    fn spanning(spanned: &impl syn::spanned::Spanned, text: String) -> Self {
54        let span = spanned.span();
55        Self {
56            start: span.start(),
57            end: span.end(),
58            text,
59        }
60    }
61
62    fn insert(at: LineColumn, text: String) -> Self {
63        Self {
64            start: at,
65            end: at,
66            text,
67        }
68    }
69}
70
71#[derive(Debug, PartialEq, Eq)]
72pub struct Change {
73    pub file: PathBuf,
74    pub codemod: &'static str,
75    pub change: &'static str,
76    pub line: usize,
77    pub before: String,
78    pub after: String,
79}
80
81pub struct UpgradeReport {
82    pub changes: Vec<Change>,
83    /// After writing: what `sofie check` still finds once `sofie gen` ran.
84    pub remaining: Option<Vec<Finding>>,
85    /// A dry run that would upgrade manifests plans no source change: the model is read once
86    /// they are written.
87    pub sources_pending: bool,
88}
89
90/// Without `write`, shows what each codemod would change and touches nothing.
91pub fn upgrade(root: &Path, write: bool) -> Result<UpgradeReport, GenError> {
92    // `cargo metadata`, and so the model, refuses the app until its manifests are upgraded.
93    let manifests = features::planned(root)?;
94    if !write && !manifests.changes.is_empty() {
95        return Ok(UpgradeReport {
96            changes: manifests.changes,
97            remaining: None,
98            sources_pending: true,
99        });
100    }
101    let mut journal = Journal::default();
102    let written = write_upgrade(&mut journal, root, manifests, write);
103    let changes = journal.finish(written)?;
104    let remaining = if write && !changes.is_empty() {
105        Some(check_app(root)?)
106    } else {
107        None
108    };
109    Ok(UpgradeReport {
110        changes,
111        remaining,
112        sources_pending: false,
113    })
114}
115
116/// Every source file is parsed and upgraded before the first is written; the caller's
117/// journal undoes them all when anything after fails, gen included.
118fn write_upgrade(
119    journal: &mut Journal,
120    root: &Path,
121    manifests: Planned,
122    write: bool,
123) -> Result<Vec<Change>, GenError> {
124    for (file, upgraded) in &manifests.files {
125        journal.write(file, upgraded)?;
126    }
127    let mut changes = manifests.changes;
128    let planned = plan(&sofie_model::load(root).map_err(GenError::Model)?)?;
129    changes.extend(planned.changes);
130    if !write || changes.is_empty() {
131        return Ok(changes);
132    }
133    for (file, upgraded) in &planned.files {
134        journal.write(file, upgraded)?;
135    }
136    regenerate(root)?;
137    Ok(changes)
138}
139
140fn plan(app: &sofie_model::App) -> Result<Planned, GenError> {
141    let mut planned = Planned::default();
142    for krate in crates(app) {
143        for (package, codemod) in planned.upgrade_files(krate)? {
144            planned.depend(krate, package, codemod)?;
145            // `sofie gen` wires a framework crate a module uses only through the app.
146            planned.depend(&app.app, package, codemod)?;
147        }
148    }
149    Ok(planned)
150}
151
152fn codemod(name: &str) -> &'static Codemod {
153    CODEMODS
154        .iter()
155        .find(|codemod| codemod.name == name)
156        .expect("a change names the codemod making it")
157}
158
159/// What the upgrade would write, each file once with all its changes.
160#[derive(Default)]
161struct Planned {
162    changes: Vec<Change>,
163    files: Vec<(PathBuf, String)>,
164}
165
166impl Planned {
167    /// The packages the rewritten code of `krate` names, with a codemod naming each.
168    fn upgrade_files(
169        &mut self,
170        krate: &Crate,
171    ) -> Result<Vec<(&'static str, &'static Codemod)>, GenError> {
172        let mut needed = Vec::new();
173        for file in rust_files(krate) {
174            if self.files.iter().any(|(planned, _)| *planned == file) {
175                continue;
176            }
177            let text = fs::read_to_string(&file).map_err(|source| io_error(&file, source))?;
178            let (upgraded, found) = upgrade_text(&text)
179                .map_err(|error| GenError::App(format!("{}: {error}", file.display())))?;
180            if found.is_empty() {
181                continue;
182            }
183            for change in found {
184                let codemod = codemod(change.codemod);
185                if let Some(package) = codemod.needs
186                    && needed.iter().all(|(needed, _)| *needed != package)
187                {
188                    needed.push((package, codemod));
189                }
190                self.changes.push(Change {
191                    file: file.clone(),
192                    ..change
193                });
194            }
195            self.files.push((file, upgraded));
196        }
197        Ok(needed)
198    }
199
200    fn depend(&mut self, krate: &Crate, package: &str, codemod: &Codemod) -> Result<(), GenError> {
201        if krate.depends_on(package) {
202            return Ok(());
203        }
204        let manifest = krate.path.join("Cargo.toml");
205        let text = self.text(&manifest)?;
206        let declared = toml_section(&text, DEPENDENCIES).is_some_and(|(start, end)| {
207            dependency_line_start(&text[start..end], package).is_some()
208        });
209        if declared {
210            return Ok(());
211        }
212        let line = format!("{package}.workspace = true\n");
213        self.plan(manifest, with_dependency_line(&text, &line), &line, codemod);
214        let Some((workspace, text)) = workspace_declaration(&krate.path, package)? else {
215            return Ok(());
216        };
217        if self.files.iter().all(|(planned, _)| *planned != workspace) {
218            let line = text
219                .lines()
220                .find(|line| dependency_line_start(line, package).is_some())
221                .unwrap_or_default()
222                .to_owned();
223            self.plan(workspace, text, &line, codemod);
224        }
225        Ok(())
226    }
227
228    fn text(&self, file: &Path) -> Result<String, GenError> {
229        match self.files.iter().find(|(planned, _)| planned == file) {
230            Some((_, text)) => Ok(text.clone()),
231            None => read(file),
232        }
233    }
234
235    fn plan(&mut self, file: PathBuf, text: String, added: &str, codemod: &Codemod) {
236        let added = added.trim_end();
237        let line = text
238            .lines()
239            .position(|line| line == added)
240            .map_or(0, |index| index + 1);
241        self.changes.push(Change {
242            file: file.clone(),
243            codemod: codemod.name,
244            change: codemod.change,
245            line,
246            before: String::new(),
247            after: added.to_owned(),
248        });
249        match self.files.iter_mut().find(|(planned, _)| *planned == file) {
250            Some((_, planned)) => *planned = text,
251            None => self.files.push((file, text)),
252        }
253    }
254}
255
256fn crates(app: &sofie_model::App) -> impl Iterator<Item = &Crate> {
257    std::iter::once(&app.app).chain(
258        app.modules
259            .iter()
260            .flat_map(|module| std::iter::once(&module.krate).chain(&module.contract)),
261    )
262}
263
264fn rust_files(krate: &Crate) -> Vec<PathBuf> {
265    let mut files: Vec<PathBuf> = krate
266        .sources
267        .iter()
268        .chain(&krate.tests_and_examples)
269        .map(|source| source.file.clone())
270        .collect();
271    files.sort();
272    files.dedup();
273    files
274}
275
276/// Codemods run one after another, each on the text the one before left.
277fn upgrade_text(text: &str) -> syn::Result<(String, Vec<Change>)> {
278    let mut text = text.to_owned();
279    let mut file = syn::parse_file(&text)?;
280    let mut changes = Vec::new();
281    for codemod in CODEMODS {
282        let mut edits = (codemod.edits)(&text, &file);
283        if edits.is_empty() {
284            continue;
285        }
286        edits.sort_by_key(|edit| (edit.start.line, edit.start.column));
287        let upgraded = apply(&text, &edits);
288        // Removed lines all lie before the edit, so the subtraction stays positive.
289        let (mut added_before, mut removed_before) = (0, 0);
290        for edit in &edits {
291            let removed = edit.end.line - edit.start.line;
292            let added = edit.text.matches('\n').count();
293            let line = edit.start.line + added_before - removed_before;
294            changes.push(Change {
295                file: PathBuf::new(),
296                codemod: codemod.name,
297                change: codemod.change,
298                line,
299                before: lines(&text, edit.start.line, removed),
300                after: lines(&upgraded, line, added),
301            });
302            added_before += added;
303            removed_before += removed;
304        }
305        text = upgraded;
306        file = syn::parse_file(&text)?;
307    }
308    Ok((text, changes))
309}
310
311/// Line `first` (from 1) and the `more` after it, each trimmed.
312fn lines(text: &str, first: usize, more: usize) -> String {
313    text.lines()
314        .skip(first - 1)
315        .take(more + 1)
316        .map(str::trim)
317        .collect::<Vec<_>>()
318        .join("\n")
319}
320
321/// Edits never overlap; applied from the last, so earlier positions stay valid.
322fn apply(text: &str, edits: &[Edit]) -> String {
323    let mut text = text.to_owned();
324    for edit in edits.iter().rev() {
325        let start = offset(&text, edit.start);
326        let end = offset(&text, edit.end);
327        text.replace_range(start..end, &edit.text);
328    }
329    text
330}
331
332/// `column` counts characters, as proc-macro2 does.
333fn offset(text: &str, at: LineColumn) -> usize {
334    let line_start: usize = text
335        .split_inclusive('\n')
336        .take(at.line - 1)
337        .map(str::len)
338        .sum();
339    line_start
340        + text[line_start..]
341            .chars()
342            .take(at.column)
343            .map(char::len_utf8)
344            .sum::<usize>()
345}
346
347#[cfg(test)]
348mod tests {
349    use super::upgrade_text;
350
351    #[test]
352    fn an_upgraded_text_is_left_as_it_is() {
353        let text = "fn app(app: App) -> App {\n    app.serving(|serving| serving.content_security::<Web>(sources()))\n}\n";
354
355        let (upgraded, changes) = upgrade_text(text).unwrap();
356
357        assert_eq!(upgraded, text);
358        assert_eq!(changes, []);
359    }
360
361    #[test]
362    fn reports_each_change_with_its_line_before_and_after() {
363        let text = "fn app(app: App) -> App {\n    app.content_security_all(sources())\n}\n";
364
365        let (_, changes) = upgrade_text(text).unwrap();
366
367        assert_eq!(changes.len(), 1);
368        assert_eq!(changes[0].line, 2);
369        assert_eq!(changes[0].before, "app.content_security_all(sources())");
370        assert_eq!(
371            changes[0].after,
372            "app.serving(|serving| serving.content_security_all(sources()))"
373        );
374    }
375}