Skip to content
This repository has been archived by the owner on Aug 31, 2023. It is now read-only.

Latest commit

 

History

History
426 lines (297 loc) · 12.8 KB

002-runtime-api.md

File metadata and controls

426 lines (297 loc) · 12.8 KB

Overview

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.

Terminology

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;

Use case for Rome

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 aim of the APIs

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.

The current ecosystem

As for today, different tools have different approaches

prettier

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

esbuild

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

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);

postcss

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.

Rome's APIs

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.

Runtime design

  1. 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.
  2. 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.
  3. 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

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).

Formatter

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.

Formatting a file

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

Formatting a directory

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 only formatFiles

Formatting some content

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.

Format range

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.

Debugging

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

Linter

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

Lint files

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

Lint content

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

Parse

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 and ast 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, then ast could be returned as an object

Parse content

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

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,

Q&A

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)
})