Skip to content

multi-line comment formatting fix and handler #16385

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 30 commits into from
Aug 18, 2017

Conversation

aozgaa
Copy link
Contributor

@aozgaa aozgaa commented Jun 9, 2017

Fixes an issue where indentation behavior in VS was poor in multi-line comments. Two changes:

  1. in order for VS to recognize that it needs to add asterisks on newlines, the client needs to know that it is in a multi-line comment when a newline is typed. The new request provides precisely this information.

  2. The level of indentation for lines in the middle of a multi-line comment corresponds to the position where the multi-line comment starts. ie:

function foo(){
    let x = 10;  /**
                  * some comment text
                  */

Instead*, we determine indentation in by looking at the first non-whitespace character on the previous line or start of the comment, whichever is later, and shifting one column left if there is an asterisk character. In the case of jsdoc-style comments, this result precisely coincides with the level of indentation of the start of the comment (provided the comment started on its own line). This behavior is consistent with what Roslyn does in CSharp.

* The original strategy was problematic because finding the number of columns to displace for the opening comment marker would require us to determine how many columns non-whitespace characters take up. That is, we would need to start splitting source text into graphemes, which we haven't done up to this point. To handle this, we would either need to (a) add a runtime dependency or (b) implement it ourselves. Since we don't currently have runtime dependencies, we are biased towards (b). But the added complexity of handling graphemes correctly is not worthwhile for what appears to be a rare use-case.

EDIT (8/16/2017): re-ordered items, and changed description of indentation strategy.

@@ -2500,6 +2500,16 @@ namespace FourSlash {
}
}

public verifyisInMultiLineCommentAtPosition(negative: boolean) {
Copy link
Member

Choose a reason for hiding this comment

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

"verifyIs"?

const fileName = this.activeFile.fileName;
const actual = this.languageService.getisInMultiLineCommentAtPosition(fileName, position);
if (expected !== actual) {
this.raiseError(`verifyIsInDocComment failed: at position '${position}' in '${fileName}', expected '${expected}'.`);
Copy link
Member

Choose a reason for hiding this comment

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

Copy-paste error?

const expected = !negative;
const position = this.currentCaretPosition;
const fileName = this.activeFile.fileName;
const actual = this.languageService.getisInMultiLineCommentAtPosition(fileName, position);
Copy link
Member

Choose a reason for hiding this comment

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

"getis" -> "is"?

Copy link
Member

Choose a reason for hiding this comment

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

e.g. isValidBraceCompletion

@@ -1117,6 +1117,27 @@ namespace ts.formatting {
}
}

/**
* @returns -1 iff the position is not in a multi-line comment.
Copy link
Member

Choose a reason for hiding this comment

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

It's common to simply say "a negative value" rather than "-1" to retain flexibility for future refactoring.

Also, what does it return if it is in a multi-line comment?

if (leadingCommentRanges) {
loop: for (const range of leadingCommentRanges) {
// We need to extend the range when in an unclosed multi-line comment.
if (range.pos < position && (position < range.end || position === range.end && position === sourceFile.getFullWidth())) {
Copy link
Member

Choose a reason for hiding this comment

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

Does range.pos < position mean that the opening slash doesn't count?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes.

Copy link
Member

Choose a reason for hiding this comment

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

Is that important?

return 0;
}

const lineAtPosition = sourceFile.getLineAndCharacterOfPosition(position).line;

const indentationOfEnclosingMultiLineComment = getIndentationOfEnclosingMultiLineComment(sourceFile, position);
if (indentationOfEnclosingMultiLineComment !== -1) {
Copy link
Member

Choose a reason for hiding this comment

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

>= 0?

@@ -835,6 +840,14 @@ namespace ts {
);
}

/// GET IS IN MULTI-LINE COMMENT
Copy link
Member

Choose a reason for hiding this comment

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

This comment isn't obviously helpful.

verify.isInMultiLineCommentAtPosition();
}

for (let i = 0; i < 2; ++i) {
Copy link
Member

Choose a reason for hiding this comment

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

I think the loop makes this harder to read.

@@ -0,0 +1,36 @@
/// <reference path="fourslash.ts" />

//// /* x */
Copy link
Member

Choose a reason for hiding this comment

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

Would it be interesting to test a comment that spans multiple lines?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

added

* @returns -1 iff the position is not in a multi-line comment.
*/
export function getIndentationOfEnclosingMultiLineComment(sourceFile: SourceFile, position: number): number {
const token = getTokenAtPosition(sourceFile, position, /*includeJsDocComment*/ false);
Copy link
Member

Choose a reason for hiding this comment

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

Is there a test that is affected by the value of includeJsDocComment?

const leadingCommentRangesOfNextToken = getLeadingCommentRangesOfNode(getTokenAtPosition(sourceFile, position, /*includeJsDocComment*/ false), sourceFile);
const commentRanges = trailingRangesOfPreviousToken && leadingCommentRangesOfNextToken ?
trailingRangesOfPreviousToken.concat(leadingCommentRangesOfNextToken) :
trailingRangesOfPreviousToken || leadingCommentRangesOfNextToken;
Copy link
Member

Choose a reason for hiding this comment

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

Can you add some explanatory comments for the logic in here? It assumes the reader knows a lot of detail about how we keep comments in the trees attached to tokens. And I'm guessing there's some edge cases or patterns here this logic needs to handle that makes it non-trivial. That knowledge would be good for future readers/maintains not to have to figure out again for themselves.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added above.

return 0;
}

const lineAtPosition = sourceFile.getLineAndCharacterOfPosition(position).line;

const indentationOfEnclosingMultiLineComment = getIndentationOfEnclosingMultiLineComment(sourceFile, position, options);
Copy link
Member

Choose a reason for hiding this comment

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

utilities.ts already exports a brief isInComment function. It it worth enclosing this block in an if check on that to make it clearer?

@aozgaa
Copy link
Contributor Author

aozgaa commented Aug 7, 2017

This has been updated after sitting here for a while. Thoughts, @billti , @amcasey ?

const commentStart = range.pos;
const commentLineStart = getLineStartPositionForPosition(commentStart, sourceFile);
const { column, character } = SmartIndenter.findFirstNonWhitespaceCharacterAndColumn(commentLineStart, commentStart, sourceFile, options);
return column + /*length after whitespace ends*/ range.pos - (commentLineStart + character);
Copy link
Member

Choose a reason for hiding this comment

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

Can you clarify what the above logic is doing? Its a lot more involved than I would have expected, and I can't figure out the reason for some of it.

@billti
Copy link
Member

billti commented Aug 8, 2017

Minor comment to clarify some logic, and make sure the builds/checks are passing before checking in. Thanks!

Copy link
Member

@amcasey amcasey left a comment

Choose a reason for hiding this comment

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

Some question inline.

loop: for (const range of leadingCommentRanges) {
export function getIndentationOfEnclosingMultiLineComment(sourceFile: SourceFile, position: number, options: EditorSettings): number {
const range = getRangeOfEnclosingComment(sourceFile, position, /*onlyMultiLine*/ true);
if (range) {
Copy link
Member

Choose a reason for hiding this comment

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

Nit: if you return immediately after determining that range is undefined, the rest of the method reads as straight-line code.

Copy link
Contributor Author

@aozgaa aozgaa Aug 17, 2017

Choose a reason for hiding this comment

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

Just in-lined the helper since it's only called once. Additionally, the logic has changed because I discovered an issue with determining the columns when we have text which is non-whitespace (because determining the number of graphemes in a string is non-trivial).

@@ -2508,6 +2508,20 @@ namespace FourSlash {
}
}

public verifySpanOfEnclosingComment(negative: boolean, onlyMultiLine: boolean) {
Copy link
Member

Choose a reason for hiding this comment

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

Why not just pass expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Methods in VerifyNegatable often call into the TestState by passing in negative. I'm merely following that idiom.


goTo.position(singleLineCommentStart + 1);
verify.not.isInCommentAtPosition(/*onlyMultiLine*/ true);
verify.isInCommentAtPosition(/*onlyMultiLine*/ false);
Copy link
Member

Choose a reason for hiding this comment

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

Presumably, the result for false matches the result for true in every case but this one?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. I thought it felt a bit heavy-handed to query each case in the test that is checked in, but I'm open to checking more thoroughly if it seems worthwhile.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe isInCommentAtPosition could check both unless either true or false is specified?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added.

/**
* Requires that the enclosing span be a multi-line comment, or else the request returns undefined.
*/
onlyMultiLine: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

Is this just here for the generality of the API? We don't appear to actually use it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We only ever pass in onlyMultiLine: true right now with the proposed change in the VS extension.

Copy link
Member

Choose a reason for hiding this comment

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

I have mixed feeling about this but don't object as long as we have test coverage (which we do).

}

export function getRangeOfEnclosingComment(sourceFile: SourceFile, position: number, onlyMultiLine: boolean): CommentRange | undefined {
// Considering a fixed position,
Copy link
Member

Choose a reason for hiding this comment

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

Is this an arbitrary position or is it assumed to be at a token boundary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Arbitrary.

// tokens contain all comments in a sourcefile disjointly.
const precedingToken = findPrecedingToken(position, sourceFile);
const trailingRangesOfPreviousToken = precedingToken && getTrailingCommentRanges(sourceFile.text, precedingToken.end);
const leadingCommentRangesOfNextToken = getLeadingCommentRangesOfNode(getTokenAtPosition(sourceFile, position, /*includeJsDocComment*/ false), sourceFile);
Copy link
Member

Choose a reason for hiding this comment

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

What is the impact of /*includeJsDocComment*/ false? Is there a test that depends on the value?

for (const range of commentRanges) {
// We need to extend the range when in an unclosed multi-line comment.
if (range.pos < position && position < range.end ||
position === range.end && (range.kind === SyntaxKind.SingleLineCommentTrivia || position === sourceFile.getFullWidth())) {
Copy link
Member

Choose a reason for hiding this comment

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

What is sourceFile.getFullWidth()?

trailingRangesOfPreviousToken || leadingCommentRangesOfNextToken;
if (commentRanges) {
for (const range of commentRanges) {
// We need to extend the range when in an unclosed multi-line comment.
Copy link
Member

Choose a reason for hiding this comment

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

How does the check below detect unclosed multi-line comments?

else if (key === "\n") {
return formatting.formatOnEnter(position, sourceFile, getRuleProvider(settings), settings);

if (!isInComment(sourceFile, position)) {
Copy link
Member

Choose a reason for hiding this comment

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

Would it be worthwhile to test this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Adding a test to fourslash and will add an integration test on the managed side to make sure the experience works end-to-end.

// we need to find the last token in a previous child.
// 2) `position` is within the same span: we recurse on `child`.
// * JsxText is exceptional in that its tokens are (non-trivia) whitespace, which we do not want to return.
// TODO(arozga): shouldn't `findRightmost...` need to handle JsxText?
Copy link
Member

Choose a reason for hiding this comment

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

This comment seems like something you should do before check-in - especially as you have zero test-cases covering JSX.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed and tests have been added.

Copy link
Member

@billti billti left a comment

Choose a reason for hiding this comment

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

Please add more tests. You have one test file with static text (i.e. no editing occurring) and a well-formed comment. Please ensure behavior in unclosed comments, nested comments, etc.

sourceFile: SourceFile,
position: number,
onlyMultiLine: boolean,
precedingToken: Node | null | undefined = findPrecedingToken(position, sourceFile), // tslint:disable-line:no-null-keyword
Copy link
Member

Choose a reason for hiding this comment

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

findPrecedingToken || null?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It shouldn't matter in what follows, as the null value is only used to prevent the default initializer from being evaluated.

aozgaa added 9 commits August 16, 2017 15:28
* When in a multi-line comment, we would have liked to use the start of
the comment as a reference point for the indentation inside the comment,
but determining the number of columns shifted for the comment start
woudl require determining the length w/r/t graphemes, which we do not
currently implement. We would like to avoid taking on a runtime
dependency on a grapheme-parsing library.

Instead, we look at the indentation level on the previoud line or start
of the comment as a reference point, and correct shift for lines
starting with an asterisk.
@aozgaa
Copy link
Contributor Author

aozgaa commented Aug 17, 2017

@amcasey and @billti , changes have been addressed, can you take another look?

@RyanCavanaugh , can you comment on whether the treatment of JsxText in src/compiler/utilities.ts and src/services/utilities.ts is sane?

@uniqueiniquity , thoughts?

@@ -647,7 +647,7 @@ namespace ts {
}

export function getLeadingCommentRangesOfNode(node: Node, sourceFileOfNode: SourceFile) {
return getLeadingCommentRanges(sourceFileOfNode.text, node.pos);
return node.kind !== SyntaxKind.JsxText ? getLeadingCommentRanges(sourceFileOfNode.text, node.pos) : undefined;
Copy link
Member

Choose a reason for hiding this comment

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

What's special about jsx? Does it apply to tsx as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

JsxText nodes are children of JsxElements that are not JsxElements themselves or curly-brace-delimited assignment expressions. In the following example, the underlined is JsxText:

<div>Click me!<h1/></div>
     ~~~~~~~~~

We essentially treat them like string literals, though differences in the way they are delimited mean that getLeadingCommentRanges cannot handle them correctly without some information from the parser, which getLeadingCommentRanges has been designed to avoid.

Copy link
Member

Choose a reason for hiding this comment

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

And that covers tsx as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes.

// Note, `node.start` is the start-position of the first comment following the previous
// token that is not a trailing comment, so the leading and trailing comments of all
// tokens contain all comments in a sourcefile disjointly.
const trailingRangesOfPreviousToken = precedingToken && getTrailingCommentRanges(sourceFile.text, precedingToken.end);
Copy link
Member

Choose a reason for hiding this comment

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

We should probably short-circuit if position is within tokenAtPosition.

precedingToken: Node | null | undefined = findPrecedingToken(position, sourceFile), // tslint:disable-line:no-null-keyword
tokenAtPosition = getTokenAtPosition(sourceFile, position, /*includeJsDocComment*/ false),
predicate?: (c: CommentRange) => boolean): CommentRange | undefined {
// Considering a fixed position,
Copy link
Member

Choose a reason for hiding this comment

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

I would simplify this comment to something like

// Between two consecutive tokens, all comments are either trailing on the former or leading on the latter (and none are in both lists).

Copy link
Member

@amcasey amcasey left a comment

Choose a reason for hiding this comment

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

LGTM, modulo minor comments

Copy link
Member

@billti billti left a comment

Choose a reason for hiding this comment

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

That's a lot of work to put an asterisk at the right indentation :-o Thanks!

@aozgaa
Copy link
Contributor Author

aozgaa commented Aug 18, 2017

All PR's from the last day appear to be failing Travis with node 4 due to a gulp-typescript dependency. See ivogabe/gulp-typescript#536 for the fix.

Going to ignore this and go ahead and merge.

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

Successfully merging this pull request may close these issues.

4 participants