Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Extensibility model #9038

Closed
wants to merge 62 commits into from
Closed

RFC: Extensibility model #9038

wants to merge 62 commits into from

Conversation

weswigham
Copy link
Member

@weswigham weswigham commented Jun 8, 2016

We use our internal module resolution logic to resolve the location of the entrypoint of a plugin from the compilation, then pass that path off to the host to have it load the code. For our node host, this is just a require call. We then look at the exported values of this module and check for members annotated with the private member ["extension-kind"] which is intended to be one of the string literal values defined as valid ExtensionKinds. Later on, the loaded extensions can be retrieved and constructed when required, then have their methods called.

Please note: The extension API is not included anywhere in the compiler - however, an example of how to implement a package for using the extension API is given in the tests. It's my recommendation that the extension API be a separate package from TS proper, since the 'global' tsc needs to use the 'local' extension, and if that local extension has its own bundled typescript, then version mismatching can happen unexpectedly.

Additionally, to serve as a testbed for extension loading (and to implement a frequently asked for feature), single-pass semantic and syntactic lint extensions are also in this PR.

Thoughts:

  • We could implement the loadExtension method on other hosts similarly to how I've implemented it in the tests (if that is desirable). This is pretty much amounts to building a commonjs module loader into the compiler.

Future work:

@weswigham
Copy link
Member Author

weswigham commented Jun 8, 2016

@mhegazy Would you like to look over this?

@weswigham weswigham changed the title WIP: Extensibility model RFC: Extensibility model Jun 9, 2016
@weswigham
Copy link
Member Author

@billti You should take a look at this, too.

@weswigham weswigham force-pushed the extensibility-model branch 5 times, most recently from d077396 to f1b8fe0 Compare June 20, 2016 20:50
@weswigham
Copy link
Member Author

@alexeagle I am. I was asked to start working on this a bit more than two weeks ago. ;)

@weswigham
Copy link
Member Author

I'm also planning on proposing an external API for building types, to pair with the language service extensions, type provider extensions (more of a prerequisite), and semantic lint extensions - this way you don't have to inject new files of types into a compilation (usually meaning re-running the compilation) just to perform type comparisons.

@alexeagle
Copy link
Contributor

Other extension points that Angular needs:

  • codegen: create additional files before typechecking, write them to a configured gen directory, and include into the program before typechecking
  • some way to hook into the upcoming transforms pipeline to pre- and post-process the AST during emit.

I wrote more details: #3003 (comment)

@jkillian
Copy link

jkillian commented Jun 21, 2016

This looks very neat! I pulled these examples of a Lint extension out of your PR for ease of viewing:

import {SemanticLintWalker} from "typescript-plugin-api";

export default class IsValueFoo extends SemanticLintWalker {
    constructor(state) { super(state); }
    visit(node, stop, error) {
        const type = this.checker.getTypeAtLocation(node);
        if (type.flags & this.ts.TypeFlags.StringLiteral) {
            if (node.text === "foo") {
                error("String literal type 'foo' is forbidden.", node);
            }
        }
    }
}
import {SyntacticLintWalker} from "typescript-plugin-api";

export default class IsNamedFoo extends SyntacticLintWalker {
    constructor(state) { super(state); }
    visit(node, stop, error) {
        if (node.kind === this.ts.SyntaxKind.Identifier) {
            if (node.text.toLowerCase() === "foo") {
                error("Identifier 'foo' is forbidden.", node);
            }
        }
    }
}

@jkillian
Copy link

Skimming over this briefly, I didn't see an example of how an end user of an extension would be able to configure an extension, but it looks like from the code that it would be something like this in a tsconfig.json file:

"extensions": {
  "some-extension": { "option1": 1, "option2": 2},
  "no-options-extension": true,
  "any-data-format-extension": [1,2,3,4]
}

}

function error(err: string, node: Node) {
diagnostics.push(createExtensionDiagnosticForNode(node, activeLint.name, err));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are times when an error might not appropriately be matched to a specific node. Is it worth considering a method of specifying an error that doesn't correspond to a single node?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh. I can just make node optional and emit a general program diagnostic if it's not present.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a reasonably easy way to provide the start and length of the added Diagnostic should give the appropriate amount of flexibility I think. Being able to provide a node (like the current implementation) seems like a special case of above, but on that's worth providing first-class support for as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

function error(err: string);
function error(err: string, node: Node);
function error(err: string, start: number, length: number);
function error(err: string, nodeOrStart?: Node | number, length?: number) {
 //...
}

seems appropriate.

@weswigham
Copy link
Member Author

@jkillian That would be correct. This should also be valid (if you don't need/want to pass any arguments to your extensions): extensions: ["ext-a", "ext-b"].

@@ -17258,6 +17258,10 @@ namespace ts {
return getTypeForVariableLikeDeclaration(<VariableLikeDeclaration>node.parent, /*includeOptionality*/ true);
}

if (node.kind === SyntaxKind.SourceFile) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the file is a module, we should return the module symbol.

return; // Avoid errors on explicitly exported null/undefined (why would someone do that, though?)
}
const annotatedKind = potentialExtension["extension-kind"];
if (typeof annotatedKind === "string") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

annotatedKind [](start = 35, length = 13)

when is this guard ever false? I thought "extension-kind" had a string literal union type.

Copy link
Member Author

@weswigham weswigham Jul 15, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're reading it from untrusted JS. So it's always possible (at run time). This is where the canonical extension object is "made", so we need to guard against exceptional types here.

Debug.fail("If only one argument is passed to error, it must be the error message string.");
return;
}
// error(err: string): void;
Copy link
Member

@sandersn sandersn Jul 21, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this comment belong here any more?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - I was placing comments with the corresponding overload next to their implementation, just for clarity.

@weswigham
Copy link
Member Author

@sandersn I think you'll be much more pleased with the error function's structure now (I know I am). The only downside is that there are more casts, since TS can't track throws across function calls. :(

// Overrides

// A plugin can implement one of the override methods to replace the results that would
// be returned by the TypeScript language service. If a plugin returns a defined results
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

results [](start = 89, length = 7)

typo: result

// program. The value passed in as previous is the result returned by the prior plugin. If a
// plugin returns undefined, the result passed in as previous is used and the undefined
// result is ignored. All plugins are consulted before the result is returned to the host.
// If a plugin overrides behavior of the method, no filter methods are consulted.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a plugin overrides behavior of the method, no filter methods are consulted. [](start = 10, length = 79)

I'm confused by the relationship of override and filter methods. Are overrides and filters called in the same sequence? It sounds like they are, with the first override aborting the cascade of the previous filters (but not throwing away their filtered values).

  1. What happens if a plugin has both an override and a filter?
  2. Could the whole thing be simplified to just have filter that have some way to signal that they should be the last plugin in the cascade -- that is, that they are an override? (This would dodge (1) by only allowing one of each on a class.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If an override returns a non-undefined value, no further overrides are queried, no filters are queried. and the value is returned. If no override returns a non-undefined value, then the base service is queried for a value. This value is then piped through the filter chain. If a filter returns undefined, the previous value is used as input to the next filter instead. The result of the last non-undefined returning filter is the final result, returned by the service.

A plugin can have both an override and a filter because it may, at times, wish to override everything (and at other times returning undefined and not providing a value) and at others filter (assuming it returned undefined for the same operation in its override method).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So overrides are before (or replace) and filters are after, but overrides stop filters from running?

I'd suggest two things:

  1. Document overrides and filters together, plus add an example or two to show how they interact. (Maybe even change the names.)
  2. Consider making it so that overrides don't stop filters. Wouldn't it be useful for other plugins to be able to filter the output of a previous plugin?

Copy link
Member Author

@weswigham weswigham Jul 21, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Okay.
  2. @chuckjaz had the original API idea. Unless he has some strong objection to this, I'm okay with always having filters run.

@weswigham
Copy link
Member Author

After some discussion within the team, I'm going to split this PR back into three separate PRs: One for loading extensions, one for lint extensions, and one for LS extensions. We're generally find with loading extensions and how that works, however we have some contention within the team as to what extensions have value and what risks we're willing to take with them. I'm going to close this PR and open up three new ones shortly.

@weswigham
Copy link
Member Author

This PR has now been split into #10159, #10160, and #10162.

@TheLarkInn
Copy link
Member

Cc @TheLarkInn to track this incredible awesomeness. We could test this for webpack also.

@waderyan waderyan added the VS Code Tracked There is a VS Code equivalent to this issue label Sep 14, 2016
@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
VS Code Tracked There is a VS Code equivalent to this issue
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants