diff --git a/langserver/internal/source/completion.go b/langserver/internal/source/completion.go index ca8f470..a6184c4 100644 --- a/langserver/internal/source/completion.go +++ b/langserver/internal/source/completion.go @@ -6,6 +6,7 @@ import ( "strings" "cloud.google.com/go/bigquery" + "github.com/goccy/go-zetasql" "github.com/goccy/go-zetasql/ast" rast "github.com/goccy/go-zetasql/resolved_ast" "github.com/goccy/go-zetasql/types" @@ -52,40 +53,38 @@ func (p *Project) Complete(ctx context.Context, uri string, position lsp.Positio termOffset := positionToByteOffset(sql.RawText, position) parsedFile := p.ParseFile(uri, sql.RawText) + termOffset = parsedFile.fixTermOffsetForNode(termOffset) // cursor is on table name - if node, ok := searchAstNode[*ast.TablePathExpressionNode](parsedFile.Node, termOffset); ok { - return p.completeTablePath(ctx, node) + tablePathNode, ok := searchAstNode[*ast.TablePathExpressionNode](parsedFile.Node, termOffset) + if ok && tablePathNode.ParseLocationRange().End().ByteOffset() != termOffset { + return p.completeTablePath(ctx, tablePathNode) } - output, ok := parsedFile.findTargetAnalyzeOutput(termOffset) + output, ok := parsedFile.FindTargetAnalyzeOutput(termOffset) if !ok { p.logger.Debug("not found analyze output") return nil, nil } - incompleteColumnName := parsedFile.findIncompleteColumnName(position) + incompleteColumnName := parsedFile.FindIncompleteColumnName(position) - node, ok := searchResolvedAstNode[*rast.ProjectScanNode](output, termOffset) - if !ok { - // In some case, *rast.ProjectScanNode.ParseLocationRange() returns nil. - // So, if we cannot find *rast.ProjectScanNode, we search *rast.ProjectScanNode which ParseLocationRange returns nil. - rast.Walk(output.Statement(), func(n rast.Node) error { - sNode, ok := n.(*rast.ProjectScanNode) - if !ok { - return nil - } - lRange := sNode.ParseLocationRange() - if lRange == nil { - node = sNode - } - return nil - }) - if node == nil { - return nil, nil - } + node, ok := findScanNode(output, termOffset) + if node == nil { + p.logger.Debug("not found project scan node") + return nil, nil } - columns := node.InputScan().ColumnList() + if pScanNode, ok := node.(*rast.ProjectScanNode); ok { + node = pScanNode.InputScan() + } + if oScanNode, ok := node.(*rast.OrderByScanNode); ok { + node = oScanNode.InputScan() + } + if aScanNode, ok := node.(*rast.AggregateScanNode); ok { + node = aScanNode.InputScan() + } + + columns := node.ColumnList() for _, column := range columns { if !strings.HasPrefix(column.Name(), incompleteColumnName) { continue @@ -109,11 +108,51 @@ func (p *Project) Complete(ctx context.Context, uri string, position lsp.Positio } // for table alias completion - result = append(result, p.completeScanField(ctx, node.InputScan(), incompleteColumnName)...) + result = append(result, p.completeScanField(ctx, node, incompleteColumnName)...) return result, nil } +func findScanNode(output *zetasql.AnalyzerOutput, termOffset int) (node rast.ScanNode, ok bool) { + node, ok = searchResolvedAstNode[*rast.ProjectScanNode](output, termOffset) + if ok { + return node, true + } + + node, ok = searchResolvedAstNode[*rast.OrderByScanNode](output, termOffset) + if ok { + return node, true + } + + // In some case, *rast.ProjectScanNode.ParseLocationRange() returns nil. + // So, if we cannot find *rast.ProjectScanNode, we search *rast.ProjectScanNode which ParseLocationRange returns nil. + rast.Walk(output.Statement(), func(n rast.Node) error { + if !n.IsScan() { + return nil + } + + sNode := n.(rast.ScanNode) + + lRange := n.ParseLocationRange() + if lRange == nil { + node = sNode + return nil + } + // if the cursor is on the end of the node, the cursor is out of the node + // So, we need to permit the some offset. + if lRange.End().ByteOffset() < termOffset+5 { + node = sNode + return nil + } + return nil + }) + + if node == nil { + return nil, false + } + return node, true +} + type tablePathParams struct { ProjectID string DatasetID string diff --git a/langserver/internal/source/completion_test.go b/langserver/internal/source/completion_test.go index 341feda..c3581c8 100644 --- a/langserver/internal/source/completion_test.go +++ b/langserver/internal/source/completion_test.go @@ -15,6 +15,7 @@ import ( "github.com/kitagry/bqls/langserver/internal/lsp" "github.com/kitagry/bqls/langserver/internal/source" "github.com/kitagry/bqls/langserver/internal/source/helper" + "github.com/sirupsen/logrus" "google.golang.org/api/cloudresourcemanager/v1" ) @@ -397,6 +398,100 @@ func TestProject_CompleteColumn(t *testing.T) { }, }, }, + "Complete column in where clause": { + files: map[string]string{ + "file1.sql": "SELECT * FROM `project.dataset.table` WHERE |", + }, + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Description: "id description", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectCompletionItems: []source.CompletionItem{ + { + Kind: lsp.CIKField, + NewText: "id", + Detail: "INTEGER\nid description", + }, + }, + }, + "Complete column in group by clause": { + files: map[string]string{ + "file1.sql": "SELECT * FROM `project.dataset.table` GROUP BY |", + }, + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Description: "id description", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectCompletionItems: []source.CompletionItem{ + { + Kind: lsp.CIKField, + NewText: "id", + Detail: "INTEGER\nid description", + }, + }, + }, + "Complete incomplete column in group by clause": { + files: map[string]string{ + "file1.sql": "SELECT * FROM `project.dataset.table` GROUP BY i|", + }, + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Description: "id description", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectCompletionItems: []source.CompletionItem{ + { + Kind: lsp.CIKField, + NewText: "id", + Detail: "INTEGER\nid description", + TypedPrefix: "i", + }, + }, + }, + "Complete incomplete column in order by clause": { + files: map[string]string{ + "file1.sql": "SELECT * FROM `project.dataset.table` ORDER BY i|", + }, + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Description: "id description", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectCompletionItems: []source.CompletionItem{ + { + Kind: lsp.CIKField, + NewText: "id", + Detail: "INTEGER\nid description", + TypedPrefix: "i", + }, + }, + }, } for n, tt := range tests { @@ -410,7 +505,10 @@ func TestProject_CompleteColumn(t *testing.T) { } bqClient.EXPECT().GetTableMetadata(gomock.Any(), tablePathSplitted[0], tablePathSplitted[1], tablePathSplitted[2]).Return(schema, nil).MinTimes(0) } - p := source.NewProjectWithBQClient("/", bqClient) + bqClient.EXPECT().ListTables(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).MinTimes(0) + logger := logrus.New() + logger.SetLevel(logrus.DebugLevel) + p := source.NewProjectWithBQClient("/", bqClient, logger) files, path, position, err := helper.GetLspPosition(tt.files) if err != nil { @@ -585,7 +683,7 @@ func TestProject_CompleteFromClause(t *testing.T) { for n, tt := range tests { t.Run(n, func(t *testing.T) { bqClient := tt.bigqueryClientMockFunc(t) - p := source.NewProjectWithBQClient("/", bqClient) + p := source.NewProjectWithBQClient("/", bqClient, logrus.New()) files, path, position, err := helper.GetLspPosition(tt.files) if err != nil { diff --git a/langserver/internal/source/document.go b/langserver/internal/source/document.go index 5d15dd0..adb1ed9 100644 --- a/langserver/internal/source/document.go +++ b/langserver/internal/source/document.go @@ -20,6 +20,7 @@ func (p *Project) TermDocument(uri string, position lsp.Position) ([]lsp.MarkedS parsedFile := p.ParseFile(uri, sql.RawText) termOffset := positionToByteOffset(sql.RawText, position) + termOffset = parsedFile.fixTermOffsetForNode(termOffset) targetNode, ok := searchAstNode[*ast.PathExpressionNode](parsedFile.Node, termOffset) if !ok { p.logger.Debug("not found target node") @@ -37,7 +38,7 @@ func (p *Project) TermDocument(uri string, position lsp.Position) ([]lsp.MarkedS } } - output, ok := parsedFile.findTargetAnalyzeOutput(termOffset) + output, ok := parsedFile.FindTargetAnalyzeOutput(termOffset) if !ok { return nil, nil } diff --git a/langserver/internal/source/document_test.go b/langserver/internal/source/document_test.go index 0f0da98..c0d8009 100644 --- a/langserver/internal/source/document_test.go +++ b/langserver/internal/source/document_test.go @@ -13,6 +13,7 @@ import ( "github.com/kitagry/bqls/langserver/internal/lsp" "github.com/kitagry/bqls/langserver/internal/source" "github.com/kitagry/bqls/langserver/internal/source/helper" + "github.com/sirupsen/logrus" ) func TestProject_TermDocument(t *testing.T) { @@ -347,7 +348,7 @@ json description`, ctrl := gomock.NewController(t) bqClient := mock_bigquery.NewMockClient(ctrl) bqClient.EXPECT().GetTableMetadata(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(tt.bqTableMetadata, nil).MinTimes(0) - p := source.NewProjectWithBQClient("/", bqClient) + p := source.NewProjectWithBQClient("/", bqClient, logrus.New()) files, path, position, err := helper.GetLspPosition(tt.files) if err != nil { diff --git a/langserver/internal/source/file.go b/langserver/internal/source/file.go index 4d0fba3..1689e01 100644 --- a/langserver/internal/source/file.go +++ b/langserver/internal/source/file.go @@ -21,8 +21,18 @@ type ParsedFile struct { // index is Node's statement order RNode []*zetasql.AnalyzerOutput - Fixed bool - Errors []Error + Fixed bool + FixOffsets []FixOffset + Errors []Error +} + +func (p ParsedFile) fixTermOffsetForNode(termOffset int) int { + for _, fo := range p.FixOffsets { + if termOffset > fo.Offset+fo.Length { + termOffset += fo.Length + } + } + return termOffset } func (p ParsedFile) FindTargetStatementNode(termOffset int) (ast.StatementNode, bool) { @@ -87,7 +97,7 @@ func (p ParsedFile) findTargetStatementNodeIndex(termOffset int) (int, bool) { return -1, false } -func (p *ParsedFile) findTargetAnalyzeOutput(termOffset int) (*zetasql.AnalyzerOutput, bool) { +func (p *ParsedFile) FindTargetAnalyzeOutput(termOffset int) (*zetasql.AnalyzerOutput, bool) { index, ok := p.findTargetStatementNodeIndex(termOffset) if !ok { return nil, false @@ -100,13 +110,14 @@ func (p *ParsedFile) findTargetAnalyzeOutput(termOffset int) (*zetasql.AnalyzerO return p.RNode[index], true } -func (p *ParsedFile) findIncompleteColumnName(pos lsp.Position) string { +func (p *ParsedFile) FindIncompleteColumnName(pos lsp.Position) string { + targetTerm := positionToByteOffset(p.Src, pos) + targetTerm = p.fixTermOffsetForNode(targetTerm) + for _, err := range p.Errors { - line := pos.Line - character := pos.Character - len(err.IncompleteColumnName) - startIn := err.Position.Line > line || (err.Position.Line == line && err.Position.Character >= character) - endIn := err.Position.Line < line || (err.Position.Line == line && err.Position.Character >= character) - if startIn && endIn { + startOffset := positionToByteOffset(p.Src, err.Position) + startOffset = p.fixTermOffsetForNode(startOffset) + if startOffset <= targetTerm && targetTerm <= startOffset+err.TermLength { return err.IncompleteColumnName } } @@ -118,19 +129,24 @@ func (p *Project) ParseFile(uri string, src string) ParsedFile { var node ast.ScriptNode rnode := make([]*zetasql.AnalyzerOutput, 0) + fixOffsets := make([]FixOffset, 0) var analyzeErrFixed bool for _retry := 0; _retry < 10; _retry++ { var err error - var fixed bool + var fo []FixOffset node, err = zetasql.ParseScript(fixedSrc, zetasql.NewParserOptions(), zetasql.ErrorMessageOneLine) if err != nil { pErr := parseZetaSQLError(err) if strings.Contains(pErr.Msg, "SELECT list must not be empty") { - fixedSrc, pErr, fixed = fixSelectListMustNotBeEmptyStatement(fixedSrc, pErr) + fixedSrc, pErr, fo = fixSelectListMustNotBeEmptyStatement(fixedSrc, pErr) + } + if strings.Contains(pErr.Msg, "Unexpected end of script") { + fixedSrc, pErr, fo = fixUnexpectedEndOfScript(fixedSrc, pErr) } errs = append(errs, pErr) - if fixed { + if len(fo) > 0 { // retry + fixOffsets = append(fixOffsets, fo...) continue } } @@ -156,11 +172,24 @@ func (p *Project) ParseFile(uri string, src string) ParsedFile { pErr := parseZetaSQLError(err) switch { case strings.Contains(pErr.Msg, "Unrecognized name: "): - fixedSrc, pErr, fixed = fixUnrecognizedNameStatement(fixedSrc, pErr) + errTermOffset := positionToByteOffset(fixedSrc, pErr.Position) + pErr = addInformationToUnrecognizedNameError(fixedSrc, pErr) + if _, ok := searchAstNode[*ast.SelectListNode](node, errTermOffset); ok { + fixedSrc, fo = fixUnrecognizedNameToLiteral(fixedSrc, pErr) + } + if _, ok := searchAstNode[*ast.WhereClauseNode](node, errTermOffset); ok { + fixedSrc, fo = fixUnrecognizedNameForWhereStatement(fixedSrc, s, pErr) + } + if _, ok := searchAstNode[*ast.GroupByNode](node, errTermOffset); ok { + fixedSrc, fo = fixUnrecognizedNameToLiteral(fixedSrc, pErr) + } + if _, ok := searchAstNode[*ast.OrderByNode](node, errTermOffset); ok { + fixedSrc, fo = fixUnrecognizedNameToLiteral(fixedSrc, pErr) + } case strings.Contains(pErr.Msg, "does not exist in STRUCT"): - fixedSrc, pErr, fixed = fixFieldDoesNotExistInStructStatement(fixedSrc, pErr) + fixedSrc, pErr, fo = fixFieldDoesNotExistInStructStatement(fixedSrc, pErr) case strings.Contains(pErr.Msg, "not found inside"): - fixedSrc, pErr, fixed = fixNotFoundIndsideTableStatement(fixedSrc, pErr) + fixedSrc, pErr, fo = fixNotFoundIndsideTableStatement(fixedSrc, pErr) case strings.Contains(pErr.Msg, "Table not found: "): ind := strings.Index(pErr.Msg, "Table not found: ") table := strings.TrimSpace(pErr.Msg[ind+len("Table not found: "):]) @@ -168,8 +197,9 @@ func (p *Project) ParseFile(uri string, src string) ParsedFile { pErr.IncompleteColumnName = table } errs = append(errs, pErr) - if fixed { + if len(fo) > 0 { analyzeErrFixed = true + fixOffsets = append(fixOffsets, fo...) goto retry } } @@ -178,15 +208,21 @@ func (p *Project) ParseFile(uri string, src string) ParsedFile { } return ParsedFile{ - URI: uri, - Src: src, - Node: node, - RNode: rnode, - Fixed: dotFixed || analyzeErrFixed, - Errors: errs, + URI: uri, + Src: src, + Node: node, + RNode: rnode, + Fixed: dotFixed || analyzeErrFixed, + FixOffsets: fixOffsets, + Errors: errs, } } +type FixOffset struct { + Offset int + Length int +} + type Error struct { Msg string Position lsp.Position @@ -254,21 +290,57 @@ func fixDot(src string) (fixedSrc string, errs []Error, fixed bool) { // becomes // // SELECT 1 FROM table -func fixSelectListMustNotBeEmptyStatement(src string, parsedErr Error) (fixedSrc string, err Error, fixed bool) { +func fixSelectListMustNotBeEmptyStatement(src string, parsedErr Error) (fixedSrc string, err Error, fixOffsets []FixOffset) { errOffset := positionToByteOffset(src, parsedErr.Position) - return src[:errOffset] + "1 " + src[errOffset:], parsedErr, true + return src[:errOffset] + "1 " + src[errOffset:], parsedErr, []FixOffset{ + { + Offset: errOffset, + Length: len("1 "), + }, + } } -// fix Unrecognized name: error. +// fix Unexpected end of script // -// SELECT unexist_column FROM table +// SELECT * FROM table WHERE // // becomes // -// SELECT "111111111111" FROM table -func fixUnrecognizedNameStatement(src string, parsedErr Error) (fixedSrc string, err Error, fixed bool) { - ind := strings.Index(parsedErr.Msg, "Unrecognized name: ") +// SELECT * FROM table +func fixUnexpectedEndOfScript(src string, parsedErr Error) (fixedSrc string, err Error, fixOffsets []FixOffset) { + targetUnexpectedEndKeyword := []string{"WHERE", "GROUP BY", "ORDER BY"} + errOffset := positionToByteOffset(src, parsedErr.Position) + oneLineSrc := strings.Join(strings.Fields(src), " ") + targetIndex := -1 + for i, keyword := range targetUnexpectedEndKeyword { + if strings.HasSuffix(oneLineSrc, keyword) { + targetIndex = i + break + } + } + + if targetIndex == -1 { + return src, parsedErr, nil + } + + targetKeyword := targetUnexpectedEndKeyword[targetIndex] + targetOffset := strings.LastIndex(src[:errOffset], strings.Split(targetKeyword, " ")[0]) + if targetOffset == -1 { + return src, parsedErr, nil + } + + fixedSrc = strings.TrimSpace(src[:targetOffset]) + return fixedSrc, parsedErr, []FixOffset{ + { + Offset: errOffset, + Length: -(errOffset - len(fixedSrc)), + }, + } +} + +func addInformationToUnrecognizedNameError(src string, parsedErr Error) Error { + ind := strings.Index(parsedErr.Msg, "Unrecognized name: ") unrecognizedName := strings.TrimSpace(parsedErr.Msg[ind+len("Unrecognized name: "):]) // For the folowing error message: @@ -277,20 +349,72 @@ func fixUnrecognizedNameStatement(src string, parsedErr Error) (fixedSrc string, unrecognizedName = unrecognizedName[:ind] } + parsedErr.TermLength = len(unrecognizedName) + parsedErr.IncompleteColumnName = unrecognizedName + return parsedErr +} + +// fix Unrecognized name: error. +// +// SELECT unexist_column FROM table +// +// becomes +// +// SELECT 1 FROM table +func fixUnrecognizedNameToLiteral(src string, parsedErr Error) (fixedSrc string, fixOffsets []FixOffset) { + errOffset := positionToByteOffset(src, parsedErr.Position) + if errOffset == 0 || errOffset == len(src) { + return src, nil + } + + unrecognizedName := parsedErr.IncompleteColumnName + fixedSrc = src[:errOffset] + "1" + src[errOffset+len(unrecognizedName):] + + fixOffsets = append(fixOffsets, FixOffset{ + Offset: errOffset, + Length: -len(unrecognizedName) + len("1"), + }) + return fixedSrc, fixOffsets +} + +// fix Unrecognized name: error. +// +// SELECT unexist_column FROM table +// +// becomes +// +// SELECT "111111111111" FROM table +func fixUnrecognizedNameForWhereStatement(src string, node ast.StatementNode, parsedErr Error) (fixedSrc string, fixOffsets []FixOffset) { errOffset := positionToByteOffset(src, parsedErr.Position) if errOffset == 0 || errOffset == len(src) { - return src, parsedErr, false + return src, nil } - if len(unrecognizedName) < 2 { - fixedSrc = src[:errOffset] + strings.Repeat("1", len(unrecognizedName)) + src[errOffset+len(unrecognizedName):] + fixOffset := FixOffset{Offset: errOffset, Length: 0} + if node, ok := searchAstNode[*ast.BinaryExpressionNode](node, errOffset); ok { + // e.x.) SELECT * FROM table WHERE unexist_column = 1 + loc := node.ParseLocationRange() + startOffset := loc.Start().ByteOffset() + endOffset := loc.End().ByteOffset() + length := endOffset - startOffset + if length < 4 { + fixedSrc = src[:startOffset] + "true" + src[endOffset:] + fixOffset.Length = len("true") - length + } else { + fixedSrc = src[:startOffset] + "true" + strings.Repeat(" ", length-4) + src[endOffset:] + } } else { - fixedSrc = src[:errOffset] + `"` + strings.Repeat("1", len(unrecognizedName)-2) + `"` + src[errOffset+len(unrecognizedName):] + // e.x.) SELECT * FROM table WHERE unexist_column + unrecognizedName := parsedErr.IncompleteColumnName + if len(unrecognizedName) < 4 { + fixedSrc = src[:errOffset] + "true" + src[errOffset+len(unrecognizedName):] + fixOffset.Length = len("true") - len(unrecognizedName) + } else { + fixedSrc = src[:errOffset] + "true" + strings.Repeat(" ", len(unrecognizedName)-4) + src[errOffset+len(unrecognizedName):] + } } - parsedErr.TermLength = len(unrecognizedName) - parsedErr.IncompleteColumnName = unrecognizedName - return fixedSrc, parsedErr, true + return fixedSrc, []FixOffset{fixOffset} } // fix field name error. @@ -300,32 +424,34 @@ func fixUnrecognizedNameStatement(src string, parsedErr Error) (fixedSrc string, // becomes // // SELECT "1111111111111111111" FROM table -func fixFieldDoesNotExistInStructStatement(src string, parsedErr Error) (fixedSrc string, err Error, fixed bool) { +func fixFieldDoesNotExistInStructStatement(src string, parsedErr Error) (fixedSrc string, err Error, fixOffsets []FixOffset) { ind := strings.Index(parsedErr.Msg, "Field name ") if ind == -1 { - return src, parsedErr, false + return src, parsedErr, nil } notExistColumn := parsedErr.Msg[ind+len("Field name "):] notExistColumn = notExistColumn[:strings.Index(notExistColumn, " ")] errOffset := positionToByteOffset(src, parsedErr.Position) if errOffset == 0 || errOffset == len(src) { - return src, parsedErr, false + return src, parsedErr, nil } + fixOffset := FixOffset{Offset: errOffset, Length: 0} parsedErr.TermLength = len(notExistColumn) firstIndex := strings.LastIndex(src[:errOffset], " ") + 1 if firstIndex == 0 { fixedSrc = src[:errOffset] + "*," + src[errOffset+len(notExistColumn):] - return fixedSrc, parsedErr, true + fixOffset.Length = len(notExistColumn) - len("*,") + return fixedSrc, parsedErr, []FixOffset{fixOffset} } // struct.uneixst_column parsedErr.IncompleteColumnName = src[firstIndex : errOffset+len(notExistColumn)] structLen := len(src[firstIndex:errOffset]) fixedSrc = src[:firstIndex] + `"` + strings.Repeat("1", structLen+len(notExistColumn)-2) + `"` + src[errOffset+len(notExistColumn):] - return fixedSrc, parsedErr, true + return fixedSrc, parsedErr, []FixOffset{fixOffset} } // fix field name error. @@ -335,29 +461,31 @@ func fixFieldDoesNotExistInStructStatement(src string, parsedErr Error) (fixedSr // becomes // // SELECT "11111111111111" FROM table AS t -func fixNotFoundIndsideTableStatement(src string, parsedErr Error) (fixedSrc string, err Error, fixed bool) { +func fixNotFoundIndsideTableStatement(src string, parsedErr Error) (fixedSrc string, err Error, fixOffsets []FixOffset) { ind := strings.Index(parsedErr.Msg, "INVALID_ARGUMENT: Name ") if ind == -1 { - return src, parsedErr, false + return src, parsedErr, nil } notExistColumn := parsedErr.Msg[ind+len("INVALID_ARGUMENT: Name "):] notExistColumn = notExistColumn[:strings.Index(notExistColumn, " ")] errOffset := positionToByteOffset(src, parsedErr.Position) if errOffset == 0 || errOffset == len(src) { - return src, parsedErr, false + return src, parsedErr, nil } + fixOffset := FixOffset{Offset: errOffset, Length: 0} parsedErr.TermLength = len(notExistColumn) firstIndex := strings.LastIndex(src[:errOffset], " ") + 1 if firstIndex == 0 { fixedSrc = src[:errOffset] + "*," + src[errOffset+len(notExistColumn):] - return fixedSrc, parsedErr, true + fixOffset.Length = len(notExistColumn) - len("*,") + return fixedSrc, parsedErr, []FixOffset{fixOffset} } parsedErr.IncompleteColumnName = src[firstIndex : errOffset+len(notExistColumn)] structLen := len(src[firstIndex:errOffset]) fixedSrc = src[:firstIndex] + `"` + strings.Repeat("1", structLen+len(notExistColumn)-2) + `"` + src[errOffset+len(notExistColumn):] - return fixedSrc, parsedErr, true + return fixedSrc, parsedErr, []FixOffset{fixOffset} } diff --git a/langserver/internal/source/file_test.go b/langserver/internal/source/file_test.go index 365619e..ca7b906 100644 --- a/langserver/internal/source/file_test.go +++ b/langserver/internal/source/file_test.go @@ -13,6 +13,7 @@ import ( "github.com/kitagry/bqls/langserver/internal/bigquery/mock_bigquery" "github.com/kitagry/bqls/langserver/internal/lsp" "github.com/kitagry/bqls/langserver/internal/source" + "github.com/sirupsen/logrus" ) func TestProject_ParseFile(t *testing.T) { @@ -22,6 +23,30 @@ func TestProject_ParseFile(t *testing.T) { expectedErrs []source.Error }{ + "parse dot file": { + file: "SELECT t. FROM `project.dataset.table` t", + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectedErrs: []source.Error{ + { + Msg: "INVALID_ARGUMENT: Unrecognized name: t.", + Position: lsp.Position{ + Line: 0, + Character: 7, + }, + TermLength: 2, + IncompleteColumnName: "t.", + }, + }, + }, "parse SELECT list must not be empty error file": { file: "SELECT FROM `project.dataset.table`", bqTableMetadataMap: map[string]*bq.TableMetadata{ @@ -45,7 +70,53 @@ func TestProject_ParseFile(t *testing.T) { }, }, }, - "parse unrecognized file": { + "parse Unexpected end of script error with WHERE file": { + file: "SELECT * FROM `project.dataset.table` WHERE ", + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectedErrs: []source.Error{ + { + Msg: "INVALID_ARGUMENT: Syntax error: Unexpected end of script", + Position: lsp.Position{ + Line: 0, + Character: 43, + }, + TermLength: 0, + }, + }, + }, + "parse Unexpected end of script error with GROUP BY file": { + file: "SELECT * FROM `project.dataset.table` GROUP BY", + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectedErrs: []source.Error{ + { + Msg: "INVALID_ARGUMENT: Syntax error: Unexpected end of script", + Position: lsp.Position{ + Line: 0, + Character: 46, + }, + TermLength: 0, + }, + }, + }, + "parse unrecognized name in select clause": { file: "SELECT unexist_column FROM `project.dataset.table`", bqTableMetadataMap: map[string]*bq.TableMetadata{ "project.dataset.table": { @@ -69,6 +140,78 @@ func TestProject_ParseFile(t *testing.T) { }, }, }, + "parse only unrecognized name in where clause": { + file: "SELECT id FROM `project.dataset.table` WHERE unexist_column", + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectedErrs: []source.Error{ + { + Msg: "INVALID_ARGUMENT: Unrecognized name: unexist_column", + Position: lsp.Position{ + Line: 0, + Character: 45, + }, + TermLength: 14, + IncompleteColumnName: "unexist_column", + }, + }, + }, + "parse unrecognized name with binary expression in where clause": { + file: "SELECT id FROM `project.dataset.table` WHERE unexist_column = 1", + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectedErrs: []source.Error{ + { + Msg: "INVALID_ARGUMENT: Unrecognized name: unexist_column", + Position: lsp.Position{ + Line: 0, + Character: 45, + }, + TermLength: 14, + IncompleteColumnName: "unexist_column", + }, + }, + }, + "parse unrecognized name in GROUP BY clause": { + file: "SELECT * FROM `project.dataset.table` GROUP BY unexist_column", + bqTableMetadataMap: map[string]*bq.TableMetadata{ + "project.dataset.table": { + Schema: bq.Schema{ + { + Name: "id", + Type: bq.IntegerFieldType, + }, + }, + }, + }, + expectedErrs: []source.Error{ + { + Msg: "INVALID_ARGUMENT: Unrecognized name: unexist_column", + Position: lsp.Position{ + Line: 0, + Character: 47, + }, + TermLength: 14, + IncompleteColumnName: "unexist_column", + }, + }, + }, "parse unrecognized file with recommend": { file: "SELECT timestam FROM `project.dataset.table`", bqTableMetadataMap: map[string]*bq.TableMetadata{ @@ -201,30 +344,6 @@ func TestProject_ParseFile(t *testing.T) { }, }, }, - "parse dot file": { - file: "SELECT t. FROM `project.dataset.table` t", - bqTableMetadataMap: map[string]*bq.TableMetadata{ - "project.dataset.table": { - Schema: bq.Schema{ - { - Name: "id", - Type: bq.IntegerFieldType, - }, - }, - }, - }, - expectedErrs: []source.Error{ - { - Msg: "INVALID_ARGUMENT: Unrecognized name: t.", - Position: lsp.Position{ - Line: 0, - Character: 7, - }, - TermLength: 2, - IncompleteColumnName: "t.", - }, - }, - }, } for n, tt := range tests { @@ -238,7 +357,7 @@ func TestProject_ParseFile(t *testing.T) { } bqClient.EXPECT().GetTableMetadata(gomock.Any(), tablePathSplitted[0], tablePathSplitted[1], tablePathSplitted[2]).Return(schema, nil).MinTimes(0) } - p := source.NewProjectWithBQClient("/", bqClient) + p := source.NewProjectWithBQClient("/", bqClient, logrus.New()) got := p.ParseFile("uri", tt.file) if diff := cmp.Diff(tt.expectedErrs, got.Errors, cmpopts.IgnoreUnexported()); diff != "" { @@ -284,7 +403,7 @@ func TestProject_ParseFileWithIncompleteTable(t *testing.T) { for n, tt := range tests { t.Run(n, func(t *testing.T) { bqClient := tt.bigqueryClientMockFunc(t) - p := source.NewProjectWithBQClient("/", bqClient) + p := source.NewProjectWithBQClient("/", bqClient, logrus.New()) got := p.ParseFile("uri", tt.file) if diff := cmp.Diff(tt.expectedErrs, got.Errors, cmpopts.IgnoreUnexported()); diff != "" { diff --git a/langserver/internal/source/helper/file.go b/langserver/internal/source/helper/file.go index 2dcbd25..c87f016 100644 --- a/langserver/internal/source/helper/file.go +++ b/langserver/internal/source/helper/file.go @@ -31,7 +31,7 @@ func indexToPosition(file string, index int) lsp.Position { col, row := 0, 0 lines := strings.Split(file, "\n") for _, line := range lines { - if index < len(line) { + if index <= len(line) { col = index break } diff --git a/langserver/internal/source/project.go b/langserver/internal/source/project.go index 8ffdd6c..64ccbe5 100644 --- a/langserver/internal/source/project.go +++ b/langserver/internal/source/project.go @@ -50,11 +50,12 @@ func NewProject(ctx context.Context, rootPath string, logger *logrus.Logger) (*P }, nil } -func NewProjectWithBQClient(rootPath string, bqClient bigquery.Client) *Project { +func NewProjectWithBQClient(rootPath string, bqClient bigquery.Client, logger *logrus.Logger) *Project { cache := cache.NewGlobalCache() catalog := NewCatalog(bqClient) return &Project{ rootPath: rootPath, + logger: logger, cache: cache, bqClient: bqClient, catalog: catalog,