readseek 0.5.16

structural source reader with stable line hashes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (c) 2026 Jarkko Sakkinen

//! Binding-accurate rename planning.
//!
//! `rename` resolves the lexical binding under a cursor (via the scope resolver)
//! and emits an edit plan: one edit per occurrence that binds to the same
//! declaration. The plan is data only; it carries the line hashline for each
//! edit so an applier can verify the file has not drifted before writing.
//!
//! With `--workspace` the rename expands across a directory or repository. The
//! cursor file stays binding-accurate; for a lexical binding target, other files
//! retain only free uses when a binding table is available. Other targets use
//! name matching. `--apply` then writes every file all-or-nothing.

use crate::engine::binding::{self, OccurrenceKind};
use crate::engine::flags::GitFlags;
use crate::engine::lang::Language;
use crate::engine::output::{RenameConflict, RenameEdit, RenameFileOutput, RenameOutput};
use crate::engine::paths::{command_paths, identifier_spans};
use crate::engine::source::{
    ContentCategory, SourceFile, read_source_containing, source_from_text,
};
use anyhow::{Context, Result, bail};
use rayon::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};

/// Inputs for [`output`]: the cursor location, the new name, and expansion scope.
pub(crate) struct Request {
    pub(crate) target: PathBuf,
    pub(crate) line: usize,
    pub(crate) column: Option<usize>,
    pub(crate) to: String,
    pub(crate) workspace: Option<PathBuf>,
    pub(crate) apply: bool,
    pub(crate) language: Option<Language>,
    pub(crate) flags: GitFlags,
}

#[allow(clippy::too_many_lines)]
pub(crate) fn output(request: &Request) -> Result<RenameOutput> {
    let line = request.line;
    let column = request.column.unwrap_or(1);
    if line == 0 || column == 0 {
        bail!("line and column must be greater than zero");
    }
    if request.to.is_empty() {
        bail!("new name must not be empty");
    }
    if !is_plain_identifier(&request.to) {
        bail!("new name must be a plain identifier");
    }
    if !request.target.is_file() {
        bail!("rename requires a single regular file target");
    }

    let bytes =
        fs::read(&request.target).with_context(|| format!("read {}", request.target.display()))?;
    let text = String::from_utf8(bytes).context("file is not valid UTF-8")?;
    let source = source_from_text(
        &request.target,
        text,
        request.language,
        ContentCategory::Text,
        None,
    );

    let cursor_byte = source.cursor_byte(line, column)?;

    // The cursor file is binding-accurate when its symbol resolves to a local
    // declaration. Symbols without a lexical binding (macros, top-level functions
    // and types) fall back to the same name-based plan used for the other files,
    // so they rename in a single file as well as across a workspace.
    let (old_name, conflicts, edits, is_binding) = if let Some((binding, raw_conflicts)) =
        binding::resolve_with_conflicts(&source, cursor_byte, Some(&request.to))
    {
        if binding.name == request.to {
            bail!("new name is identical to the current name");
        }
        let conflicts = raw_conflicts
            .into_iter()
            .map(|conflict| {
                let (line, column) = source.line_column(conflict.byte);
                RenameConflict {
                    line,
                    column,
                    reason: conflict.reason,
                }
            })
            .collect();
        let edits = binding
            .occurrences
            .iter()
            .filter(|occurrence| occurrence.kind != OccurrenceKind::Shadowed)
            .map(|occurrence| {
                rename_edit(
                    &source,
                    occurrence.start_byte,
                    occurrence.end_byte,
                    occurrence.kind,
                )
            })
            .collect();
        (binding.name, conflicts, edits, true)
    } else {
        let name = binding::identifier_at(&source, cursor_byte).with_context(|| {
            format!(
                "no identifier at {}:{line}:{column}",
                request.target.display()
            )
        })?;
        if name == request.to {
            bail!("new name is identical to the current name");
        }
        let plan = build_other(&source, &name, &request.to, false).with_context(|| {
            format!(
                "`{name}` has no renamable occurrences in {}",
                request.target.display()
            )
        })?;
        (name, plan.conflicts, plan.edits, false)
    };

    let others = workspace_others(request, &old_name, is_binding)?;

    let applied = if request.apply {
        apply_all(request, &old_name, &edits, &conflicts, &others)?;
        true
    } else {
        false
    };

    Ok(RenameOutput {
        file: source.path.clone(),
        language: source.detection.language,
        engine: source.detection.engine,
        file_hash: source.file_hash.clone(),
        old_name,
        new_name: request.to.clone(),
        applied,
        conflicts,
        edits,
        others,
    })
}

/// The cursor file is excluded. Lexical binding targets use
/// [`binding::cross_file_matches`] (free uses only) where available; other
/// targets and unsupported languages use a plain name scan.
fn workspace_others(
    request: &Request,
    old_name: &str,
    target_is_binding: bool,
) -> Result<Vec<RenameFileOutput>> {
    let Some(workspace) = request.workspace.as_ref() else {
        return Ok(Vec::new());
    };
    let paths = command_paths(workspace, request.flags)?;
    let origin = request.target.canonicalize().ok();

    let mut others: Vec<RenameFileOutput> = paths
        .par_iter()
        .filter(|path| !is_origin(path, &request.target, origin.as_deref()))
        .filter_map(|path| {
            let source = read_source_containing(path, old_name, request.language)?;
            build_other(&source, old_name, &request.to, target_is_binding)
        })
        .collect();
    others.sort_by(|a, b| a.file.cmp(&b.file));
    Ok(others)
}

/// Build the edit plan for a single non-cursor file, or `None` when nothing in
/// it matches the old name.
///
/// Binding-aware targets still use a byte-level name scan so preprocessor
/// bodies and parse-error subtrees are retained, then exclude same-file local
/// declarations and uses that resolve to them. Non-binding targets rename every
/// name match.
fn build_other(
    source: &SourceFile,
    old_name: &str,
    new_name: &str,
    target_is_binding: bool,
) -> Option<RenameFileOutput> {
    let mut occurrences = name_scan(source, old_name);
    if occurrences.is_empty() {
        return None;
    }

    let matches = binding::cross_file_matches(source, old_name, new_name);
    let (excluded, conflict_bytes) = if target_is_binding {
        match matches {
            Some(matches) => (matches.excluded, matches.conflicts),
            None => (Vec::new(), Vec::new()),
        }
    } else {
        let conflicts = matches.map(|matches| matches.conflicts).unwrap_or_default();
        (Vec::new(), conflicts)
    };
    if !excluded.is_empty() {
        occurrences.retain(|(start_byte, _)| excluded.binary_search(start_byte).is_err());
        if occurrences.is_empty() {
            return None;
        }
    }

    let edits = occurrences
        .into_iter()
        .map(|(start_byte, end_byte)| {
            rename_edit(source, start_byte, end_byte, OccurrenceKind::Reference)
        })
        .collect();
    let conflicts = conflict_bytes
        .into_iter()
        .map(|byte| {
            let (line, column) = source.line_column(byte);
            RenameConflict {
                line,
                column,
                reason: format!("`{new_name}` already resolves to a binding here"),
            }
        })
        .collect();

    Some(RenameFileOutput {
        file: source.path.clone(),
        language: source.detection.language,
        engine: source.detection.engine,
        file_hash: source.file_hash.clone(),
        conflicts,
        edits,
    })
}

/// Word-boundary name match for languages without binding support.
fn name_scan(source: &SourceFile, name: &str) -> Vec<(usize, usize)> {
    let text = source.text.as_bytes();
    let needle = name.as_bytes();
    identifier_spans(text, needle)
        .map(|index| (index, index + needle.len()))
        .collect()
}

/// Whether `path` denotes the cursor file and so must be skipped during expansion.
fn is_origin(path: &Path, target: &Path, origin_canon: Option<&Path>) -> bool {
    if path == target {
        return true;
    }
    match (path.canonicalize(), origin_canon) {
        (Ok(candidate), Some(origin)) => candidate == origin,
        _ => false,
    }
}

fn rename_edit(
    source: &SourceFile,
    start_byte: usize,
    end_byte: usize,
    kind: OccurrenceKind,
) -> RenameEdit {
    let line_idx = source.line_index(start_byte);
    let source_line = &source.lines[line_idx];
    let line_start = source.line_starts[line_idx];
    RenameEdit {
        line: source_line.number,
        start_column: start_byte - line_start + 1,
        end_column: end_byte - line_start + 1,
        start_byte,
        end_byte,
        occurrence: kind,
        line_hash: source_line.hash(),
        text: source_line.text.clone(),
    }
}

/// Write the planned edits across every file, refusing on conflicts or drift.
///
/// Every file is verified before any is written: each edit's `line_hash` is
/// re-checked against the file on disk, so a file that changed since the plan
/// was computed aborts the whole rename rather than leaving a partial result.
/// If a write fails mid-way, already-written files are restored.
fn apply_all(
    request: &Request,
    old_name: &str,
    edits: &[RenameEdit],
    conflicts: &[RenameConflict],
    others: &[RenameFileOutput],
) -> Result<()> {
    let conflict_count = conflicts.len() + others.iter().map(|o| o.conflicts.len()).sum::<usize>();
    if conflict_count > 0 {
        bail!(
            "refusing to apply: {conflict_count} naming conflict(s); resolve them or rename to a free name"
        );
    }

    let plans: Vec<(&Path, &[RenameEdit])> = std::iter::once((request.target.as_path(), edits))
        .chain(
            others
                .iter()
                .map(|other| (other.file.as_path(), other.edits.as_slice())),
        )
        .collect();

    // Verify every file and compute its new text before touching any of them.
    let mut writes: Vec<(&Path, String, String)> = Vec::new();
    for (path, edits) in plans {
        if edits.is_empty() {
            continue;
        }
        let current =
            fs::read_to_string(path).with_context(|| format!("re-read {}", path.display()))?;
        let current =
            source_from_text(path, current, request.language, ContentCategory::Text, None);
        for edit in edits {
            let line = current.line(edit.line).with_context(|| {
                format!("line {} no longer exists in {}", edit.line, path.display())
            })?;
            if line.hash() != edit.line_hash {
                bail!(
                    "refusing to apply: {}:{} changed since the plan was computed",
                    path.display(),
                    edit.line
                );
            }
        }
        let new_text = rewrite(&current.text, edits, old_name, &request.to)?;
        writes.push((path, current.text, new_text));
    }

    // All files verified: write them, restoring on any failure.
    let mut written: Vec<(&Path, &str)> = Vec::new();
    for (path, original, new_text) in &writes {
        if let Err(error) = fs::write(path, new_text) {
            let _ = fs::write(path, original);
            for (done_path, done_original) in &written {
                let _ = fs::write(done_path, done_original);
            }
            return Err(error).with_context(|| format!("write {}", path.display()));
        }
        written.push((path, original));
    }
    Ok(())
}

/// Replace each edit span with `new_name`, applying back-to-front so earlier
/// byte offsets stay valid. Refuses unless every span still holds `old_name`.
fn rewrite(source: &str, edits: &[RenameEdit], old_name: &str, new_name: &str) -> Result<String> {
    let mut text = source.to_owned();
    let mut ordered: Vec<&RenameEdit> = edits.iter().collect();
    ordered.sort_by_key(|edit| std::cmp::Reverse(edit.start_byte));
    for edit in ordered {
        if edit.end_byte > text.len()
            || !text.is_char_boundary(edit.start_byte)
            || !text.is_char_boundary(edit.end_byte)
        {
            bail!("refusing to apply: edit span is out of range");
        }
        if &text[edit.start_byte..edit.end_byte] != old_name {
            bail!("refusing to apply: a target span no longer holds `{old_name}`");
        }
        text.replace_range(edit.start_byte..edit.end_byte, new_name);
    }
    Ok(text)
}

fn is_plain_identifier(name: &str) -> bool {
    let mut chars = name.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    (first.is_alphabetic() || first == '_') && chars.all(|ch| ch.is_alphanumeric() || ch == '_')
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::build_other;
    use crate::engine::lang::Language;
    use crate::engine::source::{ContentCategory, source_from_text};

    #[test]
    fn workspace_rename_excludes_local_shadow() {
        let source = source_from_text(
            Path::new("other.py"),
            "def f():\n    value = 2\n    return value\n\nprint(value)\n".to_owned(),
            Some(Language::Python),
            ContentCategory::Text,
            None,
        );

        let plan = build_other(&source, "value", "renamed", true).expect("workspace rename plan");

        assert_eq!(plan.edits.len(), 1);
        assert_eq!(plan.edits[0].line, 5);
    }

    #[test]
    fn workspace_rename_keeps_c_macro_body_occurrence() {
        let source = source_from_text(
            Path::new("other.c"),
            "#define INC() (value++)\nint other;\n".to_owned(),
            Some(Language::C),
            ContentCategory::Text,
            None,
        );

        let plan = build_other(&source, "value", "renamed", true).expect("workspace rename plan");

        assert_eq!(plan.edits.len(), 1);
        assert_eq!(plan.edits[0].line, 1);
    }
}