Skip to main content

kotlin_codegen/
file.rs

1//! Model-level file merging and on-disk layout.
2//!
3//! Emitters produce per-declaration [`KtFile`] fragments; [`merge_files`]
4//! groups them so every Java/Kotlin package collapses to ONE file, written
5//! at the FLATTENED path `<root>/<package as dirs>.kt` (`io.zenoh.jni.bytes`
6//! → `io/zenoh/jni/bytes.kt`) — the file is named after the package's last
7//! segment and lives in its parent package's directory. Kotlin imposes no
8//! file-location/`package` correspondence and a file `bytes.kt` never
9//! clashes with a sibling `bytes/` directory, so the layout is
10//! collision-free.
11
12use std::{
13    collections::{BTreeMap, BTreeSet},
14    fs,
15    path::{Component, Path, PathBuf},
16    sync::atomic::{AtomicUsize, Ordering},
17};
18
19use super::{
20    model::{KtDecl, KtFile},
21    validate::{Diagnostic, Severity, ValidationPolicy},
22};
23
24/// Errors surfaced by Kotlin emission.
25#[derive(Debug)]
26pub enum WriteKotlinError {
27    Io(std::io::Error),
28    /// One or more error-severity [`Diagnostic`]s. Every problem found is
29    /// reported at once, rather than stopping at the first: fixing one name and
30    /// rerunning the whole build to find the next is a poor loop for a
31    /// generator.
32    Validation(Vec<Diagnostic>),
33    Other(String),
34}
35
36impl std::fmt::Display for WriteKotlinError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            WriteKotlinError::Io(e) => write!(f, "I/O error writing Kotlin file: {}", e),
40            WriteKotlinError::Validation(ds) => {
41                writeln!(f, "{} Kotlin validation error(s):", ds.len())?;
42                for d in ds {
43                    writeln!(f, "  {d}")?;
44                }
45                Ok(())
46            }
47            WriteKotlinError::Other(s) => write!(f, "Kotlin emission error: {}", s),
48        }
49    }
50}
51
52impl std::error::Error for WriteKotlinError {}
53
54impl From<std::io::Error> for WriteKotlinError {
55    fn from(e: std::io::Error) -> Self {
56        WriteKotlinError::Io(e)
57    }
58}
59
60/// Merge fragments into one [`KtFile`] per package (sorted package order;
61/// within a package, fragments keep their emission order), then validate each
62/// merged file with every check at its default severity.
63///
64/// A [`KtFile::banner`] override carries into the merged file — the first
65/// fragment of a package that sets one wins.
66///
67/// Use [`merge_files_with`] to change a check's severity or to see warnings.
68pub fn merge_files(fragments: Vec<KtFile>) -> Result<Vec<KtFile>, WriteKotlinError> {
69    let (merged, _warnings) = merge_files_with(fragments, &ValidationPolicy::new())?;
70    Ok(merged)
71}
72
73/// [`merge_files`] with per-check severities, also returning the diagnostics
74/// that did not stop generation.
75pub fn merge_files_with(
76    fragments: Vec<KtFile>,
77    policy: &ValidationPolicy,
78) -> Result<(Vec<KtFile>, Vec<Diagnostic>), WriteKotlinError> {
79    let mut groups: BTreeMap<String, KtFile> = BTreeMap::new();
80    // A `Raw` block is a hoisted singleton keyed by its name, so two fragments
81    // may legitimately carry the same one — that is deduplication, not a
82    // mistake. Identical blocks collapse to one here; anything left sharing a
83    // name genuinely differs, and validation reports it.
84    let mut raw_seen: BTreeMap<String, BTreeSet<(String, String)>> = BTreeMap::new();
85    for frag in fragments {
86        let seen = raw_seen.entry(frag.package.clone()).or_default();
87        let merged = groups
88            .entry(frag.package.clone())
89            .or_insert_with(|| KtFile::new(frag.package.clone()));
90        for decl in frag.decls {
91            if let KtDecl::Raw { name, code } = &decl {
92                let mut rendered = String::new();
93                code.render(0, &mut rendered);
94                if !seen.insert((name.clone(), rendered)) {
95                    continue;
96                }
97            }
98            merged.decls.push(decl);
99        }
100        merged.extra_imports.extend(frag.extra_imports);
101        // A banner override belongs to the package, not to whichever fragment
102        // happened to carry it, so the first fragment that sets one wins and
103        // later fragments do not silently clear it.
104        if merged.banner.is_none() {
105            merged.banner = frag.banner;
106        }
107    }
108    let merged: Vec<KtFile> = groups.into_values().collect();
109    let (errors, warnings) = split_diagnostics(&merged, policy);
110    if errors.is_empty() {
111        Ok((merged, warnings))
112    } else {
113        Err(WriteKotlinError::Validation(errors))
114    }
115}
116
117/// Validate every file, partitioning the diagnostics by whether they stop
118/// generation.
119fn split_diagnostics(
120    files: &[KtFile],
121    policy: &ValidationPolicy,
122) -> (Vec<Diagnostic>, Vec<Diagnostic>) {
123    files
124        .iter()
125        .flat_map(|f| f.validate_with(policy))
126        .partition(|d| d.severity == Severity::Error)
127}
128
129/// The flattened on-disk path of one merged file under `kotlin_root`:
130/// `io.zenoh.jni.bytes` becomes `<kotlin_root>/io/zenoh/jni/bytes.kt`.
131///
132/// [`write_files`] uses this to lay out its output; it is public so a consumer
133/// can predict, report or post-process those paths without writing anything.
134/// `fallback_name` names the file when the package is empty.
135pub fn merged_file_path(kotlin_root: &Path, file: &KtFile, fallback_name: &str) -> PathBuf {
136    if file.package.is_empty() {
137        kotlin_root.join(format!("{fallback_name}.kt"))
138    } else {
139        kotlin_root.join(format!("{}.kt", file.package.replace('.', "/")))
140    }
141}
142
143const OWNERSHIP_MARKER: &str = ".kotlin-codegen-output";
144const OWNERSHIP_MARKER_CONTENT: &str = "kotlin-codegen output v1\n";
145
146/// Render and write every merged file; returns the written paths.
147///
148/// A non-empty `kotlin_root` must contain the ownership marker.
149/// The initial write accepts a missing or empty directory and creates that
150/// marker. Subsequent writes stage the complete output beside the root before
151/// replacing the marked tree, so stale generated files are removed without
152/// deleting caller-owned files or leaving an old tree half-deleted on failure.
153///
154/// The marker's content is matched ignoring surrounding whitespace and line
155/// endings, so a committed marker checked out with CRLF (git `autocrlf` on
156/// Windows) is still recognized.
157pub fn write_files(files: &[KtFile], kotlin_root: &Path) -> Result<Vec<PathBuf>, WriteKotlinError> {
158    write_files_with(files, kotlin_root, &ValidationPolicy::new()).map(|(paths, _)| paths)
159}
160
161/// [`write_files`] with per-check severities, also returning the diagnostics
162/// that did not stop generation. Nothing is written when any check errors.
163pub fn write_files_with(
164    files: &[KtFile],
165    kotlin_root: &Path,
166    policy: &ValidationPolicy,
167) -> Result<(Vec<PathBuf>, Vec<Diagnostic>), WriteKotlinError> {
168    let (errors, warnings) = split_diagnostics(files, policy);
169    if !errors.is_empty() {
170        return Err(WriteKotlinError::Validation(errors));
171    }
172    write_validated(files, kotlin_root).map(|paths| (paths, warnings))
173}
174
175fn write_validated(files: &[KtFile], kotlin_root: &Path) -> Result<Vec<PathBuf>, WriteKotlinError> {
176    let root_state = inspect_root(kotlin_root)?;
177    let parent = kotlin_root.parent().unwrap_or_else(|| Path::new("."));
178    fs::create_dir_all(parent)?;
179    let staging = unique_sibling_path(kotlin_root, "staging");
180    fs::create_dir(&staging)?;
181
182    let result = write_staging(files, &staging).and_then(|relative_paths| {
183        replace_root(kotlin_root, root_state, &staging)?;
184        Ok(relative_paths
185            .into_iter()
186            .map(|path| kotlin_root.join(path))
187            .collect())
188    });
189    if result.is_err() {
190        let _ = fs::remove_dir_all(&staging);
191    }
192    result
193}
194
195#[derive(Clone, Copy)]
196enum RootState {
197    Missing,
198    Empty,
199    Owned,
200}
201
202fn inspect_root(kotlin_root: &Path) -> Result<RootState, WriteKotlinError> {
203    let metadata = match fs::symlink_metadata(kotlin_root) {
204        Ok(metadata) => metadata,
205        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
206            return Ok(RootState::Missing)
207        }
208        Err(error) => return Err(error.into()),
209    };
210    if metadata.file_type().is_symlink() || !metadata.is_dir() {
211        return Err(WriteKotlinError::Other(format!(
212            "Kotlin output root `{}` must be a directory",
213            kotlin_root.display()
214        )));
215    }
216    if fs::read_dir(kotlin_root)?.next().is_none() {
217        return Ok(RootState::Empty);
218    }
219
220    let marker = kotlin_root.join(OWNERSHIP_MARKER);
221    let marker_metadata = match fs::symlink_metadata(&marker) {
222        Ok(metadata) => metadata,
223        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
224            return Err(WriteKotlinError::Other(format!(
225                "refusing to replace non-empty Kotlin output root `{}` without an ownership marker",
226                kotlin_root.display()
227            )));
228        }
229        Err(error) => return Err(error.into()),
230    };
231    if marker_metadata.file_type().is_symlink() || !marker_metadata.is_file() {
232        return Err(WriteKotlinError::Other(format!(
233            "refusing to replace non-empty Kotlin output root `{}` without an ownership marker",
234            kotlin_root.display()
235        )));
236    }
237    // Compare ignoring surrounding whitespace / line endings: the marker is a
238    // sentinel, and git's `autocrlf` rewrites the committed LF marker to CRLF on
239    // a Windows checkout — an exact-byte compare would then reject the (present,
240    // valid) marker. Trimming still rejects a wrong/foreign/empty marker.
241    if fs::read_to_string(&marker)?.trim() != OWNERSHIP_MARKER_CONTENT.trim() {
242        return Err(WriteKotlinError::Other(format!(
243            "refusing to replace non-empty Kotlin output root `{}` without an ownership marker",
244            kotlin_root.display()
245        )));
246    }
247    Ok(RootState::Owned)
248}
249
250fn write_staging(files: &[KtFile], staging: &Path) -> Result<Vec<PathBuf>, WriteKotlinError> {
251    fs::write(staging.join(OWNERSHIP_MARKER), OWNERSHIP_MARKER_CONTENT)?;
252    let mut written = Vec::new();
253    for file in files {
254        let fallback = file
255            .decls
256            .first()
257            .map(|decl| decl.name().to_string())
258            .unwrap_or_else(|| "Generated".to_string());
259        let relative_path = merged_file_path(Path::new(""), file, &fallback);
260        ensure_relative_output_path(&relative_path)?;
261        let path = staging.join(&relative_path);
262        if let Some(parent) = path.parent() {
263            fs::create_dir_all(parent)?;
264        }
265        fs::write(&path, file.render())?;
266        written.push(relative_path);
267    }
268    Ok(written)
269}
270
271fn ensure_relative_output_path(path: &Path) -> Result<(), WriteKotlinError> {
272    if path.components().any(|component| {
273        matches!(
274            component,
275            Component::RootDir | Component::Prefix(_) | Component::ParentDir
276        )
277    }) {
278        return Err(WriteKotlinError::Other(format!(
279            "Kotlin output path `{}` escapes the output root",
280            path.display()
281        )));
282    }
283    Ok(())
284}
285
286fn replace_root(
287    kotlin_root: &Path,
288    root_state: RootState,
289    staging: &Path,
290) -> Result<(), WriteKotlinError> {
291    match root_state {
292        RootState::Missing => fs::rename(staging, kotlin_root)?,
293        RootState::Empty => {
294            fs::remove_dir(kotlin_root)?;
295            fs::rename(staging, kotlin_root)?;
296        }
297        RootState::Owned => {
298            let backup = unique_sibling_path(kotlin_root, "previous");
299            fs::rename(kotlin_root, &backup)?;
300            if let Err(error) = fs::rename(staging, kotlin_root) {
301                let _ = fs::rename(&backup, kotlin_root);
302                return Err(error.into());
303            }
304            fs::remove_dir_all(backup)?;
305        }
306    }
307    Ok(())
308}
309
310fn unique_sibling_path(kotlin_root: &Path, purpose: &str) -> PathBuf {
311    static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
312    let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
313    let name = kotlin_root
314        .file_name()
315        .and_then(|name| name.to_str())
316        .unwrap_or("kotlin");
317    kotlin_root.with_file_name(format!(
318        ".{name}.kotlin-codegen-{purpose}-{}_{}",
319        std::process::id(),
320        sequence
321    ))
322}