github.com/drellem2/pogo@v0.0.0-20240503070746-2c2b76da329a/internal/plugins/search/search_impl.go (about)

     1  package search
     2  
     3  import (
     4  	"encoding/json"
     5  	"net/url"
     6  	"os"
     7  	"path/filepath"
     8  	"strconv"
     9  
    10  	"github.com/fsnotify/fsnotify"
    11  	"github.com/hashicorp/go-hclog"
    12  	"github.com/hashicorp/go-plugin"
    13  
    14  	pogoPlugin "github.com/drellem2/pogo/pkg/plugin"
    15  )
    16  
    17  const pogoDir = ".pogo"
    18  const searchDir = "search"
    19  
    20  // API Version for this plugin
    21  const version = "0.0.1"
    22  
    23  const UseWatchers = true
    24  
    25  type BasicSearch struct {
    26  	logger   hclog.Logger
    27  	projects map[string]IndexedProject
    28  	watcher  *fsnotify.Watcher
    29  	updater  *ProjectUpdater
    30  }
    31  
    32  // Input to an "Execute" call should be a serialized SearchRequest
    33  type SearchRequest struct {
    34  	// Values: "search" or "files"
    35  	Type        string `json:"type"`
    36  	ProjectRoot string `json:"projectRoot"`
    37  	// Command timeout duration - only for 'search'-type requests
    38  	Duration string `json:"string"`
    39  	Data     string `json:"data"`
    40  }
    41  
    42  type SearchResponse struct {
    43  	Index   IndexedProject `json:"index"`
    44  	Results SearchResults  `json:"results"`
    45  	Error   string         `json:"error"`
    46  }
    47  
    48  type ErrorResponse struct {
    49  	ErrorCode int    `json:"errorCode"`
    50  	Error     string `json:"error"`
    51  }
    52  
    53  func New() func() (pogoPlugin.IPogoPlugin, error) {
    54  	return func() (pogoPlugin.IPogoPlugin, error) {
    55  		return createBasicSearch(), nil
    56  	}
    57  }
    58  
    59  func clean(path string) string {
    60  	// Append a trailing delimiter if it doesn't exist
    61  	p := filepath.Clean(path)
    62  	if p[len(p)-1] != filepath.Separator {
    63  		p += string(filepath.Separator)
    64  	}
    65  	return p
    66  }
    67  
    68  func (g *BasicSearch) printSearchResponse(response SearchResponse) string {
    69  	// Instead of marshalling the obect, write code to go through all fields
    70  	// and concatenate them into a string.
    71  	var str string
    72  	str += "Index: " + response.Index.Root + "\n"
    73  	str += "Paths: " + "\n"
    74  	for _, path := range response.Index.Paths {
    75  		str += path + "\n"
    76  	}
    77  	str += "Results: " + "\n"
    78  	for _, result := range response.Results.Files {
    79  		str += "\t" + result.Path + "\n"
    80  		for _, match := range result.Matches {
    81  			// Convert match.Content bytes to string
    82  			var lineStr = strconv.FormatUint(uint64(match.Line), 10)
    83  			str += "\t\t" + lineStr + "\n"
    84  			if len(match.Content) > 0 {
    85  				// str += "\t\t" + string(match.Content) + "\n"
    86  				str += "\t\t" + match.Content + "\n"
    87  			} else {
    88  				str += "\t\t" + "No content" + "\n"
    89  			}
    90  		}
    91  	}
    92  	str += "Error: " + response.Error + "\n"
    93  	return str
    94  }
    95  
    96  func (g *BasicSearch) errorResponse(code int, message string) string {
    97  	resp := ErrorResponse{ErrorCode: code, Error: message}
    98  	bytes, err := json.Marshal(&resp)
    99  	if err != nil {
   100  		g.logger.Error("Error writing error response")
   101  		panic(err)
   102  	}
   103  	return url.QueryEscape(string(bytes))
   104  }
   105  
   106  func (g *BasicSearch) searchResponse(index *IndexedProject, results *SearchResults) string {
   107  	var response SearchResponse
   108  	if index == nil {
   109  		indexedProject := IndexedProject{Root: "", Paths: []string{}}
   110  		response.Index = indexedProject
   111  	} else {
   112  		response.Index = *index
   113  	}
   114  	if results == nil {
   115  		g.logger.Info("Search response was nil")
   116  		searchResults := SearchResults{}
   117  		response.Results = searchResults
   118  	} else {
   119  		response.Results = *results
   120  	}
   121  	response.Error = ""
   122  
   123  	bytes, err := json.Marshal(&response)
   124  	if err != nil {
   125  		g.logger.Error("Error writing search response")
   126  		return g.errorResponse(500, "Error writing search response")
   127  	}
   128  	return url.QueryEscape(string(bytes))
   129  }
   130  
   131  func (g *BasicSearch) Info() *pogoPlugin.PluginInfoRes {
   132  	g.logger.Debug("Returning version %s", version)
   133  	return &pogoPlugin.PluginInfoRes{Version: version}
   134  }
   135  
   136  // Executes a command sent to this plugin.
   137  func (g *BasicSearch) Execute(encodedReq string) string {
   138  	g.logger.Debug("Executing request.")
   139  	req, err2 := url.QueryUnescape(encodedReq)
   140  	if err2 != nil {
   141  		g.logger.Error("500 Could not query decode request.", "error", err2)
   142  		return g.errorResponse(500, "Could not query decode request.")
   143  	}
   144  	var searchRequest SearchRequest
   145  	err := json.Unmarshal([]byte(req), &searchRequest)
   146  	if err != nil {
   147  		g.logger.Info("400 Invalid request.", "error", err)
   148  		return g.errorResponse(400, "Invalid request.")
   149  	}
   150  
   151  	switch reqType := searchRequest.Type; reqType {
   152  	case "search":
   153  		searchRequest.ProjectRoot = clean(searchRequest.ProjectRoot)
   154  		results, err := g.Search(searchRequest.ProjectRoot,
   155  			searchRequest.Data, searchRequest.Duration)
   156  		if err != nil {
   157  			g.logger.Error("500 Error executing search.", "error", err)
   158  			return g.errorResponse(500, "Error executing search.")
   159  		}
   160  		return g.searchResponse(nil, results)
   161  	case "files":
   162  		searchRequest.ProjectRoot = clean(searchRequest.ProjectRoot)
   163  		proj, err3 := g.GetFiles(searchRequest.ProjectRoot)
   164  		if err3 != nil {
   165  			g.logger.Error("500 Error retrieving files.", "error", err3)
   166  			return g.errorResponse(500, "Error retrieving files.")
   167  		}
   168  		return g.searchResponse(proj, nil)
   169  	default:
   170  		g.logger.Info("404 Unknown request type.", "type", searchRequest.Type)
   171  		return g.errorResponse(404, "Unknown request type.")
   172  	}
   173  
   174  }
   175  
   176  func (g *BasicSearch) ProcessProject(req *pogoPlugin.IProcessProjectReq) error {
   177  	g.logger.Info("Processing project %s", (*req).Path())
   178  	proj, err := g.Load((*req).Path())
   179  	if err != nil {
   180  		g.logger.Error("Error processing project", "error", err)
   181  	}
   182  	if err != nil || len(proj.Paths) == 0 {
   183  		go g.Index(req)
   184  	}
   185  	return nil
   186  }
   187  
   188  func (g *BasicSearch) Close() {
   189  	g.watcher.Close()
   190  }
   191  
   192  // handshakeConfigs are used to just do a basic handshake betw1een
   193  // a plugin and host. If the handshake fails, a user friendly error is shown.
   194  // This prevents users from executing bad plugins or executing a plugin
   195  // directory. It is a UX feature, not a security feature.
   196  var handshakeConfig = plugin.HandshakeConfig{
   197  	ProtocolVersion:  2,
   198  	MagicCookieKey:   "SEARCH_PLUGIN",
   199  	MagicCookieValue: "93f6bc9f97c03ed00fa85c904aca15a92752e549",
   200  }
   201  
   202  // Ensure's plugin directory exists in project config
   203  // Returns full path of search dir
   204  func (p *IndexedProject) makeSearchDir() (string, error) {
   205  	fullSearchDir := filepath.Join(p.Root, pogoDir, searchDir)
   206  	err := os.MkdirAll(fullSearchDir, os.ModePerm)
   207  	if err != nil {
   208  		return "", err
   209  	}
   210  	return fullSearchDir, nil
   211  }
   212  
   213  func createBasicSearch() *BasicSearch {
   214  	logger := hclog.New(&hclog.LoggerOptions{
   215  		Level:      hclog.Info,
   216  		Output:     os.Stderr,
   217  		JSONFormat: true,
   218  	})
   219  
   220  	watcher, err := fsnotify.NewWatcher()
   221  	if err != nil {
   222  		logger.Error("Could not create file watcher. Index will run frequently.")
   223  	}
   224  
   225  	basicSearch := &BasicSearch{
   226  		logger:   logger,
   227  		projects: make(map[string]IndexedProject),
   228  		watcher:  watcher,
   229  		updater:  nil,
   230  	}
   231  	basicSearch.updater = basicSearch.newProjectUpdater()
   232  
   233  	if UseWatchers && watcher != nil {
   234  		go func() {
   235  			for {
   236  				select {
   237  				case event, ok := <-watcher.Events:
   238  					if !ok {
   239  						logger.Warn("Not ok")
   240  						logger.Warn(event.String())
   241  						return
   242  					}
   243  					if event.Has(fsnotify.Create) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
   244  						logger.Info("File update: ", event)
   245  						basicSearch.ReIndex(event.Name)
   246  					}
   247  				case err, ok := <-watcher.Errors:
   248  
   249  					if !ok {
   250  						return
   251  					}
   252  					logger.Error("File watcher error: %v", err)
   253  				}
   254  			}
   255  		}()
   256  	}
   257  	return basicSearch
   258  }
   259  
   260  // This is how to serve a plugin remotely
   261  // func main() {
   262  // 	gob.Register(pogoPlugin.ProcessProjectReq{})
   263  
   264  // 	basicSearch := createBasicSearch()
   265  // 	defer basicSearch.watcher.Close()
   266  
   267  // 	// pluginMap is the map of plugins we can dispense.
   268  // 	var pluginMap = map[string]plugin.Plugin{
   269  // 		"basicSearch": &pogoPlugin.PogoPlugin{Impl: basicSearch},
   270  // 	}
   271  
   272  // 	plugin.Serve(&plugin.ServeConfig{
   273  // 		HandshakeConfig: handshakeConfig,
   274  // 		Plugins:         pluginMap,
   275  // 	})
   276  // }