Skip to main content

sim_codec_typescript/
lower.rs

1//! Direct, erasure-only lowering to the JavaScript expression graph.
2
3use sim_codec::{DecodeBudget, Input, Output, ReadCx};
4use sim_codec_javascript::{JavascriptBuilder, Origin, Span, Token, TokenKind};
5use sim_kernel::{
6    Error, Expr, LocatedExpr, LocatedExprTree, Origin as KernelOrigin, Result, SourceId,
7    Span as KernelSpan,
8};
9
10use crate::{
11    Language, Limits, SyntaxKind, SyntaxNode, SyntaxTree, TYPESCRIPT_CODEC_ID,
12    parse_module_with_limits,
13};
14
15/// A retained reference to erased annotation syntax.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct AnnotationReference {
18    /// Exact annotation location in the TypeScript source.
19    pub span: Span,
20    /// Parser context in which the annotation occurred.
21    pub context: Vec<String>,
22    /// Complete TypeScript-to-JavaScript derivation chain.
23    pub origin: Origin,
24}
25
26/// A located reason why syntax cannot be erased without a compiler decision.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct EvaluationGap {
29    /// Exact unsupported syntax location.
30    pub span: Span,
31    /// Stable syntax category.
32    pub construct: String,
33    /// Literal admission-rule explanation.
34    pub reason: String,
35}
36
37impl std::fmt::Display for EvaluationGap {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(
40            f,
41            "TypeScript evaluation gap at {}..{}: {} requires {}",
42            self.span.start, self.span.end, self.construct, self.reason
43        )
44    }
45}
46impl std::error::Error for EvaluationGap {}
47
48/// An admitted JavaScript graph plus lossless TypeScript provenance.
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct LoweredTypeScript {
51    /// The exact JavaScript expression graph built without emit or reparse.
52    pub javascript: Expr,
53    /// References to annotations erased from runtime syntax.
54    pub annotations: Vec<AnnotationReference>,
55    /// The original lossless TypeScript tree.
56    pub source_tree: SyntaxTree,
57}
58
59/// Apply the literal "requires no compiler decision" predicate and lower directly.
60pub fn lower_typescript(
61    tree: &SyntaxTree,
62) -> std::result::Result<LoweredTypeScript, EvaluationGap> {
63    if let Some(gap) = first_gap(tree) {
64        return Err(gap);
65    }
66    let erased = erased_tokens(tree);
67    let builder = JavascriptBuilder;
68    let javascript = builder.form(
69        "module",
70        erased
71            .iter()
72            .map(|token| {
73                builder.token(
74                    token_name(&token.kind),
75                    &tree.source()[token.span.start..token.span.end],
76                    executable(&token.kind),
77                )
78            })
79            .collect(),
80    );
81    let annotations = tree
82        .nodes
83        .iter()
84        .filter_map(|node| match node {
85            SyntaxNode::TypeScript {
86                kind: SyntaxKind::Annotation,
87                span,
88                context,
89            } => Some(AnnotationReference {
90                span: *span,
91                context: context.clone(),
92                origin: builder.derived_origin(
93                    "typescript",
94                    *span,
95                    Some(builder.derived_origin("javascript", *span, None)),
96                ),
97            }),
98            _ => None,
99        })
100        .collect();
101    Ok(LoweredTypeScript {
102        javascript,
103        annotations,
104        source_tree: tree.clone(),
105    })
106}
107
108fn first_gap(tree: &SyntaxTree) -> Option<EvaluationGap> {
109    let source = tree.source();
110    for token in significant(&tree.tokens) {
111        let text = token_text(source, token);
112        let reason = match text {
113            "enum" | "namespace" | "module" => {
114                Some("an emitter transform and runtime code generation")
115            }
116            "satisfies" | "asserts" => Some("a type-checker acceptance decision"),
117            "as" if !is_module_alias(source, token.span.start) => {
118                Some("a type-checker acceptance decision")
119            }
120            "@" => Some("a decorator transform and target selection"),
121            _ => None,
122        };
123        if let Some(reason) = reason {
124            return Some(gap(token.span, text, reason));
125        }
126    }
127    if tree.language == Language::Tsx
128        && let Some(span) = tree.nodes.iter().find_map(|node| match node {
129            SyntaxNode::TypeScript {
130                kind: SyntaxKind::Jsx,
131                span,
132                ..
133            } => Some(*span),
134            _ => None,
135        })
136    {
137        return Some(gap(
138            span,
139            "JSX/TSX",
140            "a JSX emitter transform and target selection",
141        ));
142    }
143    // Accessibility on constructor parameters creates fields and assignments.
144    for window in significant(&tree.tokens).windows(2) {
145        let first = token_text(source, window[0]);
146        if matches!(first, "public" | "private" | "protected")
147            && source[..window[0].span.start]
148                .rsplit_once("constructor(")
149                .is_some_and(|(_, tail)| !tail.contains(')'))
150        {
151            return Some(gap(
152                window[0].span,
153                "parameter property",
154                "an emitter transform and runtime assignment",
155            ));
156        }
157    }
158    None
159}
160
161fn erased_tokens(tree: &SyntaxTree) -> Vec<&Token> {
162    let tokens = significant(&tree.tokens);
163    let source = tree.source();
164    let mut erase = vec![false; tokens.len()];
165    let mut index = 0;
166    while index < tokens.len() {
167        let text = token_text(source, tokens[index]);
168        if matches!(text, "interface" | "type")
169            || (text == "declare"
170                && tokens.get(index + 1).is_some_and(|t| {
171                    matches!(
172                        token_text(source, t),
173                        "interface" | "class" | "function" | "const" | "let" | "var"
174                    )
175                }))
176        {
177            let end = declaration_end(&tokens, source, index);
178            erase[index..end].fill(true);
179            index = end;
180            continue;
181        }
182        if matches!(text, "readonly" | "abstract" | "override" | "declare") {
183            erase[index] = true;
184        }
185        if text == ":" {
186            let end = type_end(&tokens, source, index + 1);
187            erase[index..end].fill(true);
188            index = end;
189            continue;
190        }
191        if text == "<" && looks_like_generic(&tokens, source, index) {
192            let end = matching_angle(&tokens, source, index).map_or(index + 1, |x| x + 1);
193            erase[index..end].fill(true);
194            index = end;
195            continue;
196        }
197        index += 1;
198    }
199    tree.tokens
200        .iter()
201        .filter(|token| {
202            token.kind == TokenKind::Trivia
203                || tokens
204                    .iter()
205                    .position(|candidate| std::ptr::eq(*candidate, *token))
206                    .is_none_or(|i| !erase[i])
207        })
208        .collect()
209}
210
211fn is_module_alias(source: &str, at: usize) -> bool {
212    let statement = source[..at]
213        .rsplit_once([';', '\n'])
214        .map_or(&source[..at], |(_, tail)| tail);
215    statement
216        .split_whitespace()
217        .any(|word| matches!(word, "import" | "export"))
218}
219
220fn declaration_end(tokens: &[&Token], source: &str, start: usize) -> usize {
221    let mut braces = 0usize;
222    for (i, token) in tokens.iter().enumerate().skip(start) {
223        match token_text(source, token) {
224            "{" => braces += 1,
225            "}" if braces > 0 => {
226                braces -= 1;
227                if braces == 0 {
228                    return i + 1;
229                }
230            }
231            ";" if braces == 0 => return i + 1,
232            _ => {}
233        }
234    }
235    tokens.len()
236}
237fn type_end(tokens: &[&Token], source: &str, start: usize) -> usize {
238    let mut depth = 0usize;
239    for (i, token) in tokens.iter().enumerate().skip(start) {
240        match token_text(source, token) {
241            "{" if depth == 0 && i > start => return i,
242            "<" | "[" | "{" => depth += 1,
243            ">" | "]" | "}" if depth > 0 => depth -= 1,
244            "," | ")" | "=" | ";" if depth == 0 => return i,
245            _ => {}
246        }
247    }
248    tokens.len()
249}
250fn looks_like_generic(tokens: &[&Token], source: &str, at: usize) -> bool {
251    at > 0
252        && matches!(
253            tokens[at - 1].kind,
254            TokenKind::Identifier | TokenKind::Keyword
255        )
256        && matching_angle(tokens, source, at).is_some_and(|end| {
257            tokens
258                .get(end + 1)
259                .is_some_and(|t| matches!(token_text(source, t), "(" | "."))
260        })
261}
262fn matching_angle(tokens: &[&Token], source: &str, at: usize) -> Option<usize> {
263    let mut depth = 0usize;
264    for (i, token) in tokens.iter().enumerate().skip(at) {
265        match token_text(source, token) {
266            "<" => depth += 1,
267            ">" => {
268                depth -= 1;
269                if depth == 0 {
270                    return Some(i);
271                }
272            }
273            _ => {}
274        }
275    }
276    None
277}
278fn significant(tokens: &[Token]) -> Vec<&Token> {
279    tokens
280        .iter()
281        .filter(|t| !matches!(t.kind, TokenKind::Trivia | TokenKind::End))
282        .collect()
283}
284fn token_text<'a>(source: &'a str, token: &Token) -> &'a str {
285    &source[token.span.start..token.span.end]
286}
287fn gap(span: Span, construct: &str, reason: &str) -> EvaluationGap {
288    EvaluationGap {
289        span,
290        construct: construct.into(),
291        reason: reason.into(),
292    }
293}
294fn token_name(kind: &TokenKind) -> &'static str {
295    match kind {
296        TokenKind::Identifier => "identifier",
297        TokenKind::Keyword => "keyword",
298        TokenKind::Number => "number",
299        TokenKind::String => "string",
300        TokenKind::RegExp => "regexp",
301        TokenKind::Template => "template",
302        TokenKind::Punctuator => "punctuator",
303        TokenKind::Trivia => "trivia",
304        TokenKind::End => "end",
305    }
306}
307fn executable(kind: &TokenKind) -> bool {
308    matches!(
309        kind,
310        TokenKind::Identifier
311            | TokenKind::Number
312            | TokenKind::String
313            | TokenKind::RegExp
314            | TokenKind::Template
315            | TokenKind::Punctuator
316    )
317}
318
319/// Decode admitted TypeScript to its direct JavaScript graph.
320pub fn decode_typescript(
321    cx: &mut ReadCx<'_>,
322    source: &str,
323    budget: &mut DecodeBudget,
324) -> Result<Expr> {
325    budget.check_input_bytes(cx.codec, source.len())?;
326    if source.starts_with("__sim_expr__(") {
327        return sim_codec_javascript::decode_javascript(cx, source, budget);
328    }
329    let tree = parse_module_with_limits(source, parser_limits(budget))
330        .map_err(|e| codec_error(e.to_string()))?;
331    budget.check_tokens(cx.codec, tree.tokens.len())?;
332    lower_typescript(&tree)
333        .map(|x| x.javascript)
334        .map_err(|e| codec_error(e.to_string()))
335}
336/// Decode admitted TypeScript with a root source location.
337pub fn decode_typescript_located(
338    cx: &mut ReadCx<'_>,
339    source_id: impl Into<String>,
340    input: Input,
341) -> Result<LocatedExpr> {
342    let source = input_text(input)?;
343    let source_id = SourceId(source_id.into());
344    cx.cx.sources_mut().intern_text(source_id.clone(), &source);
345    let mut budget = DecodeBudget::new(cx.limits);
346    let expr = decode_typescript(cx, &source, &mut budget)?;
347    Ok(LocatedExpr {
348        expr,
349        origin: Some(origin(cx.codec, source_id, 0, source.len())),
350    })
351}
352/// Decode admitted TypeScript into a recursively located JavaScript tree.
353pub fn decode_typescript_tree(
354    cx: &mut ReadCx<'_>,
355    source_id: impl Into<String>,
356    input: Input,
357) -> Result<LocatedExprTree> {
358    let located = decode_typescript_located(cx, source_id, input)?;
359    let mut tree = LocatedExprTree::from_expr_recursive(located.expr);
360    tree.origin = located.origin;
361    Ok(tree)
362}
363/// Canonically encode JavaScript forms as TypeScript-compatible source, with tagged fallback.
364pub fn encode_typescript(expr: &Expr) -> Result<Output> {
365    if is_javascript_form(expr) {
366        let mut source = String::new();
367        encode_direct(expr, &mut source)?;
368        return Ok(Output::Text(source));
369    }
370    sim_codec_javascript::encode_javascript(expr)
371}
372fn is_javascript_form(expr: &Expr) -> bool {
373    matches!(expr, Expr::Call { operator, .. } if matches!(operator.as_ref(), Expr::Symbol(symbol) if symbol.namespace.as_deref().map(AsRef::as_ref) == Some("javascript")))
374}
375fn encode_direct(expr: &Expr, source: &mut String) -> Result<()> {
376    let Expr::Call { operator, args } = expr else {
377        return Err(codec_error("malformed JavaScript form in TypeScript graph"));
378    };
379    let Expr::Symbol(symbol) = operator.as_ref() else {
380        return Err(codec_error(
381            "malformed JavaScript operator in TypeScript graph",
382        ));
383    };
384    if symbol.namespace.as_deref().map(AsRef::as_ref) != Some("javascript") {
385        return Err(codec_error("non-JavaScript child in TypeScript graph"));
386    }
387    if symbol.name.as_ref() == "token" {
388        if let [Expr::Symbol(_), Expr::String(text), Expr::Bool(_)] = args.as_slice() {
389            source.push_str(text);
390            return Ok(());
391        }
392        return Err(codec_error(
393            "javascript/token expects kind, text, executable",
394        ));
395    }
396    for arg in args {
397        encode_direct(arg, source)?;
398    }
399    Ok(())
400}
401fn parser_limits(b: &DecodeBudget) -> Limits {
402    let l = b.limits();
403    Limits {
404        max_bytes: l.max_input_bytes,
405        max_nodes: l.max_tokens,
406        max_nesting: l.max_depth,
407    }
408}
409fn input_text(input: Input) -> Result<String> {
410    match input {
411        Input::Text(x) => Ok(x),
412        Input::Bytes(x) => String::from_utf8(x)
413            .map_err(|e| codec_error(format!("codec input is not valid UTF-8: {e}"))),
414    }
415}
416fn origin(codec: sim_kernel::CodecId, source: SourceId, start: usize, end: usize) -> KernelOrigin {
417    KernelOrigin {
418        codec,
419        source,
420        span: KernelSpan { start, end },
421        trivia: Vec::new(),
422    }
423}
424fn codec_error(message: impl Into<String>) -> Error {
425    Error::CodecError {
426        codec: TYPESCRIPT_CODEC_ID,
427        message: message.into(),
428    }
429}