oxc-project / oxc

⚓ A collection of JavaScript tools written in Rust.
https://oxc.rs
MIT License
11.91k stars 427 forks source link

Maybe bug: ImportOrExportKind sometimes reported as Value when should be Type on ImportDeclarationSpecifier::ImportSpecifier #6044

Closed tgrushka closed 3 weeks ago

tgrushka commented 3 weeks ago

Hi, great project! I'm working on a TypeScript to Rust types generator (why doesn't this exist yet? -- tried ts2rs crate but it's based on Pest & seems no longer maintained -- oxc seems much better for TypeScript -- really fast, don't have to write syntax rules, etc.).

Anyway, trying to figure out how to have an "entrypoint" .ts file in which I either import or export the types I want to convert, and found that an imported/exported type is detected as a ImportOrExportKind::Value rather than a ImportOrExportKind::Type unless very specific syntax is used:

Case 1: examples/axe/axe-types.ts - FAILS TEST ❌

import { RunOptions } from 'axe-core'

Case 2: examples/axe/axe-types.ts - FAILS TEST ❌

import type { RunOptions } from 'axe-core'

Case 3: examples/axe/axe-types.ts - PASSES TEST ✅

import { type RunOptions } from 'axe-core'

Maybe this is not a bug -- or less of a bug than I think it is -- because it seems you'd have to actually resolve the library to detect whether it is actually a Type or Value. So maybe I need to resolve the import/export in my crate to check whether it is a type or not.

In any case, if Case 1 is indeterminate, shouldn't Case 2 be able to pass, because type specifies everything in the curly braces -- or is this yet another TypeScript confusion? 🤔

Example structure: Create examples/axe folder. Add package.json and axe-types.ts (below) and run npm install. Add examples/minimal.rs file below and relevant dependencies to Cargo.toml.

Note that in axe-core/axe.d.ts, RunOptions is an interface:

  interface RunOptions {
    runOnly?: RunOnly | TagValue[] | string[] | string;
    rules?: RuleObject;
    reporter?: ReporterVersion | string;
    resultTypes?: resultGroups[];
...

examples/axe/package.json

{
  "dependencies": {
    "axe-core": "^4.10.0"
  }
}

examples/minimal.rs

use std::{fs, path::Path};

use oxc_allocator::Allocator;
use oxc_ast::{ast, visit::walk, Visit};
use oxc_parser::{ParseOptions, Parser};
use oxc_resolver::{ResolveOptions, Resolver};
use oxc_span::SourceType;

struct TypeScriptToRustVisitor;

impl<'a> Visit<'a> for TypeScriptToRustVisitor {
    fn visit_export_named_declaration(&mut self, it: &ast::ExportNamedDeclaration<'a>) {
        for spec in &it.specifiers {
            let exported_name = spec.exported.name().into_string();
            let export_kind = spec.export_kind;
            let local_name = spec.local.name().into_string();

            println!(
                "Found export: {:?} {} as {} from {:?}",
                export_kind, exported_name, local_name, it.source
            );

            assert_eq!(export_kind, ast::ImportOrExportKind::Type);
        }

        walk::walk_export_named_declaration(self, it);
    }

    fn visit_import_declaration(&mut self, it: &ast::ImportDeclaration<'a>) {
        let Some(specifiers) = &it.specifiers else {
            return;
        };

        for specifier in specifiers {
            if let ast::ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier {
                let imported_name = spec.imported.name().into_string();
                let import_kind = spec.import_kind;
                let local_name = spec.local.name.clone().into_string();
                let module_source = it.source.value.clone().into_string();

                println!(
                    "Found import: {:?} {} as {} from {}",
                    import_kind, imported_name, local_name, module_source
                );

                assert_eq!(import_kind, ast::ImportOrExportKind::Type);
            }
        }
        walk::walk_import_declaration(self, it);
    }
}

fn main() -> Result<(), String> {
    let entrypoint = String::from("examples/axe/axe-types.ts");

    let path = Path::new(&entrypoint).canonicalize().unwrap();
    assert!(&path.is_absolute(), "{path:?} must be an absolute path.");
    let dir = path
        .parent()
        .expect("Failed to get parent directory of path");
    let file = path.file_name().expect("Failed to get file name of path");

    let resolve_options = ResolveOptions {
        extensions: vec![".d.ts".into(), ".ts".into()],
        ..ResolveOptions::default()
    };

    let specifier = &format!("./{}", file.to_string_lossy());

    let resolver = Resolver::new(resolve_options);
    let resolution = resolver.resolve(dir, specifier).expect("resolve");
    println!("resolution: {:#?}", resolution);

    let path = resolution.full_path();

    let source_text =
        fs::read_to_string(&path).map_err(|_| format!("Not found: '{entrypoint}'"))?;
    let source_type = SourceType::from_path(&path).unwrap();

    let allocator = Allocator::default();
    let parser = Parser::new(&allocator, &source_text, source_type).with_options(ParseOptions {
        parse_regular_expression: false,
        preserve_parens: false,
        ..ParseOptions::default()
    });

    let ret = parser.parse();

    let mut visitor = TypeScriptToRustVisitor;
    visitor.visit_program(&ret.program);

    Ok(())
}
DonIsaac commented 3 weeks ago

Thanks for the in-depth description! I'm glad you're enjoying using oxc.

What you're experiencing is expected behavior. ImportOrExportKind corresponds to the type modifier on import/export statements, not the actual value of the symbol being imported/exported. I'll break it down:

Case 1: importing a type alias/interface without import type

Consider the following files:

// foo.ts
export const a = 1
export interface B {}
// bar.ts
import { a, B } from './foo'

Parsing and static analysis of each file is done in isolation from other files. When parsing bar.ts, we have no idea what kind of symbols a and B will be. I'll describe later what you can do if you need this information.

Case 2: declaration-level type imports

import type { RunOptions } from 'axe-core'

Here, the type modifier applies to the entire declaration, not just a single specifier. If you put this code into our playground, you'll find that ImportOrExportType on ImportDeclaration is (correctly) a Type, while ImportSpecifier is a Value.

Case 3: specifier-level type imports

import { type RunOptions } from 'axe-core'

Inverse of case two: the specifier is a Type while the entire declaration is Value. Playground link

Funny enough, cases 1 and two are meaningfully different. When compiled, case one gets completely removed, while case two gets compiled into

import {} from './foo'

This is because it is a value import declaration, and importing 'foo.ts' could have side effects.

Cross-Module Analysis.

Ok, now for a Tl;Dr of what you can do for point 1:

  1. Parse and analyze a module
  2. Use oxc-resolver to find what file is being imported for each ImportSpecifier
  3. Parse and analyze those files. Create a mapping from that ModuleRecord to its SymbolTable.
  4. Use flags from the imported SymbolTable when doing stuff in bar.ts

Hope this helps!