Skip to main content

perry_wit/sdk/
mod.rs

1//! Zero-Config SDK generation and TypeScript developer experience.
2
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Configuration options for SDK type generation.
8#[derive(Debug, Clone)]
9pub struct SdkOptions {
10    pub wit_dir: PathBuf,
11    pub world: Option<String>,
12    pub out_dir: PathBuf,
13    pub project_root: Option<PathBuf>,
14    pub entry: PathBuf,
15    pub initialize_tsconfig: bool,
16}
17
18impl Default for SdkOptions {
19    fn default() -> Self {
20        Self {
21            wit_dir: PathBuf::from("wit"),
22            world: None,
23            out_dir: PathBuf::from(".perry/types"),
24            project_root: None,
25            entry: PathBuf::from("src/index.ts"),
26            initialize_tsconfig: true,
27        }
28    }
29}
30
31/// Result of SDK generation containing created file paths.
32#[derive(Debug, Clone)]
33pub struct SdkResult {
34    pub types_path: PathBuf,
35    pub check_path: PathBuf,
36    pub tsconfig_path: Option<PathBuf>,
37}
38
39/// Generates world/import declarations, an implementation check, and a missing tsconfig.
40pub fn generate_sdk_files(
41    options: &SdkOptions,
42) -> std::result::Result<SdkResult, crate::CompileError> {
43    generate_sdk_files_impl(options)
44        .map_err(|error| crate::CompileError::from_error(error, crate::DiagnosticCode::Io))
45}
46
47fn generate_sdk_files_impl(options: &SdkOptions) -> Result<SdkResult> {
48    let (resolve, package) = crate::component::wit::resolve_wit(&options.wit_dir)?;
49    let world = crate::component::wit::select_world(&resolve, package, options.world.as_deref())?;
50    let artifacts = declarations(&resolve, world).map_err(|error| {
51        crate::PerryDiagnostic::from_error(error, crate::DiagnosticCode::WitResolution)
52    })?;
53    fs::create_dir_all(&options.out_dir).with_context(|| {
54        format!(
55            "Failed to create SDK output directory at {}",
56            options.out_dir.display()
57        )
58    })?;
59
60    for artifact in artifacts {
61        let path = options.out_dir.join(artifact.name);
62        fs::write(&path, artifact.contents)
63            .with_context(|| format!("Failed to write declaration file at {}", path.display()))?;
64    }
65    let dts_path = options.out_dir.join("world.d.ts");
66
67    // Determine project root for tsconfig.json
68    let project_root = options.project_root.clone().unwrap_or_else(|| {
69        if options.out_dir.ends_with(".perry/types") {
70            options
71                .out_dir
72                .parent()
73                .and_then(|p| p.parent())
74                .filter(|p| !p.as_os_str().is_empty())
75                .unwrap_or(Path::new("."))
76                .to_path_buf()
77        } else {
78            PathBuf::from(".")
79        }
80    });
81
82    let tsconfig_path = project_root.join("tsconfig.json");
83    let entry = project_root.join(&options.entry);
84    let entry_import = relative_path(&options.out_dir, &entry.with_extension(""))?;
85    let check_path = options.out_dir.join("implementation-check.ts");
86    let check = format!(
87        "import * as implementation from {};\nimport type {{ ComponentImplementation }} from './world';\nconst checked: ComponentImplementation = implementation;\nexport {{ checked }};\n",
88        serde_json::to_string(&entry_import)?
89    );
90    fs::write(&check_path, check)?;
91    let compiler_options = || {
92        serde_json::json!({
93            "target": "ES2022",
94            "lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
95            "module": "ESNext",
96            "moduleDetection": "force",
97            "moduleResolution": "bundler",
98            "allowImportingTsExtensions": true,
99            "strict": true,
100            "noEmit": true,
101            "skipLibCheck": false,
102            "types": []
103        })
104    };
105    let generated_tsconfig = if !tsconfig_path.exists() && options.initialize_tsconfig {
106        let config = serde_json::json!({
107            "compilerOptions": compiler_options(),
108            "include": ["src/**/*", "examples/**/*", "*.ts", ".perry/types/**/*"],
109            "files": [relative_path(&project_root, &check_path)?]
110        });
111        fs::write(&tsconfig_path, serde_json::to_string_pretty(&config)?).with_context(|| {
112            format!(
113                "Failed to write tsconfig.json at {}",
114                tsconfig_path.display()
115            )
116        })?;
117        Some(tsconfig_path)
118    } else if tsconfig_path.exists() {
119        Some(tsconfig_path)
120    } else {
121        None
122    };
123
124    let mut check_config = serde_json::json!({
125        "compilerOptions": compiler_options(),
126        "files": ["implementation-check.ts"],
127        "include": [],
128        "exclude": []
129    });
130    if let Some(path) = &generated_tsconfig {
131        check_config["extends"] = serde_json::json!(relative_path(&options.out_dir, path)?);
132    }
133    fs::write(
134        options.out_dir.join("tsconfig.json"),
135        serde_json::to_string_pretty(&check_config)?,
136    )?;
137
138    Ok(SdkResult {
139        types_path: dts_path,
140        check_path,
141        tsconfig_path: generated_tsconfig,
142    })
143}
144
145/// Generate portable declaration contents independently of a project layout.
146pub(crate) fn declarations(
147    resolve: &wit_parser::Resolve,
148    world: wit_parser::WorldId,
149) -> Result<Vec<crate::api::Artifact>> {
150    Ok(perry_frontend::sdk::declarations(resolve, world)?
151        .into_iter()
152        .map(|declaration| crate::api::Artifact {
153            name: declaration.name,
154            contents: declaration.contents,
155        })
156        .collect())
157}
158
159/// Emits the compiler's supported ambient runtime surface for editors and `tsc`.
160pub fn write_runtime_declarations(out_dir: &Path) -> Result<()> {
161    fs::create_dir_all(out_dir)?;
162    fs::write(
163        out_dir.join("runtime.d.ts"),
164        perry_frontend::sdk::RUNTIME_DECLARATIONS,
165    )?;
166    Ok(())
167}
168
169fn relative_path(from: &Path, to: &Path) -> Result<String> {
170    fn normalized(path: &Path) -> Result<PathBuf> {
171        let mut result = PathBuf::new();
172        for component in std::path::absolute(path)?.components() {
173            if component == std::path::Component::ParentDir {
174                result.pop();
175            } else {
176                result.push(component);
177            }
178        }
179        Ok(result)
180    }
181    let from = normalized(from)?;
182    let to = normalized(to)?;
183    let common = from
184        .components()
185        .zip(to.components())
186        .take_while(|(a, b)| a == b)
187        .count();
188    let mut relative = PathBuf::from(".");
189    for _ in from.components().skip(common) {
190        relative.push("..");
191    }
192    relative.extend(to.components().skip(common));
193    Ok(relative.to_string_lossy().replace('\\', "/"))
194}