gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1471Shell 174Rust 105Markdown 943556 Definiciones

⎇ 630cde65510c2d1e57f33ecd0a5432c2e380c8d1 crates/hammer-cli/src/alpine_import.rs Rust · 326 líneas · 12.4 KB Historia Quién tocó qué crudo

Etapa G: importador Alpine APKBUILD→receta (carga los parches de musl) 630cde655 · sergio ·

1//! Importador `Alpine APKBUILD → receta hammer` (Etapa G, fuente #2).
2//!
3//! **Por qué Alpine y no sólo nix:** Alpine ya porta miles de paquetes a **musl** (y a menudo
4//! estático), CON los parches de portabilidad. Eso es justo lo que un import de nixpkgs PIERDE (los
5//! parches de nix viven aparte y asumen glibc). Por eso Alpine es la mejor respuesta a "¿qué si el
6//! build falla en musl?": su APKBUILD trae el `source=` con los `.patch` que lo hacen compilar.
7//!
8//! Este importador PARSEA el APKBUILD (no lo ejecuta — es shell; sourcearlo correría código ajeno)
9//! y extrae: pkgname/pkgver, la URL de la fuente, **los `.patch` (los carga a `source.patches`)**,
10//! makedepends → deps, y `build()/package()` → fases (best-effort: son shell de abuild con CHOST/
11//! --shared/etc. ⇒ necesitan adaptación al lab estático de hammer). Es un PUNTO DE PARTIDA, pero
12//! uno que YA incluye el trabajo de musl — mucho más cerca de compilar que un import crudo.
13
14use std::collections::BTreeMap;
15
16/// Convierte el texto de un APKBUILD en el TOML de una receta hammer.
17pub fn recipe_from_apkbuild(text: &str) -> Result<String, String> {
18    let vars = parse_vars(text);
19    let get = |k: &str| vars.get(k).cloned().unwrap_or_default();
20
21    let pkgname = get("pkgname");
22    if pkgname.is_empty() {
23        return Err("APKBUILD sin pkgname".into());
24    }
25    let pkgver = get("pkgver");
26    let version = if pkgver.is_empty() { "0".into() } else { pkgver };
27
28    // source=: tokens separados por espacios/saltos. Cada uno: `[name::]url-o-fichero`.
29    // El primero con esquema (://) es el tarball; los `.patch` se cargan como parches.
30    let mut tarball = String::new();
31    let mut patches: Vec<String> = Vec::new();
32    for tok in get("source").split_whitespace() {
33        let (name, url) = match tok.split_once("::") {
34            Some((n, u)) => (n.to_string(), Some(u.to_string())),
35            None => {
36                let base = tok.rsplit('/').next().unwrap_or(tok).to_string();
37                let url = if tok.contains("://") { Some(tok.to_string()) } else { None };
38                (base, url)
39            }
40        };
41        if name.ends_with(".patch") || name.ends_with(".diff") {
42            patches.push(name);
43        } else if tarball.is_empty() {
44            if let Some(u) = url {
45                tarball = u;
46            }
47        }
48    }
49
50    let source_block = if tarball.is_empty() {
51        // Sin tarball (paquetes git-based usan $_commit + snapshot); dejamos un FIXME claro.
52        "# FIXME: no detecté tarball; fijá `tarball+sha256` o `repo+commit` a mano\ntarball = \"\"\nsha256  = \"\"\n".to_string()
53    } else {
54        let sha512 = first_sha(&get("sha512sums"));
55        format!(
56            "tarball = \"{tarball}\"\n\
57             # FIXME sha256: el wrapper lo calcula (Alpine publica sha512). sha512 de Alpine:\n\
58             # sha512 = \"{sha512}\"\n\
59             sha256  = \"FIXME-sha256\"\n"
60        )
61    };
62
63    // deps: makedepends + depends (nombres; filtramos self-subpaquetes y los negados `!x`).
64    let mut deps: Vec<String> = Vec::new();
65    let self_prefix = format!("{pkgname}-");
66    for src in [get("makedepends"), get("depends")] {
67        for d in src.split_whitespace() {
68            let d = d.trim();
69            // Saltamos: negados (!x), variables sin expandir ($x), pins versionados a subpaquetes
70            // (`foo=1.2-r3`), y auto-referencias al propio paquete/subpaquetes (`pkgname-...`).
71            if d.is_empty()
72                || d.starts_with('!')
73                || d.starts_with('$')
74                || d.contains('=')
75                || d.starts_with(&self_prefix)
76                || d == pkgname
77            {
78                continue;
79            }
80            // Alpine usa `foo-dev`, `cmd:make`, `pc:libfoo`, `so:libbar.so` — normalizamos lo común.
81            let name = d
82                .strip_prefix("cmd:")
83                .or_else(|| d.strip_prefix("pc:"))
84                .unwrap_or(d)
85                .trim_end_matches("-dev")
86                .to_string();
87            if !name.is_empty() && !name.starts_with("so:") && !deps.contains(&name) {
88                deps.push(name);
89            }
90        }
91    }
92    let deps_block = if deps.is_empty() {
93        String::new()
94    } else {
95        let list = deps.iter().map(|d| format!("\"{d}\"")).collect::<Vec<_>>().join(", ");
96        format!("\n[deps]\nbuild = [{list}]\n")
97    };
98
99    // build()/package() → fases (best-effort, comentadas como abuild-shell a adaptar).
100    let phases_block = {
101        let compile = func_body(text, "build");
102        let install = func_body(text, "package");
103        if compile.is_none() && install.is_none() {
104            String::new()
105        } else {
106            let mut s = String::from("\n[build.phases]\n");
107            if let Some(b) = compile {
108                s.push_str(&format!("# de build() de Alpine (abuild: CHOST/--shared/etc; adaptá a estático):\ncompile = {}\n", toml_multiline(&b)));
109            }
110            if let Some(p) = install {
111                s.push_str(&format!("# de package() de Alpine (usa $pkgdir → ajustá a /out):\ninstall = {}\n", toml_multiline(&p)));
112            }
113            s
114        }
115    };
116
117    let patches_line = if patches.is_empty() {
118        String::new()
119    } else {
120        let list = patches.iter().map(|p| format!("\"{p}\"")).collect::<Vec<_>>().join(", ");
121        format!("patches = [{list}]\n")
122    };
123
124    let toml = format!(
125        "# Importada de Alpine aports por `hammer import-alpine` (Etapa G). PUNTO DE PARTIDA — pero\n\
126         # YA trae los parches de musl de Alpine (lo que un import de nix pierde). Pendiente: el\n\
127         # sha256 del tarball (el wrapper lo calcula), y adaptar build/install del shell de abuild.\n\
128         name    = \"{pkgname}\"\n\
129         version = \"{version}\"\n\
130         \n\
131         [source]\n\
132         {source_block}{patches_line}\
133         \n\
134         [build]\n\
135         compiler = \"zig-cc\"\n\
136         target   = \"x86_64-linux-musl\"\n\
137         link     = \"static\"\n\
138         flags    = []\n\
139         {phases_block}{deps_block}"
140    );
141    Ok(toml)
142}
143
144/// Recoge asignaciones `clave=valor` y `clave="...multilínea..."` del APKBUILD (ignora funciones y
145/// comentarios). Suficiente para los campos de metadatos; expande `$var`/`${var}` conocidas.
146fn parse_vars(text: &str) -> BTreeMap<String, String> {
147    let mut raw: BTreeMap<String, String> = BTreeMap::new();
148    let bytes: Vec<&str> = text.lines().collect();
149    let mut i = 0;
150    while i < bytes.len() {
151        let line = bytes[i];
152        let trimmed = line.trim_start();
153        // Asignación simple `key=...` al inicio de línea (no dentro de función: heurística por '=').
154        if let Some(eq) = simple_assign(trimmed) {
155            let key = trimmed[..eq].to_string();
156            let mut val = trimmed[eq + 1..].to_string();
157            // Valor multilínea entre comillas dobles: acumular hasta cerrar.
158            if let Some(rest) = val.strip_prefix('"') {
159                if let Some(end) = rest.find('"') {
160                    val = rest[..end].to_string();
161                } else {
162                    let mut acc = rest.to_string();
163                    i += 1;
164                    while i < bytes.len() {
165                        if let Some(end) = bytes[i].find('"') {
166                            acc.push('\n');
167                            acc.push_str(&bytes[i][..end]);
168                            break;
169                        } else {
170                            acc.push('\n');
171                            acc.push_str(bytes[i]);
172                        }
173                        i += 1;
174                    }
175                    val = acc;
176                }
177            } else {
178                // valor sin comillas: hasta fin de línea, sin comentario.
179                val = val.split('#').next().unwrap_or("").trim().to_string();
180                val = val.trim_matches('\'').to_string();
181            }
182            raw.insert(key, val);
183        }
184        i += 1;
185    }
186    // Expansión de variables (varias pasadas para anidamiento simple).
187    let keys: Vec<String> = raw.keys().cloned().collect();
188    for _ in 0..3 {
189        for k in &keys {
190            let mut v = raw[k].clone();
191            for k2 in &keys {
192                let val2 = raw[k2].clone();
193                v = v.replace(&format!("${{{k2}}}"), &val2).replace(&format!("${k2}"), &val2);
194            }
195            raw.insert(k.clone(), v);
196        }
197    }
198    raw
199}
200
201/// `Some(pos_del_=)` si la línea es una asignación `nombre=...` válida (nombre shell). Evita
202/// confundir `foo() {`, comparaciones, o `export X=Y`.
203fn simple_assign(line: &str) -> Option<usize> {
204    let eq = line.find('=')?;
205    if eq == 0 {
206        return None;
207    }
208    let name = &line[..eq];
209    if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && name.chars().next()?.is_ascii_alphabetic() || name.starts_with('_') {
210        // siguiente char no debe ser '=' (==) ni la asignación es vacía rara.
211        if line.as_bytes().get(eq + 1) == Some(&b'=') {
212            return None;
213        }
214        return Some(eq);
215    }
216    None
217}
218
219/// Extrae el cuerpo de una función shell `name() { ... }`: el texto entre la primera `{` y la `}`
220/// que la cierra a nivel 0. Best-effort (no parsea shell de verdad; cuenta llaves).
221fn func_body(text: &str, name: &str) -> Option<String> {
222    let pat_a = format!("{name}()");
223    let start = text.find(&pat_a)?;
224    let brace = text[start..].find('{')? + start;
225    let mut depth = 0i32;
226    let mut end = None;
227    for (idx, ch) in text[brace..].char_indices() {
228        match ch {
229            '{' => depth += 1,
230            '}' => {
231                depth -= 1;
232                if depth == 0 {
233                    end = Some(brace + idx);
234                    break;
235                }
236            }
237            _ => {}
238        }
239    }
240    let body = text[brace + 1..end?].trim().to_string();
241    if body.is_empty() {
242        None
243    } else {
244        Some(body)
245    }
246}
247
248/// Primer hash de un bloque `sha512sums="<hash>  <fichero>\n..."`.
249fn first_sha(block: &str) -> String {
250    block
251        .split_whitespace()
252        .next()
253        .unwrap_or("")
254        .to_string()
255}
256
257/// Serializa un cuerpo multilínea como string TOML (literal multi-línea `'''…'''` si tiene saltos).
258fn toml_multiline(s: &str) -> String {
259    if s.contains('\n') {
260        format!("'''\n{s}\n'''")
261    } else {
262        format!("\"{}\"", s.replace('"', "\\\""))
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    const APKBUILD_PATCHED: &str = r#"
271pkgname=foo
272pkgver=2.5
273pkgrel=1
274url="https://example.com/foo"
275makedepends="zlib-dev openssl-dev linux-headers"
276depends="bar"
277source="https://example.com/foo-$pkgver.tar.gz
278	foo-musl-fix.patch
279	foo-static.patch
280	"
281sha512sums="abc123  foo-2.5.tar.gz"
282
283build() {
284	./configure --prefix=/usr
285	make
286}
287
288package() {
289	make DESTDIR="$pkgdir" install
290}
291"#;
292
293    #[test]
294    fn extracts_source_patches_deps_phases() {
295        let toml = recipe_from_apkbuild(APKBUILD_PATCHED).unwrap();
296        let recipe = hammer_core::Recipe::from_toml(&toml).expect("recipe válida");
297        assert_eq!(recipe.name, "foo");
298        assert_eq!(recipe.version, "2.5");
299        // tarball con $pkgver expandido:
300        assert_eq!(recipe.source.tarball.as_deref(), Some("https://example.com/foo-2.5.tar.gz"));
301        // LOS PARCHES DE MUSL cargados (el punto central):
302        assert_eq!(recipe.source.patches, vec!["foo-musl-fix.patch", "foo-static.patch"]);
303        // deps: makedepends + depends, sin `-dev`:
304        assert!(recipe.deps.build.contains(&"zlib".to_string()));
305        assert!(recipe.deps.build.contains(&"openssl".to_string()));
306        assert!(recipe.deps.build.contains(&"linux-headers".to_string()));
307        assert!(recipe.deps.build.contains(&"bar".to_string()));
308        // fases capturadas de build()/package():
309        assert!(recipe.build.phases.compile.as_deref().unwrap().contains("./configure"));
310        assert!(recipe.build.phases.install.as_deref().unwrap().contains("DESTDIR"));
311    }
312
313    #[test]
314    fn no_patches_is_empty() {
315        let apk = "pkgname=zlib\npkgver=1.3\nsource=\"https://zlib.net/zlib-$pkgver.tar.gz\"\nsha512sums=\"x  z\"\n";
316        let recipe = hammer_core::Recipe::from_toml(&recipe_from_apkbuild(apk).unwrap()).unwrap();
317        assert_eq!(recipe.name, "zlib");
318        assert!(recipe.source.patches.is_empty());
319        assert_eq!(recipe.source.tarball.as_deref(), Some("https://zlib.net/zlib-1.3.tar.gz"));
320    }
321
322    #[test]
323    fn rejects_no_pkgname() {
324        assert!(recipe_from_apkbuild("pkgver=1\n").is_err());
325    }
326}