diff --git a/handlers/sshd_config/analyzer/analyzer.go b/handlers/sshd_config/analyzer/analyzer.go index 2d75767..dae9924 100644 --- a/handlers/sshd_config/analyzer/analyzer.go +++ b/handlers/sshd_config/analyzer/analyzer.go @@ -8,39 +8,45 @@ import ( protocol "github.com/tliron/glsp/protocol_3_16" ) +type analyzerContext struct { + document *sshdconfig.SSHDDocument + diagnostics []protocol.Diagnostic +} + func Analyze( d *sshdconfig.SSHDDocument, ) []protocol.Diagnostic { - errors := analyzeStructureIsValid(d) + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } - if len(errors) > 0 { - return common.ErrsToDiagnostics(errors) + analyzeStructureIsValid(ctx) + + if len(ctx.diagnostics) > 0 { + return ctx.diagnostics } i, indexErrors := indexes.CreateIndexes(*d.Config) d.Indexes = i - errors = append(errors, indexErrors...) - - if len(errors) > 0 { - return common.ErrsToDiagnostics(errors) + if len(indexErrors) > 0 { + return common.ErrsToDiagnostics(indexErrors) } - includeErrors := analyzeIncludeValues(d) + analyzeIncludeValues(ctx) - if len(includeErrors) > 0 { - errors = append(errors, includeErrors...) - } else { + if len(ctx.diagnostics) == 0 { for _, include := range d.Indexes.Includes { for _, value := range include.Values { for _, path := range value.Paths { _, err := parseFile(string(path)) if err != nil { - errors = append(errors, common.LSPError{ - Range: value.LocationRange, - Err: err, + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: value.LocationRange.ToLSPRange(), + Message: err.Error(), }) } } @@ -48,11 +54,7 @@ func Analyze( } } - errors = append(errors, analyzeMatchBlocks(d)...) + analyzeMatchBlocks(ctx) - if len(errors) > 0 { - return common.ErrsToDiagnostics(errors) - } - - return nil + return ctx.diagnostics } diff --git a/handlers/sshd_config/analyzer/include.go b/handlers/sshd_config/analyzer/include.go index 3cf9833..12425ff 100644 --- a/handlers/sshd_config/analyzer/include.go +++ b/handlers/sshd_config/analyzer/include.go @@ -12,31 +12,30 @@ import ( "path" "path/filepath" "regexp" + + protocol "github.com/tliron/glsp/protocol_3_16" ) var whitespacePattern = regexp.MustCompile(`\S+`) func analyzeIncludeValues( - d *sshdconfig.SSHDDocument, -) []common.LSPError { - errs := make([]common.LSPError, 0) - - for _, include := range d.Indexes.Includes { + ctx *analyzerContext, +) { + for _, include := range ctx.document.Indexes.Includes { for _, value := range include.Values { validPaths, err := createIncludePaths(value.Value) if err != nil { - errs = append(errs, common.LSPError{ - Range: value.LocationRange, - Err: err, + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: value.LocationRange.ToLSPRange(), + Message: err.Error(), + Severity: &common.SeverityError, }) } else { value.Paths = validPaths } } } - - return errs } func createIncludePaths( diff --git a/handlers/sshd_config/analyzer/match.go b/handlers/sshd_config/analyzer/match.go index afd3dec..fee46ee 100644 --- a/handlers/sshd_config/analyzer/match.go +++ b/handlers/sshd_config/analyzer/match.go @@ -3,27 +3,26 @@ package analyzer import ( "config-lsp/common" docvalues "config-lsp/doc-values" - sshdconfig "config-lsp/handlers/sshd_config" "config-lsp/handlers/sshd_config/fields" "config-lsp/handlers/sshd_config/match-parser" "config-lsp/utils" - "errors" "fmt" "strings" + + protocol "github.com/tliron/glsp/protocol_3_16" ) func analyzeMatchBlocks( - d *sshdconfig.SSHDDocument, -) []common.LSPError { - errs := make([]common.LSPError, 0) - - for matchBlock, options := range d.Indexes.AllOptionsPerName["Match"] { + ctx *analyzerContext, +) { + for matchBlock, options := range ctx.document.Indexes.AllOptionsPerName["Match"] { option := options[0] // Check if the match block has filled out all fields if matchBlock == nil || matchBlock.MatchValue == nil || len(matchBlock.MatchValue.Entries) == 0 { - errs = append(errs, common.LSPError{ - Range: option.LocationRange, - Err: errors.New("A match expression is required"), + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: option.ToLSPRange(), + Message: "A match expression is required", + Severity: &common.SeverityError, }) continue @@ -31,46 +30,48 @@ func analyzeMatchBlocks( for _, entry := range matchBlock.MatchValue.Entries { if entry.Values == nil { - errs = append(errs, common.LSPError{ - Range: entry.LocationRange, - Err: errors.New(fmt.Sprintf("A value for %s is required", entry.Criteria.Type)), + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: entry.ToLSPRange(), + Message: fmt.Sprintf("A value for %s is required", entry.Criteria.Type), + Severity: &common.SeverityError, }) continue } - errs = append(errs, analyzeMatchValuesContainsPositiveValue(entry.Values)...) + analyzeMatchValuesContainsPositiveValue(ctx, entry.Values) for _, value := range entry.Values.Values { - errs = append(errs, analyzeMatchValueNegation(value)...) - errs = append(errs, analyzeMatchValueIsValid(value, entry.Criteria.Type)...) + analyzeMatchValueNegation(ctx, value) + analyzeMatchValueIsValid(ctx, value, entry.Criteria.Type) } } // Check if match blocks are not empty if matchBlock.Options.Size() == 0 { - errs = append(errs, common.LSPError{ - Range: option.LocationRange, - Err: errors.New("This match block is empty"), + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: option.ToLSPRange(), + Message: "This match block is empty", + Severity: &common.SeverityInformation, + Tags: []protocol.DiagnosticTag{ + protocol.DiagnosticTagUnnecessary, + }, }) } } - - return errs } func analyzeMatchValueNegation( + ctx *analyzerContext, value *matchparser.MatchValue, -) []common.LSPError { - errs := make([]common.LSPError, 0) - +) { positionsAsList := utils.AllIndexes(value.Value.Raw, "!") positions := utils.SliceToMap(positionsAsList, struct{}{}) delete(positions, 0) for position := range positions { - errs = append(errs, common.LSPError{ + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ Range: common.LocationRange{ Start: common.Location{ Line: value.Start.Line, @@ -80,19 +81,19 @@ func analyzeMatchValueNegation( Line: value.End.Line, Character: uint32(position) + value.End.Character, }, - }, - Err: errors.New("The negation operator (!) may only occur at the beginning of a value"), + }.ToLSPRange(), + Message: "The negation operator (!) may only occur at the beginning of a value", + Severity: &common.SeverityError, }) } - - return errs } func analyzeMatchValuesContainsPositiveValue( + ctx *analyzerContext, values *matchparser.MatchValues, -) []common.LSPError { +) { if len(values.Values) == 0 { - return nil + return } containsPositive := false @@ -105,42 +106,34 @@ func analyzeMatchValuesContainsPositiveValue( } if !containsPositive { - return []common.LSPError{ - { - Range: values.LocationRange, - Err: errors.New("At least one positive value is required. A negated match will never produce a positive result by itself"), - }, - } + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: values.LocationRange.ToLSPRange(), + Message: "At least one positive value is required. A negated match will never produce a positive result by itself", + Severity: &common.SeverityError, + }) } - - return nil } func analyzeMatchValueIsValid( + ctx *analyzerContext, value *matchparser.MatchValue, criteria matchparser.MatchCriteriaType, -) []common.LSPError { - errs := make([]common.LSPError, 0) - +) { if value.Value.Raw == "" { - return errs + return } docOption := fields.MatchValueFieldMap[criteria] invalidValues := docOption.DeprecatedCheckIsValid(value.Value.Raw) - errs = append( - errs, - utils.Map( - invalidValues, - func(invalidValue *docvalues.InvalidValue) common.LSPError { - err := docvalues.LSPErrorFromInvalidValue(value.Start.Line, *invalidValue) - err.ShiftCharacter(value.Start.Character) + for _, invalidValue := range invalidValues { + err := docvalues.LSPErrorFromInvalidValue(value.Start.Line, *invalidValue) + err.ShiftCharacter(value.Start.Character) - return err - }, - )..., - ) - - return errs + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: err.Range.ToLSPRange(), + Message: err.Err.Error(), + Severity: &common.SeverityError, + }) + } } diff --git a/handlers/sshd_config/analyzer/match_test.go b/handlers/sshd_config/analyzer/match_test.go index ab45c1a..153c617 100644 --- a/handlers/sshd_config/analyzer/match_test.go +++ b/handlers/sshd_config/analyzer/match_test.go @@ -6,6 +6,8 @@ import ( "config-lsp/handlers/sshd_config/indexes" "config-lsp/utils" "testing" + + protocol "github.com/tliron/glsp/protocol_3_16" ) func TestEmptyMatchBlocksMakesErrors( @@ -22,6 +24,42 @@ Match User root t.Fatalf("Parse error: %v", errors) } + i, errors := indexes.CreateIndexes(*c) + + if len(errors) > 0 { + t.Fatalf("Index error: %v", errors) + } + + d := &sshdconfig.SSHDDocument{ + Config: c, + Indexes: i, + } + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } + + analyzeMatchBlocks(ctx) + + if !(len(ctx.diagnostics) == 1) { + t.Errorf("Expected 1 error, got %v", len(ctx.diagnostics)) + } +} + +func TestContainsOnlyNegativeValues( + t *testing.T, +) { + input := utils.Dedent(` +PermitRootLogin yes +Match User !root,!admin +`) + c := ast.NewSSHDConfig() + errors := c.Parse(input) + + if len(errors) > 0 { + t.Fatalf("Parse error: %v", errors) + } + indexes, errors := indexes.CreateIndexes(*c) if len(errors) > 0 { @@ -32,33 +70,16 @@ Match User root Config: c, Indexes: indexes, } - - errors = analyzeMatchBlocks(d) - - if !(len(errors) == 1) { - t.Errorf("Expected 1 error, got %v", len(errors)) - } -} - -func TestContainsOnlyNegativeValues( - t *testing.T, -) { - input := utils.Dedent(` -PermitRootLogin yes -Match User !root,!admin -`) - c := ast.NewSSHDConfig() - errors := c.Parse(input) - - if len(errors) > 0 { - t.Fatalf("Parse error: %v", errors) + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), } _, matchBlock := c.FindOption(uint32(1)) - errors = analyzeMatchValuesContainsPositiveValue(matchBlock.MatchValue.Entries[0].Values) + analyzeMatchValuesContainsPositiveValue(ctx, matchBlock.MatchValue.Entries[0].Values) - if !(len(errors) == 1) { - t.Errorf("Expected 1 error, got %v", len(errors)) + if !(len(ctx.diagnostics) == 1) { + t.Errorf("Expected 1 error, got %v", len(ctx.diagnostics)) } } @@ -87,11 +108,15 @@ Match User Config: c, Indexes: i, } + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } - errors = analyzeMatchBlocks(d) + analyzeMatchBlocks(ctx) - if !(len(errors) == 1) { - t.Errorf("Expected 1 error, got %v", len(errors)) + if !(len(ctx.diagnostics) == 1) { + t.Errorf("Expected 1 error, got %v", len(ctx.diagnostics)) } } @@ -120,10 +145,14 @@ Match User Config: c, Indexes: i, } + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } - errors = analyzeMatchBlocks(d) + analyzeMatchBlocks(ctx) - if !(len(errors) == 1) { - t.Errorf("Expected 1 error, got %v", len(errors)) + if !(len(ctx.diagnostics) == 1) { + t.Errorf("Expected 1 error, got %v", len(ctx.diagnostics)) } } diff --git a/handlers/sshd_config/analyzer/options.go b/handlers/sshd_config/analyzer/options.go index 9863b04..6076067 100644 --- a/handlers/sshd_config/analyzer/options.go +++ b/handlers/sshd_config/analyzer/options.go @@ -3,119 +3,102 @@ package analyzer import ( "config-lsp/common" docvalues "config-lsp/doc-values" - sshdconfig "config-lsp/handlers/sshd_config" "config-lsp/handlers/sshd_config/ast" "config-lsp/handlers/sshd_config/fields" - "config-lsp/utils" - "errors" "fmt" + + protocol "github.com/tliron/glsp/protocol_3_16" ) func analyzeStructureIsValid( - d *sshdconfig.SSHDDocument, -) []common.LSPError { - errs := make([]common.LSPError, 0) - it := d.Config.Options.Iterator() + ctx *analyzerContext, +) { + it := ctx.document.Config.Options.Iterator() for it.Next() { entry := it.Value().(ast.SSHDEntry) switch entry.(type) { case *ast.SSHDOption: - errs = append(errs, checkOption(entry.(*ast.SSHDOption), false)...) + checkOption(ctx, entry.(*ast.SSHDOption), false) case *ast.SSHDMatchBlock: matchBlock := entry.(*ast.SSHDMatchBlock) - errs = append(errs, checkMatchBlock(matchBlock)...) + checkMatchBlock(ctx, matchBlock) } - } - - return errs } func checkOption( + ctx *analyzerContext, option *ast.SSHDOption, isInMatchBlock bool, -) []common.LSPError { - errs := make([]common.LSPError, 0) - +) { if option.Key == nil { - return errs + return } - errs = append(errs, checkIsUsingDoubleQuotes(option.Key.Value, option.Key.LocationRange)...) - errs = append(errs, checkQuotesAreClosed(option.Key.Value, option.Key.LocationRange)...) + checkIsUsingDoubleQuotes(ctx, option.Key.Value, option.Key.LocationRange) + checkQuotesAreClosed(ctx, option.Key.Value, option.Key.LocationRange) docOption, found := fields.Options[option.Key.Key] if !found { - errs = append(errs, common.LSPError{ - Range: option.Key.LocationRange, - Err: errors.New(fmt.Sprintf("Unknown option: %s", option.Key.Key)), + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: option.Key.ToLSPRange(), + Message: fmt.Sprintf("Unknown option: %s", option.Key.Key), + Severity: &common.SeverityError, }) - return errs + return } if _, found := fields.MatchAllowedOptions[option.Key.Key]; !found && isInMatchBlock { - errs = append(errs, common.LSPError{ - Range: option.Key.LocationRange, - Err: errors.New(fmt.Sprintf("Option '%s' is not allowed inside Match blocks", option.Key.Key)), + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: option.Key.ToLSPRange(), + Message: fmt.Sprintf("Option '%s' is not allowed inside Match blocks", option.Key.Key), + Severity: &common.SeverityError, }) - - return errs } - if option.OptionValue == nil || option.OptionValue.Value.Value == "" { - errs = append(errs, common.LSPError{ - Range: option.Key.LocationRange, - Err: errors.New(fmt.Sprintf("Option '%s' requires a value", option.Key.Key)), - }) - } else { - errs = append(errs, checkIsUsingDoubleQuotes(option.OptionValue.Value, option.OptionValue.LocationRange)...) - errs = append(errs, checkQuotesAreClosed(option.OptionValue.Value, option.OptionValue.LocationRange)...) + if option.OptionValue != nil { + checkIsUsingDoubleQuotes(ctx, option.OptionValue.Value, option.OptionValue.LocationRange) + checkQuotesAreClosed(ctx, option.OptionValue.Value, option.OptionValue.LocationRange) invalidValues := docOption.DeprecatedCheckIsValid(option.OptionValue.Value.Value) - errs = append( - errs, - utils.Map( - invalidValues, - func(invalidValue *docvalues.InvalidValue) common.LSPError { - err := docvalues.LSPErrorFromInvalidValue(option.Start.Line, *invalidValue) - err.ShiftCharacter(option.OptionValue.Start.Character) + for _, invalidValue := range invalidValues { + err := docvalues.LSPErrorFromInvalidValue(option.Start.Line, *invalidValue) + err.ShiftCharacter(option.OptionValue.Start.Character) - return err - }, - )..., - ) + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: err.Range.ToLSPRange(), + Message: err.Err.Error(), + Severity: &common.SeverityError, + }) + } } if option.Separator == nil || option.Separator.Value.Value == "" { - errs = append(errs, common.LSPError{ - Range: option.Key.LocationRange, - Err: errors.New(fmt.Sprintf("There should be a separator between an option and its value")), + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: option.Key.LocationRange.ToLSPRange(), + Message: fmt.Sprintf("There should be a separator between an option and its value"), + Severity: &common.SeverityError, }) } else { - errs = append(errs, checkIsUsingDoubleQuotes(option.Separator.Value, option.Separator.LocationRange)...) - errs = append(errs, checkQuotesAreClosed(option.Separator.Value, option.Separator.LocationRange)...) + checkIsUsingDoubleQuotes(ctx, option.Separator.Value, option.Separator.LocationRange) + checkQuotesAreClosed(ctx, option.Separator.Value, option.Separator.LocationRange) } - - return errs } func checkMatchBlock( + ctx *analyzerContext, matchBlock *ast.SSHDMatchBlock, -) []common.LSPError { - errs := make([]common.LSPError, 0) - +) { it := matchBlock.Options.Iterator() for it.Next() { option := it.Value().(*ast.SSHDOption) - errs = append(errs, checkOption(option, true)...) + checkOption(ctx, option, true) } - - return errs } diff --git a/handlers/sshd_config/analyzer/quotes.go b/handlers/sshd_config/analyzer/quotes.go index 5d080f0..13e9920 100644 --- a/handlers/sshd_config/analyzer/quotes.go +++ b/handlers/sshd_config/analyzer/quotes.go @@ -3,57 +3,52 @@ package analyzer import ( "config-lsp/common" commonparser "config-lsp/common/parser" - sshdconfig "config-lsp/handlers/sshd_config" - "errors" + "config-lsp/utils" "strings" + + protocol "github.com/tliron/glsp/protocol_3_16" ) func analyzeQuotesAreValid( - d *sshdconfig.SSHDDocument, -) []common.LSPError { - errs := make([]common.LSPError, 0) + ctx *analyzerContext, +) { + for _, option := range ctx.document.Config.GetAllOptions() { + checkIsUsingDoubleQuotes(ctx, option.Key.Value, option.Key.LocationRange) + checkIsUsingDoubleQuotes(ctx, option.OptionValue.Value, option.OptionValue.LocationRange) - for _, option := range d.Config.GetAllOptions() { - errs = append(errs, checkIsUsingDoubleQuotes(option.Key.Value, option.Key.LocationRange)...) - errs = append(errs, checkIsUsingDoubleQuotes(option.OptionValue.Value, option.OptionValue.LocationRange)...) - - errs = append(errs, checkQuotesAreClosed(option.Key.Value, option.Key.LocationRange)...) - errs = append(errs, checkQuotesAreClosed(option.OptionValue.Value, option.OptionValue.LocationRange)...) + checkQuotesAreClosed(ctx, option.Key.Value, option.Key.LocationRange) + checkQuotesAreClosed(ctx, option.OptionValue.Value, option.OptionValue.LocationRange) } - - return errs } func checkIsUsingDoubleQuotes( + ctx *analyzerContext, value commonparser.ParsedString, valueRange common.LocationRange, -) []common.LSPError { +) { + quoteRanges := utils.GetQuoteRanges(value.Raw) singleQuotePosition := strings.Index(value.Raw, "'") - if singleQuotePosition != -1 { - return []common.LSPError{ - { - Range: valueRange, - Err: errors.New("sshd_config does not support single quotes. Use double quotes (\") instead."), - }, - } + // Single quote + if singleQuotePosition != -1 && !quoteRanges.IsCharInside(singleQuotePosition) { + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: valueRange.ToLSPRange(), + Message: "sshd_config does not support single quotes. Use double quotes (\") instead.", + Severity: &common.SeverityError, + }) } - - return nil } func checkQuotesAreClosed( + ctx *analyzerContext, value commonparser.ParsedString, valueRange common.LocationRange, -) []common.LSPError { +) { if strings.Count(value.Raw, "\"")%2 != 0 { - return []common.LSPError{ - { - Range: valueRange, - Err: errors.New("There are unclosed quotes here. Make sure all quotes are closed."), - }, - } + ctx.diagnostics = append(ctx.diagnostics, protocol.Diagnostic{ + Range: valueRange.ToLSPRange(), + Message: "There are unclosed quotes here. Make sure all quotes are closed.", + Severity: &common.SeverityError, + }) } - - return nil } diff --git a/handlers/sshd_config/analyzer/quotes_test.go b/handlers/sshd_config/analyzer/quotes_test.go index ba9ff55..152bc11 100644 --- a/handlers/sshd_config/analyzer/quotes_test.go +++ b/handlers/sshd_config/analyzer/quotes_test.go @@ -3,6 +3,8 @@ package analyzer import ( testutils_test "config-lsp/handlers/sshd_config/test_utils" "testing" + + protocol "github.com/tliron/glsp/protocol_3_16" ) func TestSimpleInvalidQuotesExample( @@ -11,11 +13,15 @@ func TestSimpleInvalidQuotesExample( d := testutils_test.DocumentFromInput(t, ` PermitRootLogin 'yes' `) + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } - errors := analyzeQuotesAreValid(d) + analyzeQuotesAreValid(ctx) - if !(len(errors) == 1) { - t.Errorf("Expected 1 error, got %v", len(errors)) + if !(len(ctx.diagnostics) == 1) { + t.Errorf("Expected 1 error, got %v", len(ctx.diagnostics)) } } @@ -25,11 +31,15 @@ func TestSingleQuotesKeyAndOptionExample( d := testutils_test.DocumentFromInput(t, ` 'Port' '22' `) + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } - errors := analyzeQuotesAreValid(d) + analyzeQuotesAreValid(ctx) - if !(len(errors) == 2) { - t.Errorf("Expected 2 errors, got %v", len(errors)) + if !(len(ctx.diagnostics) == 2) { + t.Errorf("Expected 2 errors, got %v", len(ctx.diagnostics)) } } @@ -39,11 +49,15 @@ func TestSimpleUnclosedQuoteExample( d := testutils_test.DocumentFromInput(t, ` PermitRootLogin "yes `) + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } - errors := analyzeQuotesAreValid(d) + analyzeQuotesAreValid(ctx) - if !(len(errors) == 1) { - t.Errorf("Expected 1 error, got %v", len(errors)) + if !(len(ctx.diagnostics) == 1) { + t.Errorf("Expected 1 error, got %v", len(ctx.diagnostics)) } } @@ -53,10 +67,14 @@ func TestIncompleteQuotesExample( d := testutils_test.DocumentFromInput(t, ` "Port `) + ctx := &analyzerContext{ + document: d, + diagnostics: make([]protocol.Diagnostic, 0), + } - errors := analyzeQuotesAreValid(d) + analyzeQuotesAreValid(ctx) - if !(len(errors) == 1) { - t.Errorf("Expected 1 error, got %v", len(errors)) + if !(len(ctx.diagnostics) == 1) { + t.Errorf("Expected 1 error, got %v", len(ctx.diagnostics)) } }