@typokit/transform-native 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +148 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +290 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/env.d.ts +40 -0
- package/src/index.d.ts +135 -0
- package/src/index.test.ts +878 -0
- package/src/index.ts +437 -0
- package/src/lib.rs +388 -0
- package/src/openapi_generator.rs +525 -0
- package/src/output_pipeline.rs +234 -0
- package/src/parser.rs +105 -0
- package/src/route_compiler.rs +615 -0
- package/src/schema_differ.rs +393 -0
- package/src/test_stub_generator.rs +318 -0
- package/src/type_extractor.rs +370 -0
- package/src/typia_bridge.rs +179 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
use crate::route_compiler::{RouteEntry, RouteTypeInfo, RouteObjectProp};
|
|
2
|
+
|
|
3
|
+
/// Generate contract test scaffolding from route entries.
|
|
4
|
+
///
|
|
5
|
+
/// Produces TypeScript test code using describe/it blocks that test:
|
|
6
|
+
/// - Valid request payloads return success
|
|
7
|
+
/// - Missing required fields return 400
|
|
8
|
+
/// - Invalid types return 400
|
|
9
|
+
pub fn generate_test_stubs(routes: &[RouteEntry]) -> String {
|
|
10
|
+
let mut output = String::new();
|
|
11
|
+
|
|
12
|
+
output.push_str("// AUTO-GENERATED by @typokit/transform-native — DO NOT EDIT\n");
|
|
13
|
+
output.push_str("// Contract tests generated from route definitions\n");
|
|
14
|
+
output.push_str("import { describe, it, expect } from \"@rstest/core\";\n");
|
|
15
|
+
output.push_str("import { createTestClient } from \"@typokit/testing\";\n\n");
|
|
16
|
+
output.push_str("const client = createTestClient();\n\n");
|
|
17
|
+
|
|
18
|
+
for entry in routes {
|
|
19
|
+
generate_route_test(&mut output, entry);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
output
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn generate_route_test(output: &mut String, entry: &RouteEntry) {
|
|
26
|
+
let label = format!("{} {}", entry.method, entry.path);
|
|
27
|
+
output.push_str(&format!("describe(\"{}\", () => {{\n", label));
|
|
28
|
+
|
|
29
|
+
// Test 1: Valid request
|
|
30
|
+
generate_valid_request_test(output, entry);
|
|
31
|
+
|
|
32
|
+
// Test 2: If body has required fields, test missing fields
|
|
33
|
+
if has_required_fields(&entry.body_type) {
|
|
34
|
+
generate_missing_fields_test(output, entry);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Test 3: If params exist, test with valid params
|
|
38
|
+
if !matches!(entry.params_type, RouteTypeInfo::Void) {
|
|
39
|
+
generate_params_test(output, entry);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
output.push_str("});\n\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn generate_valid_request_test(output: &mut String, entry: &RouteEntry) {
|
|
46
|
+
let method_lower = entry.method.to_lowercase();
|
|
47
|
+
output.push_str(" it(\"accepts valid request\", async () => {\n");
|
|
48
|
+
|
|
49
|
+
let path = convert_path_params(&entry.path);
|
|
50
|
+
let has_body = !matches!(entry.body_type, RouteTypeInfo::Void);
|
|
51
|
+
let has_query = !matches!(entry.query_type, RouteTypeInfo::Void);
|
|
52
|
+
|
|
53
|
+
if has_body || has_query {
|
|
54
|
+
output.push_str(&format!(" const res = await client.{}(\"{}\", {{\n", method_lower, path));
|
|
55
|
+
if has_body {
|
|
56
|
+
output.push_str(" body: ");
|
|
57
|
+
output.push_str(&generate_sample_value(&entry.body_type, 3));
|
|
58
|
+
output.push_str(",\n");
|
|
59
|
+
}
|
|
60
|
+
if has_query {
|
|
61
|
+
output.push_str(" query: ");
|
|
62
|
+
output.push_str(&generate_sample_value(&entry.query_type, 3));
|
|
63
|
+
output.push_str(",\n");
|
|
64
|
+
}
|
|
65
|
+
output.push_str(" });\n");
|
|
66
|
+
} else {
|
|
67
|
+
output.push_str(&format!(" const res = await client.{}(\"{}\");\n", method_lower, path));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
output.push_str(" expect(res.status).toBe(200);\n");
|
|
71
|
+
output.push_str(" });\n\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fn generate_missing_fields_test(output: &mut String, entry: &RouteEntry) {
|
|
75
|
+
let method_lower = entry.method.to_lowercase();
|
|
76
|
+
let path = convert_path_params(&entry.path);
|
|
77
|
+
|
|
78
|
+
output.push_str(" it(\"rejects missing required fields\", async () => {\n");
|
|
79
|
+
output.push_str(&format!(
|
|
80
|
+
" const res = await client.{}(\"{}\", {{\n body: {{}},\n }});\n",
|
|
81
|
+
method_lower, path
|
|
82
|
+
));
|
|
83
|
+
output.push_str(" expect(res.status).toBe(400);\n");
|
|
84
|
+
output.push_str(" });\n\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fn generate_params_test(output: &mut String, entry: &RouteEntry) {
|
|
88
|
+
let method_lower = entry.method.to_lowercase();
|
|
89
|
+
let path = convert_path_params(&entry.path);
|
|
90
|
+
|
|
91
|
+
output.push_str(" it(\"handles path parameters\", async () => {\n");
|
|
92
|
+
output.push_str(&format!(
|
|
93
|
+
" const res = await client.{}(\"{}\");\n",
|
|
94
|
+
method_lower, path
|
|
95
|
+
));
|
|
96
|
+
output.push_str(" expect(res.status).toBeDefined();\n");
|
|
97
|
+
output.push_str(" });\n\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Convert route path params (`:id`) to sample values for test URLs
|
|
101
|
+
fn convert_path_params(path: &str) -> String {
|
|
102
|
+
let mut result = String::new();
|
|
103
|
+
for segment in path.split('/') {
|
|
104
|
+
if segment.is_empty() {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
result.push('/');
|
|
108
|
+
if segment.starts_with(':') {
|
|
109
|
+
result.push_str("test-id");
|
|
110
|
+
} else if segment.starts_with('*') {
|
|
111
|
+
result.push_str("test/wildcard/path");
|
|
112
|
+
} else {
|
|
113
|
+
result.push_str(segment);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if result.is_empty() {
|
|
117
|
+
result.push('/');
|
|
118
|
+
}
|
|
119
|
+
result
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Check if a type has required (non-optional) fields
|
|
123
|
+
fn has_required_fields(type_info: &RouteTypeInfo) -> bool {
|
|
124
|
+
match type_info {
|
|
125
|
+
RouteTypeInfo::ObjectLiteral(props) => props.iter().any(|p| !p.optional),
|
|
126
|
+
RouteTypeInfo::Named(_) => true, // Assume named types have required fields
|
|
127
|
+
_ => false,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Generate a sample value string for a given type
|
|
132
|
+
fn generate_sample_value(type_info: &RouteTypeInfo, indent: usize) -> String {
|
|
133
|
+
match type_info {
|
|
134
|
+
RouteTypeInfo::Void => "undefined".to_string(),
|
|
135
|
+
RouteTypeInfo::Primitive(p) => match p.as_str() {
|
|
136
|
+
"string" => "\"test-value\"".to_string(),
|
|
137
|
+
"number" => "42".to_string(),
|
|
138
|
+
"boolean" => "true".to_string(),
|
|
139
|
+
_ => "null".to_string(),
|
|
140
|
+
},
|
|
141
|
+
RouteTypeInfo::Named(name) => format!("{{}} as unknown as {}", name),
|
|
142
|
+
RouteTypeInfo::ObjectLiteral(props) => {
|
|
143
|
+
generate_object_sample(props, indent)
|
|
144
|
+
}
|
|
145
|
+
RouteTypeInfo::Array(inner) => {
|
|
146
|
+
format!("[{}]", generate_sample_value(inner, indent))
|
|
147
|
+
}
|
|
148
|
+
RouteTypeInfo::StringLiteral(s) => format!("\"{}\"", s),
|
|
149
|
+
RouteTypeInfo::NumberLiteral(n) => format!("{}", n),
|
|
150
|
+
RouteTypeInfo::BooleanLiteral(b) => format!("{}", b),
|
|
151
|
+
RouteTypeInfo::Union(variants) => {
|
|
152
|
+
// Use first variant as sample
|
|
153
|
+
if let Some(first) = variants.first() {
|
|
154
|
+
generate_sample_value(first, indent)
|
|
155
|
+
} else {
|
|
156
|
+
"null".to_string()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
RouteTypeInfo::Generic(name, _args) => {
|
|
160
|
+
format!("{{}} as unknown as {}", name)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fn generate_object_sample(props: &[RouteObjectProp], indent: usize) -> String {
|
|
166
|
+
if props.is_empty() {
|
|
167
|
+
return "{}".to_string();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let indent_str = " ".repeat(indent);
|
|
171
|
+
let inner_indent = " ".repeat(indent + 1);
|
|
172
|
+
let mut parts = Vec::new();
|
|
173
|
+
|
|
174
|
+
for prop in props {
|
|
175
|
+
if !prop.optional {
|
|
176
|
+
parts.push(format!(
|
|
177
|
+
"{}{}: {}",
|
|
178
|
+
inner_indent,
|
|
179
|
+
prop.name,
|
|
180
|
+
generate_sample_value(&prop.type_info, indent + 1)
|
|
181
|
+
));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if parts.is_empty() {
|
|
186
|
+
"{}".to_string()
|
|
187
|
+
} else {
|
|
188
|
+
format!("{{\n{},\n{}}}", parts.join(",\n"), indent_str)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#[cfg(test)]
|
|
193
|
+
mod tests {
|
|
194
|
+
use super::*;
|
|
195
|
+
use crate::route_compiler::{parse_path_segments, RouteEntry, RouteTypeInfo, RouteObjectProp};
|
|
196
|
+
|
|
197
|
+
fn make_route(method: &str, path: &str, body: RouteTypeInfo) -> RouteEntry {
|
|
198
|
+
RouteEntry {
|
|
199
|
+
method: method.to_string(),
|
|
200
|
+
path: path.to_string(),
|
|
201
|
+
segments: parse_path_segments(path),
|
|
202
|
+
handler_ref: format!("Test#{} {}", method, path),
|
|
203
|
+
params_type: if path.contains(':') {
|
|
204
|
+
RouteTypeInfo::ObjectLiteral(vec![RouteObjectProp {
|
|
205
|
+
name: "id".to_string(),
|
|
206
|
+
type_info: RouteTypeInfo::Primitive("string".into()),
|
|
207
|
+
optional: false,
|
|
208
|
+
}])
|
|
209
|
+
} else {
|
|
210
|
+
RouteTypeInfo::Void
|
|
211
|
+
},
|
|
212
|
+
query_type: RouteTypeInfo::Void,
|
|
213
|
+
body_type: body,
|
|
214
|
+
response_type: RouteTypeInfo::Void,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn test_generate_stubs_basic() {
|
|
220
|
+
let routes = vec![
|
|
221
|
+
make_route("GET", "/users", RouteTypeInfo::Void),
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
let output = generate_test_stubs(&routes);
|
|
225
|
+
|
|
226
|
+
assert!(output.contains("AUTO-GENERATED"));
|
|
227
|
+
assert!(output.contains("describe(\"GET /users\""));
|
|
228
|
+
assert!(output.contains("accepts valid request"));
|
|
229
|
+
assert!(output.contains("client.get(\"/users\")"));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#[test]
|
|
233
|
+
fn test_generate_stubs_with_body() {
|
|
234
|
+
let body = RouteTypeInfo::ObjectLiteral(vec![
|
|
235
|
+
RouteObjectProp {
|
|
236
|
+
name: "email".to_string(),
|
|
237
|
+
type_info: RouteTypeInfo::Primitive("string".into()),
|
|
238
|
+
optional: false,
|
|
239
|
+
},
|
|
240
|
+
RouteObjectProp {
|
|
241
|
+
name: "name".to_string(),
|
|
242
|
+
type_info: RouteTypeInfo::Primitive("string".into()),
|
|
243
|
+
optional: false,
|
|
244
|
+
},
|
|
245
|
+
]);
|
|
246
|
+
let routes = vec![
|
|
247
|
+
make_route("POST", "/users", body),
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
let output = generate_test_stubs(&routes);
|
|
251
|
+
|
|
252
|
+
assert!(output.contains("client.post(\"/users\""));
|
|
253
|
+
assert!(output.contains("body:"));
|
|
254
|
+
assert!(output.contains("email:"));
|
|
255
|
+
assert!(output.contains("rejects missing required fields"));
|
|
256
|
+
assert!(output.contains("body: {}"));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[test]
|
|
260
|
+
fn test_generate_stubs_with_params() {
|
|
261
|
+
let routes = vec![
|
|
262
|
+
make_route("GET", "/users/:id", RouteTypeInfo::Void),
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
let output = generate_test_stubs(&routes);
|
|
266
|
+
|
|
267
|
+
assert!(output.contains("GET /users/:id"));
|
|
268
|
+
assert!(output.contains("test-id"));
|
|
269
|
+
assert!(output.contains("handles path parameters"));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#[test]
|
|
273
|
+
fn test_generate_stubs_multiple_routes() {
|
|
274
|
+
let routes = vec![
|
|
275
|
+
make_route("GET", "/users", RouteTypeInfo::Void),
|
|
276
|
+
make_route("POST", "/users", RouteTypeInfo::ObjectLiteral(vec![
|
|
277
|
+
RouteObjectProp {
|
|
278
|
+
name: "email".to_string(),
|
|
279
|
+
type_info: RouteTypeInfo::Primitive("string".into()),
|
|
280
|
+
optional: false,
|
|
281
|
+
},
|
|
282
|
+
])),
|
|
283
|
+
make_route("DELETE", "/users/:id", RouteTypeInfo::Void),
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
let output = generate_test_stubs(&routes);
|
|
287
|
+
|
|
288
|
+
assert!(output.contains("GET /users"));
|
|
289
|
+
assert!(output.contains("POST /users"));
|
|
290
|
+
assert!(output.contains("DELETE /users/:id"));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#[test]
|
|
294
|
+
fn test_convert_path_params() {
|
|
295
|
+
assert_eq!(convert_path_params("/users"), "/users");
|
|
296
|
+
assert_eq!(convert_path_params("/users/:id"), "/users/test-id");
|
|
297
|
+
assert_eq!(convert_path_params("/users/:userId/posts/:postId"), "/users/test-id/posts/test-id");
|
|
298
|
+
assert_eq!(convert_path_params("/files/*path"), "/files/test/wildcard/path");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#[test]
|
|
302
|
+
fn test_generate_sample_value_primitives() {
|
|
303
|
+
assert_eq!(generate_sample_value(&RouteTypeInfo::Primitive("string".into()), 0), "\"test-value\"");
|
|
304
|
+
assert_eq!(generate_sample_value(&RouteTypeInfo::Primitive("number".into()), 0), "42");
|
|
305
|
+
assert_eq!(generate_sample_value(&RouteTypeInfo::Primitive("boolean".into()), 0), "true");
|
|
306
|
+
assert_eq!(generate_sample_value(&RouteTypeInfo::Void, 0), "undefined");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#[test]
|
|
310
|
+
fn test_generate_imports() {
|
|
311
|
+
let routes = vec![make_route("GET", "/health", RouteTypeInfo::Void)];
|
|
312
|
+
let output = generate_test_stubs(&routes);
|
|
313
|
+
|
|
314
|
+
assert!(output.contains("import { describe, it, expect }"));
|
|
315
|
+
assert!(output.contains("import { createTestClient }"));
|
|
316
|
+
assert!(output.contains("const client = createTestClient()"));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
use serde::{Deserialize, Serialize};
|
|
3
|
+
use swc_common::comments::{Comment, CommentKind, SingleThreadedComments, Comments};
|
|
4
|
+
use swc_common::Spanned;
|
|
5
|
+
use swc_ecma_ast::*;
|
|
6
|
+
|
|
7
|
+
/// Metadata about a single property in a type
|
|
8
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
9
|
+
pub struct PropertyMetadata {
|
|
10
|
+
#[serde(rename = "type")]
|
|
11
|
+
pub type_str: String,
|
|
12
|
+
pub optional: bool,
|
|
13
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
14
|
+
pub jsdoc: Option<HashMap<String, String>>,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// Metadata about a single extracted type (matches @typokit/types TypeMetadata)
|
|
18
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
19
|
+
pub struct TypeMetadata {
|
|
20
|
+
pub name: String,
|
|
21
|
+
pub properties: HashMap<String, PropertyMetadata>,
|
|
22
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
23
|
+
pub jsdoc: Option<HashMap<String, String>>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Extract all JSDoc tags from a comment block
|
|
27
|
+
fn parse_jsdoc_tags(comment: &str) -> HashMap<String, String> {
|
|
28
|
+
let mut tags: HashMap<String, String> = HashMap::new();
|
|
29
|
+
|
|
30
|
+
for line in comment.lines() {
|
|
31
|
+
let trimmed = line.trim().trim_start_matches('*').trim();
|
|
32
|
+
// Find all @tag occurrences on this line
|
|
33
|
+
let mut rest = trimmed;
|
|
34
|
+
while let Some(at_pos) = rest.find('@') {
|
|
35
|
+
rest = &rest[at_pos + 1..];
|
|
36
|
+
let parts: Vec<&str> = rest.splitn(2, char::is_whitespace).collect();
|
|
37
|
+
let tag_name = parts[0].to_string();
|
|
38
|
+
let tag_value_str = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
|
39
|
+
// The tag value is everything up to the next @tag or end of line
|
|
40
|
+
let value = if let Some(next_at) = tag_value_str.find('@') {
|
|
41
|
+
tag_value_str[..next_at].trim().to_string()
|
|
42
|
+
} else {
|
|
43
|
+
tag_value_str.to_string()
|
|
44
|
+
};
|
|
45
|
+
tags.insert(tag_name, value);
|
|
46
|
+
// Advance rest past this tag's value to find next @
|
|
47
|
+
rest = parts.get(1).map(|s| *s).unwrap_or("");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
tags
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Get leading comments for a span position
|
|
55
|
+
fn get_leading_comments(comments: &SingleThreadedComments, span: &dyn Spanned) -> Vec<Comment> {
|
|
56
|
+
let lo = span.span().lo;
|
|
57
|
+
comments.get_leading(lo).unwrap_or_default()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Convert a TypeScript type annotation to a string representation
|
|
61
|
+
fn ts_type_to_string(ts_type: &TsType) -> String {
|
|
62
|
+
match ts_type {
|
|
63
|
+
TsType::TsKeywordType(kw) => match kw.kind {
|
|
64
|
+
TsKeywordTypeKind::TsStringKeyword => "string".to_string(),
|
|
65
|
+
TsKeywordTypeKind::TsNumberKeyword => "number".to_string(),
|
|
66
|
+
TsKeywordTypeKind::TsBooleanKeyword => "boolean".to_string(),
|
|
67
|
+
TsKeywordTypeKind::TsVoidKeyword => "void".to_string(),
|
|
68
|
+
TsKeywordTypeKind::TsNullKeyword => "null".to_string(),
|
|
69
|
+
TsKeywordTypeKind::TsUndefinedKeyword => "undefined".to_string(),
|
|
70
|
+
TsKeywordTypeKind::TsAnyKeyword => "any".to_string(),
|
|
71
|
+
TsKeywordTypeKind::TsUnknownKeyword => "unknown".to_string(),
|
|
72
|
+
TsKeywordTypeKind::TsNeverKeyword => "never".to_string(),
|
|
73
|
+
TsKeywordTypeKind::TsBigIntKeyword => "bigint".to_string(),
|
|
74
|
+
TsKeywordTypeKind::TsSymbolKeyword => "symbol".to_string(),
|
|
75
|
+
TsKeywordTypeKind::TsObjectKeyword => "object".to_string(),
|
|
76
|
+
_ => "unknown".to_string(),
|
|
77
|
+
},
|
|
78
|
+
TsType::TsTypeRef(type_ref) => {
|
|
79
|
+
let name = match &type_ref.type_name {
|
|
80
|
+
TsEntityName::Ident(ident) => ident.sym.to_string(),
|
|
81
|
+
TsEntityName::TsQualifiedName(qn) => format!("{}.{}", ts_entity_name_to_string(&TsEntityName::TsQualifiedName(qn.clone())), ""),
|
|
82
|
+
};
|
|
83
|
+
if let Some(type_params) = &type_ref.type_params {
|
|
84
|
+
let params: Vec<String> = type_params.params.iter().map(|p| ts_type_to_string(p)).collect();
|
|
85
|
+
format!("{}<{}>", name, params.join(", "))
|
|
86
|
+
} else {
|
|
87
|
+
name
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
TsType::TsArrayType(arr) => format!("{}[]", ts_type_to_string(&arr.elem_type)),
|
|
91
|
+
TsType::TsUnionOrIntersectionType(u) => match u {
|
|
92
|
+
TsUnionOrIntersectionType::TsUnionType(union) => {
|
|
93
|
+
let types: Vec<String> = union.types.iter().map(|t| ts_type_to_string(t)).collect();
|
|
94
|
+
types.join(" | ")
|
|
95
|
+
}
|
|
96
|
+
TsUnionOrIntersectionType::TsIntersectionType(inter) => {
|
|
97
|
+
let types: Vec<String> = inter.types.iter().map(|t| ts_type_to_string(t)).collect();
|
|
98
|
+
types.join(" & ")
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
TsType::TsLitType(lit) => match &lit.lit {
|
|
102
|
+
TsLit::Str(s) => format!("\"{}\"", s.value.to_string_lossy()),
|
|
103
|
+
TsLit::Number(n) => n.value.to_string(),
|
|
104
|
+
TsLit::Bool(b) => b.value.to_string(),
|
|
105
|
+
_ => "unknown".to_string(),
|
|
106
|
+
},
|
|
107
|
+
TsType::TsParenthesizedType(paren) => format!("({})", ts_type_to_string(&paren.type_ann)),
|
|
108
|
+
TsType::TsOptionalType(opt) => format!("{}?", ts_type_to_string(&opt.type_ann)),
|
|
109
|
+
TsType::TsTupleType(tuple) => {
|
|
110
|
+
let elems: Vec<String> = tuple.elem_types.iter().map(|e| ts_type_to_string(&e.ty)).collect();
|
|
111
|
+
format!("[{}]", elems.join(", "))
|
|
112
|
+
}
|
|
113
|
+
TsType::TsFnOrConstructorType(_) => "Function".to_string(),
|
|
114
|
+
TsType::TsTypeLit(_) => "object".to_string(),
|
|
115
|
+
_ => "unknown".to_string(),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Helper to convert TsEntityName to string
|
|
120
|
+
fn ts_entity_name_to_string(name: &TsEntityName) -> String {
|
|
121
|
+
match name {
|
|
122
|
+
TsEntityName::Ident(ident) => ident.sym.to_string(),
|
|
123
|
+
TsEntityName::TsQualifiedName(qn) => {
|
|
124
|
+
format!("{}.{}", ts_entity_name_to_string(&qn.left), qn.right.sym)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Extract type metadata from all interfaces in a module
|
|
130
|
+
pub fn extract_types(module: &Module, comments: &SingleThreadedComments) -> HashMap<String, TypeMetadata> {
|
|
131
|
+
let mut types: HashMap<String, TypeMetadata> = HashMap::new();
|
|
132
|
+
|
|
133
|
+
for item in &module.body {
|
|
134
|
+
match item {
|
|
135
|
+
ModuleItem::Stmt(Stmt::Decl(Decl::TsInterface(iface))) => {
|
|
136
|
+
let metadata = extract_interface(iface, comments);
|
|
137
|
+
types.insert(metadata.name.clone(), metadata);
|
|
138
|
+
}
|
|
139
|
+
ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => {
|
|
140
|
+
if let Decl::TsInterface(iface) = &export.decl {
|
|
141
|
+
let metadata = extract_interface(iface, comments);
|
|
142
|
+
types.insert(metadata.name.clone(), metadata);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
_ => {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
types
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Extract metadata from a single interface declaration
|
|
153
|
+
fn extract_interface(iface: &TsInterfaceDecl, comments: &SingleThreadedComments) -> TypeMetadata {
|
|
154
|
+
let name = iface.id.sym.to_string();
|
|
155
|
+
|
|
156
|
+
// Extract interface-level JSDoc tags
|
|
157
|
+
let leading = get_leading_comments(comments, iface);
|
|
158
|
+
let iface_jsdoc = extract_jsdoc_from_comments(&leading);
|
|
159
|
+
|
|
160
|
+
let mut properties: HashMap<String, PropertyMetadata> = HashMap::new();
|
|
161
|
+
|
|
162
|
+
for member in &iface.body.body {
|
|
163
|
+
if let TsTypeElement::TsPropertySignature(prop) = member {
|
|
164
|
+
let prop_name = match &*prop.key {
|
|
165
|
+
Expr::Ident(ident) => ident.sym.to_string(),
|
|
166
|
+
_ => continue,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
let type_str = prop
|
|
170
|
+
.type_ann
|
|
171
|
+
.as_ref()
|
|
172
|
+
.map(|ann| ts_type_to_string(&ann.type_ann))
|
|
173
|
+
.unwrap_or_else(|| "unknown".to_string());
|
|
174
|
+
|
|
175
|
+
let optional = prop.optional;
|
|
176
|
+
|
|
177
|
+
// Extract property-level JSDoc tags
|
|
178
|
+
let prop_leading = get_leading_comments(comments, prop);
|
|
179
|
+
let prop_jsdoc = extract_jsdoc_from_comments(&prop_leading);
|
|
180
|
+
|
|
181
|
+
properties.insert(
|
|
182
|
+
prop_name,
|
|
183
|
+
PropertyMetadata {
|
|
184
|
+
type_str,
|
|
185
|
+
optional,
|
|
186
|
+
jsdoc: if prop_jsdoc.is_empty() { None } else { Some(prop_jsdoc) },
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
TypeMetadata {
|
|
193
|
+
name,
|
|
194
|
+
properties,
|
|
195
|
+
jsdoc: if iface_jsdoc.is_empty() { None } else { Some(iface_jsdoc) },
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// Extract JSDoc tags from comment list
|
|
200
|
+
fn extract_jsdoc_from_comments(comments: &[Comment]) -> HashMap<String, String> {
|
|
201
|
+
let mut tags = HashMap::new();
|
|
202
|
+
for comment in comments {
|
|
203
|
+
if comment.kind == CommentKind::Block {
|
|
204
|
+
let parsed = parse_jsdoc_tags(&comment.text);
|
|
205
|
+
tags.extend(parsed);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
tags
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[cfg(test)]
|
|
212
|
+
mod tests {
|
|
213
|
+
use super::*;
|
|
214
|
+
use crate::parser::parse_typescript;
|
|
215
|
+
|
|
216
|
+
#[test]
|
|
217
|
+
fn test_extract_simple_interface() {
|
|
218
|
+
let source = r#"
|
|
219
|
+
interface User {
|
|
220
|
+
id: string;
|
|
221
|
+
name: string;
|
|
222
|
+
age: number;
|
|
223
|
+
active: boolean;
|
|
224
|
+
}
|
|
225
|
+
"#;
|
|
226
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
227
|
+
let types = extract_types(&parsed.module, &parsed.comments);
|
|
228
|
+
|
|
229
|
+
assert!(types.contains_key("User"));
|
|
230
|
+
let user = &types["User"];
|
|
231
|
+
assert_eq!(user.name, "User");
|
|
232
|
+
assert_eq!(user.properties.len(), 4);
|
|
233
|
+
assert_eq!(user.properties["id"].type_str, "string");
|
|
234
|
+
assert_eq!(user.properties["age"].type_str, "number");
|
|
235
|
+
assert_eq!(user.properties["active"].type_str, "boolean");
|
|
236
|
+
assert!(!user.properties["id"].optional);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[test]
|
|
240
|
+
fn test_extract_optional_properties() {
|
|
241
|
+
let source = r#"
|
|
242
|
+
interface Profile {
|
|
243
|
+
bio?: string;
|
|
244
|
+
avatar?: string;
|
|
245
|
+
required: number;
|
|
246
|
+
}
|
|
247
|
+
"#;
|
|
248
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
249
|
+
let types = extract_types(&parsed.module, &parsed.comments);
|
|
250
|
+
|
|
251
|
+
let profile = &types["Profile"];
|
|
252
|
+
assert!(profile.properties["bio"].optional);
|
|
253
|
+
assert!(profile.properties["avatar"].optional);
|
|
254
|
+
assert!(!profile.properties["required"].optional);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#[test]
|
|
258
|
+
fn test_extract_jsdoc_table_tag() {
|
|
259
|
+
let source = r#"
|
|
260
|
+
/**
|
|
261
|
+
* @table users
|
|
262
|
+
*/
|
|
263
|
+
interface User {
|
|
264
|
+
/** @id */
|
|
265
|
+
id: string;
|
|
266
|
+
name: string;
|
|
267
|
+
}
|
|
268
|
+
"#;
|
|
269
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
270
|
+
let types = extract_types(&parsed.module, &parsed.comments);
|
|
271
|
+
|
|
272
|
+
let user = &types["User"];
|
|
273
|
+
let jsdoc = user.jsdoc.as_ref().unwrap();
|
|
274
|
+
assert_eq!(jsdoc["table"], "users");
|
|
275
|
+
|
|
276
|
+
let id_jsdoc = user.properties["id"].jsdoc.as_ref().unwrap();
|
|
277
|
+
assert!(id_jsdoc.contains_key("id"));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#[test]
|
|
281
|
+
fn test_extract_all_jsdoc_tags() {
|
|
282
|
+
let source = r#"
|
|
283
|
+
/**
|
|
284
|
+
* @table users
|
|
285
|
+
*/
|
|
286
|
+
interface User {
|
|
287
|
+
/** @id @generated */
|
|
288
|
+
id: string;
|
|
289
|
+
/** @format email @unique */
|
|
290
|
+
email: string;
|
|
291
|
+
/** @minLength 2 @maxLength 100 */
|
|
292
|
+
name: string;
|
|
293
|
+
/** @default now() @onUpdate now() */
|
|
294
|
+
updatedAt: string;
|
|
295
|
+
}
|
|
296
|
+
"#;
|
|
297
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
298
|
+
let types = extract_types(&parsed.module, &parsed.comments);
|
|
299
|
+
|
|
300
|
+
let user = &types["User"];
|
|
301
|
+
let id_jsdoc = user.properties["id"].jsdoc.as_ref().unwrap();
|
|
302
|
+
assert!(id_jsdoc.contains_key("id"));
|
|
303
|
+
assert!(id_jsdoc.contains_key("generated"));
|
|
304
|
+
|
|
305
|
+
let email_jsdoc = user.properties["email"].jsdoc.as_ref().unwrap();
|
|
306
|
+
assert_eq!(email_jsdoc["format"], "email");
|
|
307
|
+
assert!(email_jsdoc.contains_key("unique"));
|
|
308
|
+
|
|
309
|
+
let name_jsdoc = user.properties["name"].jsdoc.as_ref().unwrap();
|
|
310
|
+
assert_eq!(name_jsdoc["minLength"], "2");
|
|
311
|
+
assert_eq!(name_jsdoc["maxLength"], "100");
|
|
312
|
+
|
|
313
|
+
let updated_jsdoc = user.properties["updatedAt"].jsdoc.as_ref().unwrap();
|
|
314
|
+
assert_eq!(updated_jsdoc["default"], "now()");
|
|
315
|
+
assert_eq!(updated_jsdoc["onUpdate"], "now()");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#[test]
|
|
319
|
+
fn test_extract_exported_interface() {
|
|
320
|
+
let source = r#"
|
|
321
|
+
export interface Post {
|
|
322
|
+
id: string;
|
|
323
|
+
title: string;
|
|
324
|
+
}
|
|
325
|
+
"#;
|
|
326
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
327
|
+
let types = extract_types(&parsed.module, &parsed.comments);
|
|
328
|
+
|
|
329
|
+
assert!(types.contains_key("Post"));
|
|
330
|
+
assert_eq!(types["Post"].properties.len(), 2);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#[test]
|
|
334
|
+
fn test_extract_complex_types() {
|
|
335
|
+
let source = r#"
|
|
336
|
+
interface Complex {
|
|
337
|
+
tags: string[];
|
|
338
|
+
metadata: Record<string, unknown>;
|
|
339
|
+
status: "active" | "inactive";
|
|
340
|
+
nested?: Array<string>;
|
|
341
|
+
}
|
|
342
|
+
"#;
|
|
343
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
344
|
+
let types = extract_types(&parsed.module, &parsed.comments);
|
|
345
|
+
|
|
346
|
+
let complex = &types["Complex"];
|
|
347
|
+
assert_eq!(complex.properties["tags"].type_str, "string[]");
|
|
348
|
+
assert_eq!(complex.properties["metadata"].type_str, "Record<string, unknown>");
|
|
349
|
+
assert_eq!(complex.properties["status"].type_str, "\"active\" | \"inactive\"");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[test]
|
|
353
|
+
fn test_extract_multiple_interfaces() {
|
|
354
|
+
let source = r#"
|
|
355
|
+
interface User {
|
|
356
|
+
id: string;
|
|
357
|
+
}
|
|
358
|
+
interface Post {
|
|
359
|
+
id: string;
|
|
360
|
+
authorId: string;
|
|
361
|
+
}
|
|
362
|
+
"#;
|
|
363
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
364
|
+
let types = extract_types(&parsed.module, &parsed.comments);
|
|
365
|
+
|
|
366
|
+
assert_eq!(types.len(), 2);
|
|
367
|
+
assert!(types.contains_key("User"));
|
|
368
|
+
assert!(types.contains_key("Post"));
|
|
369
|
+
}
|
|
370
|
+
}
|