⚓ Oxc for Ruby
A collection of high-performance tools for JavaScript and TypeScript written in Rust.
What is Oxc for Ruby?
Ruby bindings for Oxc, a collection of high-performance tools for JavaScript and TypeScript written in Rust. Parse, transform and minify JavaScript from Ruby, without the need for a JavaScript runtime.
Everything here is Oxc doing the work. For what the options mean and what it can do, oxc.rs is the reference.
Installation
bundle add oxc
Anywhere a precompiled gem is not published, the gem builds from source and needs the Rust toolchain at 1.96 or newer.
Usage
Minifying
Oxc.minify("const x = 1; console.log(x)").code
#=> "console.log(1);"
Compressing and mangling are both on. Either can be switched off, or given settings of its own.
Oxc.minify(source, compress: false).code
Oxc.minify(source, mangle: { top_level: false, reserved: ["exports"] }).code
Oxc.minify(source, compress: { drop_console: true, drop_debugger: false }).code
Transforming
transform compiles TypeScript and JSX away, and lowers what a browser you support cannot read. It leaves the output readable unless it is asked for otherwise.
Oxc.transform("const x: number = 1; console.log(x)", filename: "app.ts").code
#=> "const x = 1;\nconsole.log(x);\n"
Oxc.transform("const f = (a) => a ** 2; foo(f)", target: "es2015").code
#=> "const f = (a) => Math.pow(a, 2);\nfoo(f);\n"
Oxc.transform(source, filename: "app.jsx", source_type: "module").code
#=> "import { jsx as _jsx } from \"react/jsx-runtime\";\n..."
Minifying in the same call reads the source once instead of twice. The minifier lowers to the same target, so it never undoes the lowering the transform just did.
Oxc.transform(source, filename: "app.ts", target: "es2020", minify: true).code
define replaces a name wherever it appears, and whatever that makes unreachable is dropped with it. inject adds an import for a name the source used without importing.
Oxc.transform("if (DEBUG) { log() }", define: { "DEBUG" => "false" }).code
#=> ""
Oxc.transform("foo(process)", inject: { "process" => "node:process" }, source_type: "module").code
#=> "import process from \"node:process\";\nfoo(process);\n"
Declaration files
Asking for a declaration writes the .d.ts beside the code, from the types it just stripped.
result = Oxc.transform(source, filename: "add.ts", source_type: "module", typescript: { declaration: true })
result.code #=> "export const add = (a, b) => a + b;\n"
result.declaration #=> "export declare const add: (a: number, b: number) => number;\n"
declaration_map comes with it when sourcemap: true is set.
Decorators
Oxc.transform(source, filename: "a.ts", decorator: { legacy: true, emit_decorator_metadata: true }).code
legacy is the version of decorators TypeScript shipped before the standard, matching experimentalDecorators.
Runtime helpers
Lowering sometimes needs a helper function, and by default oxc imports it from the @oxc-project/runtime npm package. In an application with no npm packages that import resolves to nothing, so helpers_used says what a transform reached for.
result = Oxc.transform(source, target: "es2015")
result.helpers_used
#=> {"classPrivateFieldGet2" => "@oxc-project/runtime/helpers/classPrivateFieldGet2"}
Leaving target unset asks for no lowering, and needs no helpers. The other way out is external, which reads the helpers off a global babelHelpers object you provide.
Oxc.transform(source, target: "es2015", helpers: { mode: "external" }).code
An assumption can remove the need for a helper altogether. Telling oxc that public class fields shadow nothing lets it assign them directly, and the helper import goes away.
Oxc.transform("class A { x = 1 }", target: "es2015").helpers_used
#=> {"defineProperty" => "@oxc-project/runtime/helpers/defineProperty"}
Oxc.transform("class A { x = 1 }", target: "es2015", assumptions: { set_public_class_fields: true }).helpers_used
#=> {}
The assumptions are ignore_function_length, no_document_all, object_rest_no_symbols, pure_getters and set_public_class_fields. oxc says so when one of them is not implemented for the transform it would apply to.
Reading TypeScript
The grammar comes from the filename, and lang says so where the filename cannot.
Oxc.minify(source, filename: "app.ts").code
Oxc.minify(source, lang: "tsx").code
Source maps
map is the source map as JSON text, so a caller who only writes it out never pays to parse it.
result = Oxc.minify(source, filename: "app.js", sourcemap: true)
result.code
JSON.parse(result.map)
Keeping the output readable
Oxc.minify("const x = 1; foo(x)", codegen: { remove_whitespace: false }).code
#=> "foo(1);\n"
Legal comments
A legal comment is one carrying @license or @preserve, or starting with //! or /*!. They can stay inline, move to the end, or come back separately.
result = Oxc.minify("/*! (c) me */ foo()", codegen: { legal_comments: "external" })
result.code #=> "foo();"
result.legal_comments #=> ["/*! (c) me */"]
Parsing
parse answers the ESTree AST oxc read, as plain Ruby hashes and arrays.
program = Oxc.parse("let a = 1").program
program["type"] #=> "Program"
program["body"].first["kind"] #=> "let"
program["body"].first["declarations"].first["id"]["name"] #=> "a"
Parsing never raises for source it could not read. The parser recovers, so what it could not read comes back in errors, and panicked? says whether it gave up. validate! raises on demand.
parsed = Oxc.parse("const x = ;")
parsed.errors.map(&:message) #=> ["Unexpected token"]
parsed.panicked? #=> true
parsed.validate! #=> raises Oxc::SyntaxError
The AST is by far the largest thing crossing the boundary, so ast: false skips building it. Use it when only the diagnostics matter.
Oxc.parse(source, ast: false).errors?
Comments come back beside the AST, and a hashbang reads as the line comment it looks like.
Oxc.parse("// hi\nfoo() /* there */").comments.map { |comment| [comment.type, comment.value] }
#=> [["Line", " hi"], ["Block", " there "]]
A few more knobs: ranges: true adds a range pair to every node, preserve_parens: false drops the ParenthesizedExpression wrappers, ast_type: "js" leaves the TypeScript properties off a TypeScript AST, and semantic_errors: true reports what only semantic analysis can see.
Oxc.parse("let a; let a;", semantic_errors: true).errors.map(&:message)
#=> ["Identifier `a` has already been declared"]
Walking the AST
root answers the program as an Oxc::Node, which walks, reads its fields by name, and knows what it sits inside.
root = Oxc.parse(source).root
root.type #=> "Program"
root.keys #=> the ESTree fields this node carries
root.fields #=> those fields and their values, without the span
root.child_nodes #=> the nodes directly under it
root.every("Identifier") #=> every identifier in the file
root.at(offset) #=> the innermost node covering a byte offset
root.each #=> an Enumerator over every node
Inspecting a node shows every field it carries, so there is always something to reach for next.
#<Oxc::Node VariableDeclaration range=[0, 13] kind="let" declarations=[... 1 item]>
#<Oxc::Node VariableDeclarator range=[4, 13] id=#<Oxc::Node Identifier> init=#<Oxc::Node Literal>>
#<Oxc::Node Identifier range=[4, 9] name="count">
Every field is there, so what inspect prints and what keys answers never disagree. A field holding a node prints as that node's type, one holding a list prints how many it holds, and one holding nothing prints the nil, the false or the [] it holds.
An ESTree field always wins over a method of the gem's own, since name, attributes and children are all real fields. Identifier#name is the identifier's name, JSXElement#children is what the element wraps, and ImportDeclaration#attributes is the import's with clause. The walker spells its own versions underscored_type, to_h and child_nodes, which no ESTree field can be called.
A field comes back as a node when it holds one, so reads chain.
declaration = root.child_nodes.first
declaration.kind
#=> "let"
declaration.declarations.first.id.name
#=> "count"
ESTree names its fields in camelCase, and a field answers to its snake_case name too, so reading an AST does not mean writing JavaScript casing in Ruby.
node.type_annotation # the same field as node.typeAnnotation
node.super_class # superClass
root.source_type # sourceType
Patterns take either name as well, binding what you asked for.
node => { type_annotation: { type: }, readonly: }
ancestors is what a rewrite needs, since a reference sits inside the expression that has to be replaced.
reference = root.at(source.index("count +="))
reference.ancestors.find { |node| node.type == "AssignmentExpression" }.slice
#=> "count += 1"
A parse result keeps the source it read and hands it down to every node it builds, so slice answers with no argument at all.
parsed = Oxc.parse(source)
parsed.source
#=> "let count = 0\nfunction bump() { count += 1; render(count) }"
parsed.root.every("FunctionDeclaration").first.slice
#=> "function bump() { count += 1; render(count) }"
It still takes one, for a node assembled by hand or read against a different string.
node.slice(other_source)
Nodes pattern match, and nest, since a field holding a node comes back as one.
node => { type: "VariableDeclarator", id: { name: }, init: { value: } }
name #=> "count"
value #=> 0
root.select { |node| node in { type: "FunctionDeclaration", id: { name: /^handle/ } } }
deconstruct_keys is the whole protocol here. There is no deconstruct, since each yields every descendant and an array pattern over direct children would disagree with to_a.
to_h and to_json answer the ESTree the node wraps, which is what a snapshot test or a dump to another tool wants.
node.to_h
#=> { "type" => "Identifier", "name" => "count", "start" => 4, "end" => 9 }
node.to_json
#=> "{\"type\":\"Identifier\",\"name\":\"count\",\"start\":4,\"end\":9}"
Oxc::Visitor answers a node with the method named after its type, and walks through anything nothing answers.
class Reads < Oxc::Visitor
def visit_assignment_expression(node)
puts "#{node.left["name"]} #{node.operator}"
visit_children(node)
end
end
Reads.new.visit(Oxc.parse(source))
It takes a parse result or a node, so the common case needs no root. A result with no AST is nothing to walk and visits nothing.
There is one node class, not one per type, so a type the gem has never seen still walks and still answers. The types and their fields are ESTree. For the TypeScript and JSX nodes, which ESTree does not cover, oxc publishes the exact shapes it emits as @oxc-project/types.
Rewriting
Oxc::MutationVisitor records what to do to a node and splices the original text at the end, so everything it did not touch survives byte for byte, comments and indentation included.
class Renamer < Oxc::MutationVisitor
def visit_identifier(node)
replace(node, "renamed") if node["name"] == "count"
end
end
Renamer.new.rewrite("let count = 1 // keep me")
#=> "let renamed = 1 // keep me"
replace, remove, insert_before, insert_after and wrap are the operations, and each takes a node. Spans are exact, so removing debugger; removes what the node covered and leaves the newline after it alone.
Walking into a node that was replaced would edit text that is no longer there, so it stops. Two edits over the same span raise Oxc::MutationVisitor::Overlap instead of quietly producing something broken.
What goes in is text, so a node can become anything, including several statements or nothing at all. Nothing checks it on the way, so the result is read back afterwards and refused if it stopped being JavaScript.
Breaker.new.rewrite("foo(data)")
#=> Oxc::MutationVisitor::Invalid: what was rewritten no longer reads as JavaScript: Unexpected token
Pass verify: false for a fragment that was never going to parse on its own.
parsed reaches what the source parsed to, so a rewrite can ask for symbols and drive from them. That is the difference between rewriting a name and rewriting the right one.
class ToState < Oxc::MutationVisitor
def rewrite(source) = super(source, symbols: true)
def visit_identifier(node)
reference = parsed.symbols.fetch("declared").flat_map { |symbol| symbol["references"] }
.find { |found| found["start"] == node.start }
replace(node, %(state.get("#{node["name"]}"))) if reference && !reference["write"]
end
end
Drive from references, not from every Identifier. That is what keeps a declaration, a shadowed local, and a same-named property out of the rewrite.
What a file declared, and what it only used
symbols: true answers every binding with the span it was declared at and the spans of every reference to it, plus the names the file used without declaring.
symbols = Oxc.parse(source, symbols: true).symbols
symbols["declared"]
#=> [{"name" => "count", "root" => true, "declaration" => {...}, "references" => [{...}]}]
symbols["unresolved"]
#=> [{"name" => "fetch", "references" => [{...}]}]
Every reference says whether it read the name, wrote it, or both, which comes from oxc's scope analysis and not from the shape of the tree.
# let count = 0; function bump() { count += 1; render(count) }; count = 5
references.map { |reference| [reference["read"], reference["write"]] }
#=> [[true, true], [true, false], [false, true]]
root says whether the file declared it at the top level. Every span counts in UTF-8 bytes, so a rewrite can splice the source directly with String#byteslice, which is how the JavaScript ecosystem edits code without reprinting it.
What a file imports and exports
module_record: true answers the module's imports and exports without walking the AST for them.
record = Oxc.parse(source, source_type: "module", module_record: true).module_record
record["has_module_syntax"]
record["static_imports"].map { |import| import["module_request"]["value"] }
#=> ["./a", "./b"]
record["static_exports"]
record["dynamic_imports"]
record["import_metas"]
Every entry carries the span it was written at, so it maps back onto the source. An import entry says whether it was a TypeScript import type, and an export says which module it came from.
Reusing options
Oxc::Transformer and Oxc::Minifier each hold a set of options to use across many files. Options given to a call are merged over the ones the object was built with, so the ones that belong to the project are written once and the ones that belong to a single file travel with it.
transformer = Oxc::Transformer.new(target: "es2020", jsx: { runtime: "automatic" })
transformer.transform(source, filename: "app.tsx").code
transformer.with(minify: true).transform(source, filename: "app.ts").code
minifier = Oxc::Minifier.new(compress: { drop_console: true })
minifier.minify(source).code
Both answer call as well, so either can be handed to anything expecting something callable.
minifier.call(source).to_s
They are separate objects because they read separate options. minify reads compress and mangle, while transform reads target, jsx and the rest, which is the same split upstream draws between the oxc-minify and oxc-transform packages.
Options
| Option | Type | Description |
|---|---|---|
filename |
String |
The name to use in diagnostics, in the source map, and to read the grammar from. |
lang |
String |
js, jsx, ts, tsx or dts, when the filename does not say. |
source_type |
String |
script, module, commonjs or unambiguous. |
compress |
bool, Hash |
Whether to compress, and how. |
mangle |
bool, Hash |
Whether to rename what nothing outside can see, and how. |
codegen |
bool, Hash |
How to print the result. |
sourcemap |
bool |
Whether to answer a source map alongside the code. |
strict |
bool |
Whether to raise on any diagnostic. Off, only unusable output raises. |
parse reads these instead:
| Option | Type | Description |
|---|---|---|
ast |
bool |
Whether to build the AST at all. On by default. |
ast_type |
String |
js or ts, to include or leave out the TypeScript properties. |
ranges |
bool |
Whether every node carries a range pair. |
preserve_parens |
bool |
Whether parentheses become ParenthesizedExpression nodes. On by default. |
comments |
bool |
Whether to collect the comments. On by default. |
semantic_errors |
bool |
Whether to also report what semantic analysis finds. |
transform reads these instead of compress and mangle:
| Option | Type | Description |
|---|---|---|
target |
String, Array |
The ECMAScript version or browsers to lower for, such as es2015. |
jsx |
bool, Hash |
Whether to compile JSX, and how. false leaves it as written. |
typescript |
Hash |
How to compile TypeScript. |
helpers |
Hash |
Where the runtime helpers come from, runtime or external. |
define |
Hash |
Names to replace wherever they appear. |
inject |
Hash |
Names to import where the source used them without importing. |
minify |
bool, Hash |
Whether to minify in the same pass, and how. |
cwd |
String |
What relative paths in other options are relative to. |
An option nobody reads is refused, and so is one inside a nested hash:
Oxc.minify(source, nonsense: true)
#=> Oxc::OptionError: Unknown option: nonsense
Oxc.minify(source, compress: { nonsense: true })
#=> Oxc::OptionError: Invalid options: unknown field `nonsense`, expected one of `target`, ...
Results
Each call answers its own result, so no result carries a field the call that produced it can never fill.
Oxc.minifyanswers anOxc::MinifyResultOxc.transformanswers anOxc::TransformResultOxc.parseanswers anOxc::ParseResult
Oxc::MinifyResult and Oxc::TransformResult are both an Oxc::Result, so anything reading code or to_s takes either one. Oxc::ParseResult stands on its own, because a parse answers a tree and has no code to print.
What every result answers
result = Oxc.minify("const x = 1; console.log(x)")
result.diagnostics #=> everything oxc had to say
result.errors #=> the error-severity half of it
result.warnings #=> the warning-severity half
result.errors?
result.warnings?
result.panicked? #=> whether oxc gave up on the source
result.validate! #=> itself, or raises Oxc::SyntaxError
validate! means something slightly different for each. Oxc::MinifyResult and Oxc::TransformResult raise only when there is nothing usable to answer with, and strict: true widens that to any error at all. Oxc::ParseResult raises on any error, because a parse routinely answers a usable tree alongside them.
Minify and transform
result = Oxc.minify("const x = 1; console.log(x)")
result.code #=> "console.log(1);"
result.to_s #=> "console.log(1);"
result.map #=> nil, or the source map as JSON text
result.legal_comments #=> []
A transform adds what only a transform can answer.
result = Oxc.transform(source, filename: "app.ts", sourcemap: true, typescript: { declaration: true })
result.declaration #=> the .d.ts it wrote
result.declaration_map #=> its source map, as JSON text
result.helpers_used #=> the runtime helpers its lowering reached for
Parse
parsed = Oxc.parse(source, source_type: "module", module_record: true)
parsed.program #=> the ESTree AST, or nil when ast: false
parsed.module_record #=> the imports and exports, when asked for
parsed.symbols #=> the bindings and their references, when asked for
parsed.comments #=> Array[Oxc::Comment]
Diagnostics
oxc's parser recovers, so source it could not fully read still produces a result, and what it could not read comes back as diagnostics. When a call does raise, Oxc::SyntaxError carries the result it came from, whichever of the three that was.
begin
Oxc.minify("const x = ;", filename: "broken.js")
rescue Oxc::SyntaxError => e
e. #=> "Unexpected token"
e.diagnostics.first.codeframe #=> the frame below
e.result.panicked? #=> true
end
x Unexpected token
,-[broken.js:1:11]
1 | const x = ;
: ^
`----
A diagnostic's labels count in UTF-8 bytes, which is what oxc counts in and what Ruby slices by:
label = e.diagnostics.first.labels.first
label.start #=> 10
label.finish #=> 11
label.slice(source) #=> ";"
Development
The gem is a C extension over a Rust crate. rust/ builds a static library and generates the C header with cbindgen, ext/oxc/ wraps it, and lib/ is the Ruby API over that.
bin/setup
bundle exec rake
sig/ is generated from the #: annotations next to the code. Regenerate it with rake rbs after changing a signature, and CI checks that it matches.
Acknowledgements
Oxc is maintained at oxc-project/oxc and is part of VoidZero's toolchain for JavaScript. This gem only calls into it. Every parser, transformer and minifier feature comes from there.
Thank you to all of them.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/marcoroth/oxc-ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
Issues with parsing, transforming or minifying itself belong upstream, since this gem does none of that. Issues with the Ruby API, the build, or the bindings belong here.
License
The Ruby, C, and Rust code in this gem is available under the terms of the MIT License.
It builds against Oxc, which is MIT licensed and carries some Apache-2.0 code of its own. A copy of both travels with the gem in licenses/ so that whoever received it has the terms in hand.