diff --git a/doc/doc.go b/doc/doc.go index 6fc9db6..baa0d26 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -50,26 +50,39 @@ func Join(sep Doc, parts []Doc) Doc { // LineDoc is a line separator. In flat mode a plain line prints a space, a // soft line prints nothing, and a hard line prints a newline regardless of // mode. In break mode every line prints a newline followed by the current -// indentation. +// indentation. Comment marks a hard line that ends a line comment's line: +// the printer remembers that the line was comment-ended, so a following +// AfterComment line can collapse instead of leaving a blank. AfterComment +// marks a soft structural line that renders a newline unless the output +// already ended with a Comment line (a blank line before it still renders). type LineDoc struct { - Soft bool - Hard bool - Literal bool // like Hard, but the newline is followed by no indentation + Soft bool + Hard bool + Literal bool // like Hard, but the newline is followed by no indentation + Comment bool // hard line ending a line comment's line + AfterComment bool // soft structural line collapsing after a Comment line } func (LineDoc) isDoc() {} // Lines, matching Prettier's builders: // -// Line - a space in flat mode, a newline in break mode -// SoftLine - nothing in flat mode, a newline in break mode -// HardLine - always a newline; breaks enclosing groups -// LiteralLine - always a newline with no indentation; breaks enclosing groups +// Line - a space in flat mode, a newline in break mode +// SoftLine - nothing in flat mode, a newline in break mode +// HardLine - always a newline; breaks enclosing groups +// LiteralLine - always a newline with no indentation; breaks enclosing groups +// CommentLine - a hard line owning a line comment's line end; breaks +// enclosing groups; the printer remembers the line was +// comment-ended +// AfterCommentLine - a soft structural line that renders a newline unless +// the output already ended with a CommentLine var ( Line Doc = LineDoc{} SoftLine Doc = LineDoc{Soft: true} HardLine Doc = Concat{LineDoc{Hard: true}, BreakParent} LiteralLine Doc = Concat{LineDoc{Hard: true, Literal: true}, BreakParent} + CommentLine Doc = Concat{LineDoc{Hard: true, Comment: true}, BreakParent} + AfterCommentLine Doc = LineDoc{Soft: true, AfterComment: true} HardLineNoBreak Doc = LineDoc{Hard: true} LiteralLineNoBreak Doc = LineDoc{Hard: true, Literal: true} ) diff --git a/doc/print.go b/doc/print.go index a8d3d43..77c0068 100644 --- a/doc/print.go +++ b/doc/print.go @@ -104,6 +104,7 @@ type printer struct { groupMode map[int]mode lineSuffix []command shouldRemeasure bool + lastLineComment bool // the last newline written ended a line comment's line // fits scratch: the width check is called for every line candidate // and would otherwise allocate a builder and a command stack per call. @@ -168,6 +169,11 @@ func (p *printer) run(d Doc) (string, error) { if len(commands) > 0 { p.position += stringWidth(s) } + + // Content on the line invalidates the comment-ended mark; + // a line comment's own CommentLine re-sets it after the + // comment text. + p.lastLineComment = false } case Concat: @@ -258,17 +264,20 @@ func (p *printer) run(d Doc) (string, error) { if v.Literal { p.write(newLine) p.position = 0 + p.lastLineComment = false } else { // A structural soft line right after a line that - // already ended (a line comment owns its line end) - // must not emit another newline — that would be a - // blank line — but it must re-apply the structural - // indentation: the comment's hard line carried the - // inner indent, and the following content belongs at - // the structural indent (e.g. a closing bracket after - // a comment inside a list). Hard lines always render - // (consecutive hard lines are blank lines). - if !v.Hard && p.lineEnded() { + // already ended must not emit another newline — that + // would be a blank line. After a CommentLine (a line + // comment owns its line end) the structural + // indentation is re-applied so the following content + // lands at the right level (e.g. a closing bracket + // after a comment inside a list). An AfterComment + // line collapses only after a CommentLine: a real + // blank line before it still renders. Hard lines + // always render (consecutive hard lines are blank + // lines). + if !v.Hard && p.lineEnded() && (!v.AfterComment || p.lastLineComment) { p.trim() p.write(cmd.indentation.value) p.position = cmd.indentation.length @@ -279,6 +288,15 @@ func (p *printer) run(d Doc) (string, error) { p.trim() p.write(newLine + cmd.indentation.value) p.position = cmd.indentation.length + // A comment's hard line marks the line as comment + // ended; blank hard lines do not end a content line, + // so the mark survives them; a rendered structural + // line starts a fresh content line. + if v.Comment { + p.lastLineComment = true + } else if !v.Hard { + p.lastLineComment = false + } } } diff --git a/formatter/body.go b/formatter/body.go index 613f93e..f33c6bc 100644 --- a/formatter/body.go +++ b/formatter/body.go @@ -157,7 +157,7 @@ func (f *formatter) service(v *syntax.Service) doc.Doc { inner = doc.Concat{doc.Line, doc.Concat(parts), doc.Concat(f.ownLineComments(close))} } else if !f.hasOwnLineComments(close) && f.token(close).BlankLinesBefore > 0 { // Empty body with blank lines before the close and no comments: - // ownLineComments emits nothing, so preserve the blanks here. + // the blanks round-trip through the close's own line. inner = doc.Concat{doc.Concat(f.blankLineDocs(f.token(close).BlankLinesBefore, doc.HardLine)), inner} } @@ -167,23 +167,13 @@ func (f *formatter) service(v *syntax.Service) doc.Doc { openDoc = append(openDoc, doc.BreakParent) } - // The line before the close collapses when the body already ended with - // a line comment (which owns its line end); it stays hard otherwise, so - // a blank line before the close still renders. - lastFn := -1 - if len(v.Functions) > 0 { - lastFn = v.Functions[len(v.Functions)-1].TokEnd() - } - - preClose := doc.HardLine - if f.endsWithLineComment(open, close, lastFn) { - preClose = doc.Line - } - + // The line before the close collapses when the body ended with a line + // comment (which owns its line end) and renders after a real blank + // line, so the close always lands on its own line. body := doc.Concat{ doc.Concat(openDoc), doc.Indent(inner), - preClose, + doc.AfterCommentLine, f.emitTokens(close, close, emitOpts{trailing: close != v.TokEnd()}), } @@ -196,33 +186,6 @@ func (f *formatter) service(v *syntax.Service) doc.Doc { return doc.Concat(out) } -// endsWithLineComment reports whether the emission before the closing token -// ends with a line comment, which owns its line end: the last function's -// same-line comments, the open brace's same-line comments (empty body), or -// the final comment in the gap before the close. The closing line must then -// collapse instead of leaving a blank. -func (f *formatter) endsWithLineComment(open, close, lastIdx int) bool { - if lastIdx >= 0 && f.sameLineEndsLine(lastIdx) { - return true - } - - if lastIdx < 0 && f.sameLineEndsLine(open) { - return true - } - - prev := f.prevReal(close - 1) - if c := close - 1; c > prev { - ct := f.token(c) - if ct.Line == f.token(prev).Line { - return lineComment(ct.Kind) - } - - return true // own-line comment: always ends with a hard line - } - - return false -} - // function formats a service method. The signature escalates via nested // groups: the whole signature folds when it fits, otherwise the throws // clause unfolds while the arguments stay flat, and the arguments unfold diff --git a/formatter/comments.go b/formatter/comments.go index b6d90a0..78cd037 100644 --- a/formatter/comments.go +++ b/formatter/comments.go @@ -50,7 +50,7 @@ func (f *formatter) ownLineComment(c, prev int, first bool) []doc.Doc { } parts = append(parts, f.blankLineDocs(ct.BlankLinesBefore, doc.HardLine)...) - parts = append(parts, doc.Text(trimComment(ct.Text)), doc.HardLine) + parts = append(parts, doc.Text(trimComment(ct.Text)), doc.CommentLine) return parts } @@ -62,7 +62,7 @@ func (f *formatter) ownLineComment(c, prev int, first bool) []doc.Doc { func sameLineComment(ct syntax.Token) ([]doc.Doc, bool) { parts := []doc.Doc{doc.Text(" "), doc.Text(trimComment(ct.Text))} if lineComment(ct.Kind) { - return append(parts, doc.HardLine), true + return append(parts, doc.CommentLine), true } return parts, false diff --git a/formatter/testdata/fuzz/FuzzFormat/20d91d4611254ff5 b/formatter/testdata/fuzz/FuzzFormat/20d91d4611254ff5 new file mode 100644 index 0000000..ea585c2 --- /dev/null +++ b/formatter/testdata/fuzz/FuzzFormat/20d91d4611254ff5 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("service A{\r#\r\r}") diff --git a/formatter/testdata/fuzz/FuzzFormat/b51241f11a756ec4 b/formatter/testdata/fuzz/FuzzFormat/b51241f11a756ec4 new file mode 100644 index 0000000..1b2d573 --- /dev/null +++ b/formatter/testdata/fuzz/FuzzFormat/b51241f11a756ec4 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("service A{#\r\r}") diff --git a/lsp/completion/context.go b/lsp/completion/context.go index c96af85..f2d206c 100644 --- a/lsp/completion/context.go +++ b/lsp/completion/context.go @@ -56,11 +56,19 @@ func ResolveContext(doc *syntax.Document, pos syntax.Position) Context { atIdx, at := tokenAt(doc, pos.Offset) + // A cursor in a comment is not a completion slot. + if at != nil && syntax.IsComment(at.Kind) { + c.Kind = CtxNone + + return c + } + // The token before the cursor: the token containing the cursor when the - // cursor sits at its end, the previous token when mid-token. - prevIdx := atIdx + // cursor sits at its end, the previous token when mid-token. Comments + // are skipped — the grammar slot is determined by the real tokens. + prevIdx := prevReal(doc.Tokens, atIdx) if at != nil && pos.Offset < at.Offset+len(at.Text) { - prevIdx = atIdx - 1 + prevIdx = prevReal(doc.Tokens, atIdx-1) } c.Prefix, c.EditStart = prefixRange(doc, pos, atIdx, at) @@ -94,10 +102,12 @@ func ResolveContext(doc *syntax.Document, pos syntax.Position) Context { // 2. Field id slot: cursor on an int immediately followed by ':' — // before the struct member rule, so "{ |1:" is CtxFieldID, not a // member name position. - if at != nil && at.Kind == syntax.TokenIntConstant && atIdx+1 < len(doc.Tokens) && doc.Tokens[atIdx+1].Kind == syntax.TokenColon { - c.Kind = CtxFieldID + if at != nil && at.Kind == syntax.TokenIntConstant { + if n := nextReal(doc.Tokens, atIdx+1); n < len(doc.Tokens) && doc.Tokens[n].Kind == syntax.TokenColon { + c.Kind = CtxFieldID - return c + return c + } } // 3. Token adjacency before the cursor. @@ -228,7 +238,12 @@ func identifierKind(path []syntax.Node, n *syntax.Identifier) ContextKind { // afterParenKind classifies the cursor right after '(' (prev is the opener). func afterParenKind(doc *syntax.Document, opener int) ContextKind { - switch prev := doc.Tokens[opener-1]; prev.Kind { + prevIdx := prevReal(doc.Tokens, opener-1) + if prevIdx < 0 { + return CtxAnnotationKey + } + + switch prev := doc.Tokens[prevIdx]; prev.Kind { case syntax.TokenRParen: // Function annotations after a closed args list. return CtxAnnotationKey @@ -237,7 +252,7 @@ func afterParenKind(doc *syntax.Document, opener int) ContextKind { case syntax.TokenIdentifier: // A function name opens the args list; a member name opens its // annotations. The enclosing brace body disambiguates. - if kw, ok := braceBodyKind(doc, opener-1); ok && kw == syntax.TokenService { + if kw, ok := braceBodyKind(doc, prevIdx); ok && kw == syntax.TokenService { return CtxFieldName } @@ -251,13 +266,18 @@ func afterParenKind(doc *syntax.Document, opener int) ContextKind { // insideParenKind classifies the cursor after ','/';' inside the group // opened at opener. func insideParenKind(doc *syntax.Document, opener int) ContextKind { - switch prev := doc.Tokens[opener-1]; prev.Kind { + prevIdx := prevReal(doc.Tokens, opener-1) + if prevIdx < 0 { + return CtxAnnotationKey + } + + switch prev := doc.Tokens[prevIdx]; prev.Kind { case syntax.TokenRParen: return CtxAnnotationKey case syntax.TokenThrows: return CtxFieldName case syntax.TokenIdentifier: - if kw, ok := braceBodyKind(doc, opener-1); ok && kw == syntax.TokenService { + if kw, ok := braceBodyKind(doc, prevIdx); ok && kw == syntax.TokenService { return CtxFieldName } @@ -270,7 +290,9 @@ func insideParenKind(doc *syntax.Document, opener int) ContextKind { // isThrowsGroup reports whether the group opened at opener is a throws // clause (which contains fields, not annotations). func isThrowsGroup(doc *syntax.Document, opener int) bool { - return opener > 0 && doc.Tokens[opener-1].Kind == syntax.TokenThrows + prevIdx := prevReal(doc.Tokens, opener-1) + + return prevIdx >= 0 && doc.Tokens[prevIdx].Kind == syntax.TokenThrows } // memberKind maps a container keyword to its member slot. @@ -479,11 +501,17 @@ func containerKeywordBefore(doc *syntax.Document, brace int) (syntax.TokenKind, pastParens: } + j = prevReal(doc.Tokens, j) if j < 1 || doc.Tokens[j].Kind != syntax.TokenIdentifier { return 0, false } - switch kw := doc.Tokens[j-1].Kind; kw { + k := prevReal(doc.Tokens, j-1) + if k < 0 { + return 0, false + } + + switch kw := doc.Tokens[k].Kind; kw { case syntax.TokenStruct, syntax.TokenUnion, syntax.TokenException, syntax.TokenEnum, syntax.TokenService: return kw, true @@ -501,6 +529,26 @@ func deepestNode(path []syntax.Node) syntax.Node { return path[len(path)-1] } +// prevReal returns the index of the previous non-comment token strictly +// before idx, or -1. Comments are stream tokens but never participate in +// the grammar, so every adjacency lookup skips them. +func prevReal(toks []syntax.Token, idx int) int { + for idx >= 0 && syntax.IsComment(toks[idx].Kind) { + idx-- + } + + return idx +} + +// nextReal returns the index of the next non-comment token at or after idx. +func nextReal(toks []syntax.Token, idx int) int { + for idx < len(toks) && syntax.IsComment(toks[idx].Kind) { + idx++ + } + + return idx +} + // tokenOffset returns the byte offset of the first token of n. func tokenOffset(doc *syntax.Document, n syntax.Node) int { return doc.TokenPosition(n.TokStart()).Offset diff --git a/lsp/completion/context_test.go b/lsp/completion/context_test.go index 1f1edec..ae99da7 100644 --- a/lsp/completion/context_test.go +++ b/lsp/completion/context_test.go @@ -53,6 +53,9 @@ func TestResolveContext(t *testing.T) { // Function args and throws. {"function args after paren", `service Federation { void f(|`, CtxFieldName}, {"function args after comma", `service Federation { void f(1: i32 id, |`, CtxFieldName}, + {"function args after a comment line", "service Federation { void f(1: i32 id, // c\n|", CtxFieldName}, + {"function args after a same-line comment", `service Federation { void f(1: i32 id /* c */, |`, CtxFieldName}, + {"cursor in a comment is not a slot", "service Federation { void f(1: i32 id, // |c", CtxNone}, {"throws after paren", `service Federation { void f() throws (|`, CtxFieldName}, {"throws after comma", `service Federation { void f() throws (1: string m, |`, CtxFieldName}, {"function annotations after args", `service Federation { void f() (|`, CtxAnnotationKey},