Rome is a toolchain where users can use only Rome to manage their code. On the other hand, users should be free to use only specific tools in their workflow, without too much hassle.
While the latter is desirable, using Rome as all-in-one tool is preferred on the long run because it creates a homogeneous user experience in terms of:
- performance
- error messaging
- caching
- overall workflow between tools
- good defaults
Considering what written above, the Node.js API should reflect what stated above.
From now on the document will use some terms, here's their context inside the document:
- Frontend: the code written for the runtimes (Node.js, Deno, browser, etc.);
- Rust Workspace: the main hub where the bulk of the logic resides. It's the Rust code that live
inside the
rome_service
crate; - Runtime Workspace: it's the shared code used by the different Frontends;
The first and foremost important tool that users should access to is Rome's compiler. The compiler, as this time, it's not ready yet - at least not all the features we intend to implement, but this was a design decision when it was first created.
Exposing the compiler should allow a user to do multiple things (not limited to this list):
- allow to transform some code (transplilation, minification, compile to another language, etc.)
- analyze some code (linter, control flow analysis, type check, etc.)
- bundle some code
- generate documentation
The end goal of how Rome can expose its compiler is via plugins. Plugins are a non-goal for this RFC, but this first proposal should start taking them into consideration.
As for today, Rome can expose a formatter and a linter (analyzer).
The APIs should only be a medium to eventually communicate with the Rust Workspace, which means that the APIs can't provide more features than what the Rust Workspace can provide.
While designing the APIs, it's important to take this in consideration.
As for today, different tools have different approaches
Their APIs are not aligned with the CLI. The CLI allows to format a single file, a list of files or
some code passed via stdin
. The APIs mostly orbit around formatting some content, and they are not
aware if prettier is installed already. This works well for most environments but the majority of the
work is responsibility of the user:
- read files and their contents
- resolve the configuration in case prettier is already installed in the project
Their APIs and CLI are almost aligned, meaning that the options passed via APIs are mapped 100% to the CLI, for example:
require('esbuild').buildSync({
entryPoints: ['app.js'],
outfile: 'out.js',
bundle: true,
platform: 'node',
external: ['fsevents'],
})
esbuild app.js --bundle --external:fsevents --platform=node
It seems to me that esbuild is not designed to be a standalone dev server, then caching strategies and whatnot are designed for this goal. I might be wrong.
esbuild
is mostly designed to be a bundler, so their APIs and design decisions might orbit
around that.
eslint
takes a different approach compared to prettier
. Their approach emulates their CLI but
with extra steps:
// 1. Create an instance.
const eslint = new ESLint();
// 2. Lint files.
const results = await eslint.lintFiles(["lib/**/*.js"]);
// 3. Format the results.
const formatter = await eslint.loadFormatter("stylish");
const resultText = formatter.format(results);
// 4. Output it.
console.log(resultText);
They use the instance pattern. The instance accept a bunch of options.
eslint lib/**/*.js
There's also one more thing to note, their Node.js API is designed mostly for plugin authors. From their website:
The purpose of the Node.js API is to allow plugin and tool authors to use the ESLint functionality directly, without going through the command line interface.
eslint
also allows to lint some content:
const results = await eslint.lintText(code, options);
They use the instance pattern:
let postcss = require('postcss')
postcss(plugins).process(css, { from, to }).then(result => {
console.log(result.css)
})
postcss input.css -o output.css
postcss src/**/*.css --base src --dir build
cat input.css | postcss -u autoprefixer > output.css
Even though the approach is slightly different from eslint
, the result is the same.
Here's a list possible proposals of how Rome could be used.
There might be various cases, but first we need to understand how Rome is designed, and make proposal based its design AND end-goal.
- Rome is configuration aware, meaning that when communicating with Rust Workspace,
all configuration defaults are automatically applied. If the APIs are run
inside a Rome project, the configuration is automatically picked up from the
rome.json
file. - Rome's APIs should reflect the CLIs commands and arguments. What it's possible to do via CLI, should be done also via APIs, but not vice-versa. This constraint would allow the team to first design and test the feature natively, and expose it once it's stable.
- Rome should be able, in the future, to expose plugins or a way to users to use its compiler capabilities
Point
2.
might not be true for ALL cases. For example, as for today, we can't format by range via CLI. Maybe this should be more of a guideline, and not a constraint.
Instance pattern
import { Rome } from "rome";
let rome = new Rome({});
The instance pattern allows for better extensions on the long run, and allows to play better with all the tools we want to expose.
import { Rome } from "rome";
const content = "function f() { return {} }";
const rome = new Rome({
formatter: {
lineWidth: 120
},
linter: {
recommended: false,
js: {
noDeadCode: "error"
}
}
});
const ast = rome.parseContent(content); // not part of this paragraph
const new_content = rome.formatContent(content); // not part of this paragraph
const diagnostics = rome.checkContent(content); // not part of this paragraph
The first parameter of the instance should be a personalized configuration. The configuration
passed to the instance will override Rome's defaults BUT not the options inside a possible
rome.json
file.
This is an import point, because we might have cases where a user is using Rome for linting/formatting of the project, but this project is actually using the runtime API to do some ad-hoc work. For example scripts, some generated code, etc.
In the future the instance pattern will be useful to register plugins. Plugin need to have access to the compiler APIs (we don't know yet what kind of APIs and what information).
While using functions à la prettier
may seem quicker for prototyping, this approach might
create more friction in the future when we will extend the APIs with new features.
import { Rome } from "rome";
const rome = new Rome();
const result = rome.formatFiles(["./path/to/file.js"]);
console.log(result.code); // formatted content
console.log(result.errors); // possible parse errors
Which reflects
rome format ./path/to/file.js
We can also write directly the new content to a file
import { Rome } from "rome";
const rome = new Rome();
const result = rome.formatFile("./path/to/file.js", { write: true }); // `true` maps to the `--write` argument
console.log(result.code); // undefined, the new content is in the file
console.log(result.errors); // possible parse errors
Which reflects
rome format --write ./path/to/file.js
It should be possible to format a directory
import { Rome } from "rome";
const rome = new Rome();
const result = rome.formatFiles(["./path"]);
for (const [file_name, result] of result) {
console.log(file_name);
console.log(result.code);
console.log(result.errors); // errors thrown while formatting this file
}
Which reflects
rome format ./path
We can also write directly the new content to files
import { Rome } from "rome";
const rome = new Rome();
const result = rome.formatFiles(["./path"], { write: true });
for (const [file_name, result] of result) {
console.log(file_name);
console.log(result.code); // undefined, it's being written
console.log(result.errors); // errors thrown while formatting this file
}
Note:
formatFile
could be removed in favour of onlyformatFiles
Warning: as for today the CLI doesn't allow to format from
stdin
, but it's something that we plan to deliver ASAP
import { Rome } from "rome";
const rome = new Rome();
const content = "function f() { return {}}";
const result = rome.formatContent(content, { filePath: "example.js" });
console.log(result.code); // formatted content
console.log(result.errors); // possible parse errors
Which will translate to
echo "function f() { return {}}" | rome format --file-type=js
filePath
is required to tell Rome how it should parse the file.
import { Rome } from "rome";
const rome = new Rome();
const content = "function f() { return {}}";
const result = rome.formatContent(content, { filePath: "example.js", range: [7, 10] });
console.log(result.code); // formatted content
console.log(result.errors); // possible parse errors
Note: the CLI doesn't allow to format ranges as for today, and it might not be needed. It can be an exception to the rule where each API should be supported by the CLI too.
As you noticed each call accepts an object as second argument. This object could contain a
debug
property, which allows us to return the IR emitted by Rome.
import { Rome } from "rome";
const rome = new Rome();
const result = rome.formatFiles(["./path/to/file.js"], { debug: true });
console.log(result.code); // formatted content
console.log(result.errors); // possible parse errors
console.log(result.ir); // the IR emitted by Rome
const content = "function f() { return {}}";
const result2 = rome.formatContent(content, { filePath: "example.js", range: [7, 10], debug: true });
console.log(result2.ir); // the IR emitted by the call formatContent
Note: nowadays, Rome doesn't have a CLI command to only lint files. We have the
check
command that does that now, but the command is designed to run multiple checks.
It's also possible to run the linter and retrieve possible diagnostics
import { Rome } from "rome";
const rome = new Rome();
const result = rome.lintFiles(["./path/to/file.js"]);
console.log(result.errors); // diagnostics emitted while lint the files
import { Rome } from "rome";
const rome = new Rome();
const content = "function f() { return {}}";
const result = rome.lintContent(content, { filePath: "example.js" });
console.log(result.errors); // diagnostics emitted while lint the files
Allows to parse a file, and return the CST and AST emitted by the parsing phase.
Note: nowadays, the CLI doesn't a command to parse files, this could be perfect occasion to actually implement it.
Note: The reason why
cst
andast
are emitted as strings is mostly because we don't have TypeScript file types for our nodes. If we are able to generate TypeScript types for our AST, thenast
could be returned as an object
import { Rome } from "rome";
const rome = new Rome();
const content = "function f() { return {}}";
const result = rome.parseContent(content, { filePath: "example.js" });
console.log(result.ast); // AST as string
console.log(result.cst); // CST as string
console.log(result.errors); // possible parse errors
This proposal assumes that all APIs are prone to errors for different reasons. Internally, in the
Rust Workspace, we emit a RomeError
, which might contain other variants with other errors,
form example ConfigurationError
.
At the moment this RomeError
is not serialized, so it's not very easy to come up with a clear
proposal, but a runtime should receive at least a code
of the error, e.g. RomeError:ConfigurationError
,
maybe a sub_code
e.g. ConfigurationError::ConfigAlreadyExists
,
Why not expose also functions like
format
,lint
, etc.?
While this can be an option, and it could be done, I think they don't exactly fit in the grand scheme of what Rome should be.
Of course there are exceptions, for example for a future testing framework:
import {test} from "rome";
test("test something", t => {
t.assert(true)
})