Skip to content

Merge existing JSDoc comments #27978

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

Merged
merged 22 commits into from
Oct 24, 2018
Merged

Conversation

sandersn
Copy link
Member

This PR is a followup to the infer-from-usage codefix PR. Instead of generating comment text in textChanges.ts, it uses the existing infrastructure in the emitter and formatter. That means that:

  1. The emitter can now emit JSDoc.
  2. The formatter is slightly better (probably) at emitting multiline comments. Because I didn't want to take the time to completely understand and restructure the formatter, I left the existing code for single-line comments intact.
  3. The factory functions now include a few JSDoc tags, but only the ones I needed for this PR. It's easy to add these, so I think it's fine to leave them out.

It additionally means that
4. inferFromUsage.ts constructs JSDoc tags instead of its own data structures.

As a result, you'll see a lot of new, very predictable code in the emitter and lots of deletes in textChanges.ts.

I also wrote code to merge with existing JSDoc comments. The biggest change to existing comments will be the merging of multiple JSDoc comments into one, eg

/** @param x one */
/** @param y two */

Will become

/**
 * @param {number} x one
 * @param {number} y two
 */

Note that part of the code, in formatting.ts, is cloned but should be
extracted to a function instead.
But I see 4 failures with whitespace, so perhaps not.
The indentation code is very complex so I'm just going to avoid breaking
our single-line tests for now, plus add a simple jsdoc test to show that
multiline jsdoc indentation isn't destroyed in the common case.
Still doesn't merge correctly though
Not all of them; I got cold feet since I'll have to write tests for
them. I'll do that tomorrow.
And single tests (at least) for all tags
(Plus a little more in textChanges.ts)
@sandersn sandersn requested review from a user and amcasey October 18, 2018 21:36
@sandersn
Copy link
Member Author

@amcasey you are not required for this review, but your comments, combined with @Andy-MS' noImplicitThis codefix, led to it, so I thought you'd be interested.

}
}
syntheticTags.forEach(v => tags.push(v));
const tag = createJSDocComment(comments.join("\n"), createNodeArray(tags));
Copy link
Member

Choose a reason for hiding this comment

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

"\n" [](start = 53, length = 4)

Some later step will replace this with a platform-specific line break?

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, the formatting code re-breaks lines and reindents them as well.

* 1
* 2
* 3
* @returns {number} First one
Copy link
Member

Choose a reason for hiding this comment

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

@returns {number} First one [](start = 7, length = 27)

Based on our experience with imports, it seems likely that users will expect the merged JS Doc to be sorted (e.g. all of the @returnss together at the end).

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea. I filed #28090 to track that.

@amcasey
Copy link
Member

amcasey commented Oct 22, 2018

I skimmed it and it seems sensible. Thanks!


In reply to: 431172674 [](ancestors = 431172674)

Copy link

@ghost ghost left a comment

Choose a reason for hiding this comment

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

Should be a test where the codefix infers types for multiple parameters / return type of the same function, since that can cause issues with codeFixAll.

}

function emitJSDocAugmentsTag(tag: JSDocAugmentsTag) {
write("@extends");
Copy link

Choose a reason for hiding this comment

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

Might be better to always use tagName since this might be @augments or @extends. Also you could share more code between these functions since you can always unconditionally emit tagName first rather than needing a different string literal for each tag kind.

}
const typeTags = createMap<JSDocTypeTag | JSDocReturnTag>();
const typeTag = isGetAccessorDeclaration(declaration) ? createJSDocReturnTag(typeNode, "") : createJSDocTypeTag(typeNode, "");
typeTags.set("TAG", typeTag);
Copy link

Choose a reason for hiding this comment

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

See earlier comment at "UNUSED"

1. Refactor emit code into smaller functions.
2. Preceding-whitespace utility is slightly easier to use.
3. Better casts and types in inferFromUsage make it easier to read.
@sandersn
Copy link
Member Author

I think this is ready for another look now.

typeTags.set("TAG" as __String, typeTag);
insertMergedJSDoc(
typeTags, changes, sourceFile, parent,
t => t.kind === typeTag.kind && "TAG" as __String,
Copy link

Choose a reason for hiding this comment

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

IMO using the type __String | false is just confusing -- I was beginning to question my knowledge of operator precedence. Would be more clear if getKey took __String | undefined and this were t.kind === typeTag.kind ? "TAG" : undefined.

return;
}
const paramTags = createUnderscoreEscapedMap<JSDocParameterTag>();
forEach(parameterInferences, inference => {
Copy link

Choose a reason for hiding this comment

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

This is typed as a non-nullable array so this could just be a for loop? Also, this function never writes to parameterInferences so that could be a ReadonlyArray.

const key = getKey(existing);
const synthetic = key && syntheticTags.get(key);
if (synthetic) {
merge(synthetic, existing as T);
Copy link

Choose a reason for hiding this comment

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

This might be simpler as tags.push(merge(synthetic, existing as T)); where we create a new synthetic node instead of mutating an existing one -- I believe there were plans to make nodes immutable in the future.

Copy link

Choose a reason for hiding this comment

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

Also might want to comment the reason we push to tags here instead of doing it at the bottom -- looks like we're trying to preserve the original order of tags as much as possible.

if (synthetic) {
merge(synthetic, existing as T);
tags.push(synthetic);
syntheticTags.delete(key as __String);
Copy link

Choose a reason for hiding this comment

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

I'm pretty sure we can avoid a cast here. See my earlier comment on getKey.

@ghost
Copy link

ghost commented Oct 24, 2018

Here's my take on improving jsdoc comment merging:

function annotateJSDocParameters(changes: textChanges.ChangeTracker, sourceFile: SourceFile, parameterInferences: ReadonlyArray<ParameterInference>, program: Program, host: LanguageServiceHost): void {
    const signature = parameterInferences.length && parameterInferences[0].declaration.parent;
    if (!signature) {
        return;
    }
    const paramTags = mapDefined(group(parameterInferences, i => String(getNodeId(i.declaration))), inferences => {
        const param = first(inferences).declaration;
        // only infer parameters that have (1) no type and (2) an accessible inferred type
        if (param.initializer || getJSDocType(param) || !isIdentifier(param.name)) return;

        // TODO: improve combining inferences
        const type = firstDefined(inferences, i => i.type);
        const isOptional = inferences.some(i => !!i.isOptional);
        const typeNode = type && getTypeNodeIfAccessible(type, param, program, host);
        return typeNode && createJSDocParamTag(createJSDocTypeExpression(typeNode), param.name, isOptional, "");
    });
    addJsdocTags(changes, sourceFile, signature, paramTags);
}

function addJsdocTags(changes: textChanges.ChangeTracker, sourceFile: SourceFile, parent: HasJSDoc, newTags: ReadonlyArray<JSDocTag>): void {
    const comments = mapDefined(parent.jsDoc, j => j.comment);
    const oldTags = flatMap(parent.jsDoc, j => j.tags);
    const unmergedNewTags = newTags.filter(newTag => !oldTags || !oldTags.some((tag, i) => {
        const merged = tryMergeJsdocTags(tag, newTag);
        if (merged) oldTags[i] = merged;
        return !!merged;
    }));
    const tag = createJSDocComment(comments.join("\n"), createNodeArray([...(oldTags || emptyArray), ...unmergedNewTags]));
    changes.insertJsdocCommentBefore(sourceFile, parent, tag);
}

function tryMergeJsdocTags(oldTag: JSDocTag, newTag: JSDocTag): JSDocTag | undefined {
    if (oldTag.kind !== newTag.kind) return undefined;

    switch (oldTag.kind) {
        case SyntaxKind.JSDocParameterTag: {
            const oldParam = oldTag as JSDocParameterTag, newParam = newTag as JSDocParameterTag;
            return isIdentifier(oldParam.name) && isIdentifier(newParam.name) && oldParam.name.escapedText === newParam.name.escapedText
                ? createJSDocParamTag(newParam.typeExpression, newParam.name, newParam.isBracketed, oldParam.comment)
                : undefined;
        }
        case SyntaxKind.JSDocReturnTag:
            return createJSDocReturnTag((newTag as JSDocReturnTag).typeExpression, oldTag.comment);
        default:
            return undefined;
    }
}

addJSDocTags replaces insertMergedJSDoc with no map or callbacks needed. The caller just passes in the new tags as an array and the function is responsible for looking for matching existing tags and merging them, so it only needs 4 parameters. It also avoids modifying existing tags unless a new tag matches it. This is technically O(oldTags * newTags) but I expect the number of new tags is rarely more than 10.
This does break the test codeFixInferFromUsageMultipleParametersJS, but that test has two different parameters with the same name so there's no reason we should merge their tags.

@sandersn
Copy link
Member Author

sandersn commented Oct 24, 2018

I like your suggestion, although I note that the group(parameterInferences, i => getNodeId(i.declaration), ...) doesn't make much sense, since each parameter maps to exactly 0 or 1 parameterInference. I think you can mapDefined(parameterInferences, ...) directly. I'll try that.

(Edit: And the order isn't changed from the original parameter list)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants