diff --git a/go.mod b/go.mod index e3cfcfd..b811bb1 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( ) require ( + github.com/antchfx/htmlquery v1.3.4 // indirect github.com/antchfx/xpath v1.3.3 // indirect github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect github.com/aws/smithy-go v1.20.3 // indirect diff --git a/go.sum b/go.sum index 11201b4..51768c3 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/BurntSushi/xgbutil v0.0.0-20160919175755-f7c97cef3b4e/go.mod h1:uw9h2 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ= +github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM= github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg= github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc= github.com/antchfx/xpath v1.3.3 h1:tmuPQa1Uye0Ym1Zn65vxPgfltWb/Lxu2jeqIGteJSRs= diff --git a/pkg/internal/html/adapter.go b/pkg/internal/html/adapter.go new file mode 100644 index 0000000..764b946 --- /dev/null +++ b/pkg/internal/html/adapter.go @@ -0,0 +1,130 @@ +package html + +import ( + "strings" + + "github.com/antchfx/htmlquery" + "golang.org/x/net/html" +) + +type Document interface { + Find(xpath string) []Node +} + +type Node interface { + TagName() string + IsElement() bool + HasParent() bool + GetAttribute(key string) string + GetParent() Node + ChildNodes() []Node + Index() int + Equal(Node) bool +} + +type HTMLDoc struct { + root *html.Node +} + +func NewHTMLDoc(root *html.Node) *HTMLDoc { + return &HTMLDoc{root: root} +} + +func (d *HTMLDoc) Find(xpath string) []Node { + var nodes []Node + elems := htmlquery.Find(d.root, xpath) + + for _, elem := range elems { + node := NewHTMLNode(elem) + nodes = append(nodes, node) + } + return nodes +} + +func (d *HTMLDoc) Root() *HTMLNode { + return NewHTMLNode(d.root) +} + +type HTMLNode struct { + node *html.Node +} + +func NewHTMLNode(node *html.Node) *HTMLNode { + return &HTMLNode{node: node} +} + +func (n HTMLNode) TagName() string { + return n.node.Data +} + +func (n HTMLNode) IsElement() bool { + return n.node.Type == html.ElementNode +} + +func (n *HTMLNode) HasParent() bool { + return n.node.Parent != nil +} + +func (n *HTMLNode) GetAttribute(key string) string { + for _, attr := range n.node.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +func (n *HTMLNode) GetParent() Node { + return NewHTMLNode(n.node.Parent) +} + +func (n *HTMLNode) ChildNodes() []Node { + var nodes []Node + + for c := n.node.FirstChild; c != nil; c = c.NextSibling { + xn := NewHTMLNode(c) + nodes = append(nodes, xn) + } + return nodes +} + +func (n *HTMLNode) Equal(n1 Node) bool { + xn1, ok := n1.(*HTMLNode) + if !ok { + return false + } + + return n.node == xn1.node +} + +func (n *HTMLNode) Index() int { + if n.node.Parent == nil { + return 1 + } + + idx := 0 + parent := n.node.Parent + for c := parent.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && c.Data == n.node.Data { + idx += 1 + if c == n.node { + return idx + } + + } + } + return 1 +} + +func IsValidXPath(xpath, dom string) (bool, error) { + doc, err := htmlquery.Parse(strings.NewReader(dom)) + if err != nil { + return false, err + } + + elem, err := htmlquery.Query(doc, xpath) + if err != nil { + return false, err + } + return elem != nil, nil +} diff --git a/pkg/internal/html/minifier.go b/pkg/internal/html/minifier.go new file mode 100644 index 0000000..4a1a7b0 --- /dev/null +++ b/pkg/internal/html/minifier.go @@ -0,0 +1,230 @@ +package html + +import ( + "fmt" + "strings" + + "github.com/antchfx/htmlquery" + "github.com/vertexcover-io/locatr/pkg/internal/utils" + "github.com/vertexcover-io/locatr/pkg/types" + "golang.org/x/net/html" +) + +// nolint:unused +func PrintXmlTree(node *html.Node, depth int) { + if node == nil { + return + } + if node.Type == html.TextNode && strings.TrimSpace(node.Data) == "" { + return + } + + fmt.Printf("%sNode: %s", strings.Repeat(" ", depth), node.Data) + if len(node.Attr) > 0 { + fmt.Print(" [Attributes: ") + for _, attr := range node.Attr { + fmt.Printf("%s=%q ", attr.Key, attr.Val) + } + fmt.Print("]") + } + fmt.Println() + + for child := node.FirstChild; child != nil; child = child.NextSibling { + PrintXmlTree(child, depth+1) + } +} + +func findFirstElementNode(node *html.Node) *html.Node { + // If the current node is an element node, return it immediately + if node.Type == html.ElementNode { + return node + } + + // Recursively search through child nodes + for child := node.FirstChild; child != nil; child = child.NextSibling { + // Recursively call findFirstElementNode on each child + found := findFirstElementNode(child) + // If an element node is found, return it + if found != nil { + return found + } + } + + // If no element node is found, return nil + return nil +} + +// For HTML, unless we evaluate CSS as well, we can never be certain if the +// element is visible or not. However, we eliminate the base cases that +// is possible with html only. +func isElementVisible(element *html.Node) bool { + // 1. Skip non-element Nodes + if element.Type != html.ElementNode { + return false + } + + // 2. Tags that never render visible content + switch strings.ToLower(element.Data) { + case "script", "style", "template", "noscript", "head", "meta", "link": + return false + } + + // 3. Check if element has hidden attribute + if hasAttr(element, "hidden") { + return false + } + + // 4. Check if aria hidden has been applied + if val, ok := attrVal(element, "aria-hidden"); ok && strings.EqualFold(val, "true") { + return false + } + + // 5. Check if element is hidden with inline-styles + if style, ok := attrVal(element, "style"); ok { + s := strings.ToLower(style) + if strings.Contains(s, "display:none") || + strings.Contains(s, "visibility:hidden") || + strings.Contains(s, "opacity: 0") { + return false + } + } + + return true +} + +func hasAttr(element *html.Node, name string) bool { + _, ok := attrVal(element, name) + return ok +} + +func attrVal(element *html.Node, name string) (string, bool) { + for _, a := range element.Attr { + if strings.EqualFold(a.Key, name) { + return a.Val, true + } + } + return "", false +} + +func escapeString(str string) string { + return html.EscapeString(str) +} + +func getVisibleText(element *html.Node) string { + txt := element.Data + return escapeString(strings.TrimSpace(txt)) +} + +func isElementValid(element *html.Node) bool { + if element.Type == html.TextNode && strings.TrimSpace(element.Data) == "" { + return false + } + if element.Data == "hierarchy" { + return true + } + // this check is essential, in iOS, there are cases where the parent heirarchy is marked as + // not visible, despite having children as visible. In case of iOS, we can't trust on + // element visibility. + if element.FirstChild != nil { + return true + } + visible := isElementVisible(element) + return visible +} + +func attrsToMap(attrs []html.Attribute) map[string]string { + attrMap := make(map[string]string) + for _, attr := range attrs { + attrMap[attr.Key] = escapeString(attr.Val) + } + return attrMap +} + +// nolint:unused +func PrintLocatrs(locatrs []string) { + fmt.Printf("[") + for i, l := range locatrs { + if i == len(locatrs)-1 { + fmt.Printf("'%s'", l) + continue + } + fmt.Printf("'%s', ", l) + + } + fmt.Println("]") + +} + +func createElementSpec(element *html.Node, root *html.Node) (*types.ElementSpec, error) { + if !isElementValid(element) { + return nil, fmt.Errorf("not a valid element") + } + + text := getVisibleText(element) + doc := NewHTMLDoc(root) + node := NewHTMLNode(element) + xpath := GetOptimalXPath(doc, node) + uniqueId := utils.GenerateUniqueId(xpath) + + children := []types.ElementSpec{} + for child := element.FirstChild; child != nil; child = child.NextSibling { + c, err := createElementSpec(child, root) + if err == nil && c != nil { + children = append(children, *c) + } + } + return &types.ElementSpec{ + TagName: element.Data, + Id: uniqueId, + Attributes: attrsToMap(element.Attr), + Text: text, + Children: children, + }, nil +} + +func MinifySource(source string) (*types.ElementSpec, error) { + if source == "" { + return nil, fmt.Errorf("source is empty") + } + root, err := htmlquery.Parse(strings.NewReader(source)) + if err != nil { + return nil, err + } + node := findFirstElementNode(root) + spec, err := createElementSpec(node, node) + if err != nil { + return nil, err + } + return spec, nil +} + +func CreateLocatorMap(source string) (map[string][]string, error) { + if source == "" { + return nil, fmt.Errorf("source is empty") + } + root, err := htmlquery.Parse(strings.NewReader(source)) + if err != nil { + return nil, err + } + elementMap := make(map[string][]string) + + var processElement func(*html.Node) + + doc := NewHTMLDoc(root) + processElement = func(elem *html.Node) { + node := NewHTMLNode(elem) + xpath := GetOptimalXPath(doc, node) + if xpath != "" { + uniqueId := utils.GenerateUniqueId(xpath) + elementMap[uniqueId] = []string{xpath} + } + + for child := elem.FirstChild; child != nil; child = child.NextSibling { + if isElementValid(child) { + processElement(child) + } + } + } + processElement(findFirstElementNode(root)) + return elementMap, nil +} diff --git a/pkg/internal/html/xpath.go b/pkg/internal/html/xpath.go new file mode 100644 index 0000000..a16bd34 --- /dev/null +++ b/pkg/internal/html/xpath.go @@ -0,0 +1,196 @@ +package html + +import ( + "fmt" + "slices" + "strings" +) + +var ( + // Attributes on nodes that are likely to be unique to the node. These are considered in order + unique_xpath_attrs = []string{"name", "id", "accessibility-id"} + + // Attributes that are recommended as fallback but ideally only in conjunction with other + // attributes + maybe_unique_xpath_attrs = []string{"label", "text", "value"} +) + +func GetOptimalXPath(doc Document, domNode Node) string { + // BASE CASE #1: If this isn't an element, we're above the root, return empty string + if domNode == nil || domNode.TagName() == "" || !domNode.IsElement() { + return "" + } + + attrPairs := generateAttrPairs() + + cases := [][]string{ + // BASE CASE #2: If the node has a unique attribute or content attribute, return an absolute + // XPath with that attribute + unique_xpath_attrs, + + // BASE CASE #3: If the node has a unique pair of attributes including 'maybe' attributes, + // return an XPATH based on that pair + attrPairs, + + // BASE CASE #4: Look for 'maybe' unique attributes on its own. It's better than if we find one + // of these that's unique in conjunction with another attribute, but if not, it is still better + // than hierarchial query. + maybe_unique_xpath_attrs, + + // BASE CASE #5: Look to see if the node type is unique in the document + {}, + } + + // It's possible that in all of these cases we don't find a truly unique selector. But a selector + // qualified by attribute with an index attached, like //*[@id="foo"][1], which is still better + // than a fully path-based selector. + var semiUniqueXpath string + + for _, attrCase := range cases { + ok, isUnique, xpath := getUniqueXPATH(doc, domNode, attrCase) + if !ok { + continue + } + + if isUnique { + return xpath + } else if semiUniqueXpath == "" { + semiUniqueXpath = xpath + } + } + + // once we have gone through all our cases, if we do still have a semi unique xpath, send that back + if semiUniqueXpath != "" { + return semiUniqueXpath + } + + // otherwise fall back to a purely hierarchial expression of this dom node's position in the + // document as a last resort. + // First get the relative xpath of this node using tagname + xpath := fmt.Sprintf("/%s", domNode.TagName()) + + // if this node has siblings of the same tagname, get the index of this node + if domNode.HasParent() { + var siblings []Node + for _, child := range domNode.GetParent().ChildNodes() { + if child.IsElement() && child.TagName() == domNode.TagName() { + siblings = append(siblings, child) + } + } + + // If there's more than one sibling, append the index + if len(siblings) > 1 { + idx := domNode.Index() + + xpath = fmt.Sprintf("%s[%d]", xpath, idx) + } + + } + + // Make a recursive call to this nodes parents and preprend it to this xpath + parentXPath := GetOptimalXPath(doc, domNode.GetParent()) + return parentXPath + xpath +} + +func generateAttrPairs() []string { + var attrsForPairs []string + attrsForPairs = append(attrsForPairs, unique_xpath_attrs...) + attrsForPairs = append(attrsForPairs, maybe_unique_xpath_attrs...) + + var attrPairs []string + for i, attr := range attrsForPairs { + for j := i + 1; j < len(attrsForPairs); j += 1 { + pair := fmt.Sprintf("%s %s", attr, attrsForPairs[j]) + attrPairs = append(attrPairs, pair) + } + } + + return attrPairs +} + +func getUniqueXPATH(doc Document, domNode Node, attrs []string) (valid bool, unique bool, xpath string) { + isNodeName := len(attrs) == 0 + if isNodeName { + xpath := fmt.Sprintf("//%s", domNode.TagName()) + + isUnique, _ := determineXpathUniqueness(xpath, doc, domNode) + if isUnique { + if !domNode.HasParent() { + xpath = fmt.Sprintf("/%s", domNode.TagName()) + } + return true, true, xpath + } + return false, false, "" + } + + var uniqueXPath string + var semiUniqueXPath string + + tagForXpath := domNode.TagName() + if tagForXpath == "" { + tagForXpath = "*" + } + isPair := len(strings.Fields(attrs[0])) > 1 + + for _, attr := range attrs { + var xpath string + + if isPair { + attr1, attr2, ok := strings.Cut(attr, " ") + if !ok { + panic("generateUniqueXPATH invalid state") + } + + attr1Value, attr2Value := domNode.GetAttribute(attr1), domNode.GetAttribute(attr2) + if attr1Value == "" || attr2Value == "" { + continue + } + + xpath = fmt.Sprintf( + "//%s[@%s=\"%s\" and @%s=\"%s\"]", + tagForXpath, + attr1, attr1Value, + attr2, attr2Value, + ) + } else { + attrValue := domNode.GetAttribute(attr) + if attrValue == "" { + continue + } + xpath = fmt.Sprintf( + "//%s[@%s=\"%s\"]", + tagForXpath, + attr, attrValue, + ) + } + + isUnique, idx := determineXpathUniqueness(xpath, doc, domNode) + if isUnique { + uniqueXPath = xpath + break + } + + if semiUniqueXPath == "" { + semiUniqueXPath = fmt.Sprintf("(%s)[%d]", xpath, idx) + } + } + + if uniqueXPath != "" { + return true, true, uniqueXPath + } + if semiUniqueXPath != "" { + return true, false, semiUniqueXPath + } + return false, false, "" +} + +func determineXpathUniqueness(xpath string, doc Document, domNode Node) (bool, int) { + elems := doc.Find(xpath) + if len(elems) > 1 { + idx := slices.IndexFunc(elems, func(node Node) bool { + return domNode.Equal(node) + }) + return false, idx + 1 + } + return true, 0 +} diff --git a/pkg/internal/xml/adapter.go b/pkg/internal/xml/adapter.go index 3fb2974..565a577 100644 --- a/pkg/internal/xml/adapter.go +++ b/pkg/internal/xml/adapter.go @@ -1,6 +1,10 @@ package xml -import "github.com/antchfx/xmlquery" +import ( + "strings" + + "github.com/antchfx/xmlquery" +) type Document interface { Find(xpath string) []Node @@ -110,3 +114,16 @@ func (n *XMLNode) Index() int { } return 1 } + +func IsValidXPath(xpath, dom string) (bool, error) { + doc, err := xmlquery.Parse(strings.NewReader(dom)) + if err != nil { + return false, err + } + + elem, err := xmlquery.Query(doc, xpath) + if err != nil { + return false, err + } + return elem != nil, nil +} diff --git a/pkg/locatr.go b/pkg/locatr.go index 25b4093..afd5691 100644 --- a/pkg/locatr.go +++ b/pkg/locatr.go @@ -34,12 +34,13 @@ type Locatr struct { // config configures the behavior of the Locatr instance. type config struct { - llmClient types.LLMClientInterface - rerankerClient types.RerankerClientInterface - mode types.LocatrMode - useCache bool - cachePath string - logger *slog.Logger + llmClient types.LLMClientInterface + rerankerClient types.RerankerClientInterface + mode types.LocatrMode + useCache bool + disableReranker bool + cachePath string + logger *slog.Logger } // Option is a function that configures the config. @@ -52,6 +53,14 @@ func WithLLMClient(client types.LLMClientInterface) Option { } } +// WithRerankerDisabled disables reranking. +// Only DomAnalysisMode accepts this option +func WithRerankerDisabled() Option { + return func(opts *config) { + opts.disableReranker = true + } +} + // WithRerankerClient sets the reranker for the config. func WithRerankerClient(client types.RerankerClientInterface) Option { return func(opts *config) { @@ -112,7 +121,11 @@ func NewLocatr(plugin types.PluginInterface, opts ...Option) (*Locatr, error) { cfg.llmClient = llmClient } - if cfg.rerankerClient == nil { + if cfg.disableReranker { + cfg.rerankerClient = nil + } + + if cfg.rerankerClient == nil && !cfg.disableReranker { rerankerClient, err := reranker.DefaultRerankerClient(cfg.logger) if err != nil { return nil, err @@ -124,6 +137,13 @@ func NewLocatr(plugin types.PluginInterface, opts ...Option) (*Locatr, error) { cfg.mode = &mode.DOMAnalysisMode{} } + // validate that rerank is not null when on visualAnalysisMode + if _, ok := cfg.mode.(*mode.VisualAnalysisMode); ok { + if cfg.disableReranker { + return nil, fmt.Errorf("Reranker cannot be disabled for visual analysis mode") + } + } + instance := &Locatr{ plugin: plugin, config: cfg, diff --git a/pkg/mode/dom-analysis.go b/pkg/mode/dom-analysis.go index 0c14252..98fc5b6 100644 --- a/pkg/mode/dom-analysis.go +++ b/pkg/mode/dom-analysis.go @@ -67,16 +67,19 @@ func (m *DOMAnalysisMode) ProcessRequest( } domChunks := splitters.SplitHtml(dom.RootElement.Repr(), constants.HTML_SEPARATORS, m.ChunkSize) - results, err := rerankerClient.Rerank( - ctx, - &types.RerankRequest{ - Query: request, Documents: domChunks, TopN: m.MaxAttempts * m.ChunksPerAttempt, - }, - ) - if err != nil { - return err + if rerankerClient != nil { + results, err := rerankerClient.Rerank( + ctx, + &types.RerankRequest{ + Query: request, Documents: domChunks, TopN: m.MaxAttempts * m.ChunksPerAttempt, + }, + ) + if err != nil { + return err + } + domChunks = utils.SortRerankChunks(domChunks, results) } - domChunks = utils.SortRerankChunks(domChunks, results) + logger.Info("Max chunks to process", "count", len(domChunks)) if len(domChunks) == 0 { return fmt.Errorf("no chunks to process") diff --git a/pkg/mode/visual-analysis.go b/pkg/mode/visual-analysis.go index f478226..dc0a8df 100644 --- a/pkg/mode/visual-analysis.go +++ b/pkg/mode/visual-analysis.go @@ -80,6 +80,10 @@ func (m *VisualAnalysisMode) ProcessRequest( dom.RootElement.Repr(), constants.HTML_SEPARATORS, constants.DEFAULT_CHUNK_SIZE, ) + if rerankerClient == nil { + return fmt.Errorf("reranker client is required for visual analysis mode") + } + results, err := rerankerClient.Rerank( ctx, &types.RerankRequest{ diff --git a/pkg/plugins/raw-text.go b/pkg/plugins/raw-text.go new file mode 100644 index 0000000..abb9ec2 --- /dev/null +++ b/pkg/plugins/raw-text.go @@ -0,0 +1,154 @@ +package plugins + +import ( + "context" + "errors" + + "github.com/vertexcover-io/locatr/pkg/internal/html" + "github.com/vertexcover-io/locatr/pkg/internal/utils" + "github.com/vertexcover-io/locatr/pkg/internal/xml" + "github.com/vertexcover-io/locatr/pkg/types" +) + +var ( + ErrModeNotSupported = errors.New("raw-text plugin only supports DOM evalutaion mode") +) + +type PageType int + +const ( + HTMLPageType PageType = iota + XMLPageType +) + +type Platform int + +const ( + AndroidPlatform Platform = iota + IosPlatform + WebPlatform +) + +const ( + androidText = "android" + iosText = "ios" + webText = "web" + invalidText = "n/a" +) + +func (platform Platform) Str() string { + switch platform { + case AndroidPlatform: + return androidText + case IosPlatform: + return iosText + case WebPlatform: + return webText + default: + return invalidText + } +} + +type rawTextPlugin struct { + content string + pageType PageType + platform Platform +} + +func NewRawTextPlugin(content string, pageType PageType, platform Platform) *rawTextPlugin { + return &rawTextPlugin{ + content: content, + pageType: pageType, + platform: platform, + } +} + +func (plugin *rawTextPlugin) minifyHTML() (*types.DOM, error) { + pageSource := plugin.content + + eSpec, err := html.MinifySource(pageSource) + if err != nil { + return nil, err + } + locatrMap, err := html.CreateLocatorMap(pageSource) + if err != nil { + return nil, err + } + + dom := &types.DOM{ + RootElement: eSpec, + Metadata: &types.DOMMetadata{ + LocatorType: types.XPathType, + LocatorMap: locatrMap, + }, + } + return dom, nil +} + +func (plugin *rawTextPlugin) minifyXML() (*types.DOM, error) { + pageSource := plugin.content + platform := plugin.platform.Str() + + eSpec, err := xml.MinifySource(pageSource, platform) + if err != nil { + return nil, err + } + locatrMap, err := xml.CreateLocatorMap(pageSource, platform) + if err != nil { + return nil, err + } + dom := &types.DOM{ + RootElement: eSpec, + Metadata: &types.DOMMetadata{ + LocatorType: types.XPathType, + LocatorMap: locatrMap, + }, + } + return dom, nil +} + +func (plugin *rawTextPlugin) GetMinifiedDOM(ctx context.Context) (*types.DOM, error) { + if plugin.pageType == HTMLPageType { + return plugin.minifyHTML() + } + return plugin.minifyXML() +} + +func (plugin *rawTextPlugin) ExtractFirstUniqueID(ctx context.Context, fragment string) (string, error) { + if plugin.pageType == HTMLPageType { + return utils.ExtractFirstUniqueHTMLID(fragment) + } + return utils.ExtractFirstUniqueXMLID(fragment) +} + +func (plugin *rawTextPlugin) IsLocatorValid(ctx context.Context, locator string) (bool, error) { + pageSource := plugin.content + + if plugin.pageType == HTMLPageType { + return html.IsValidXPath(locator, pageSource) + } + return xml.IsValidXPath(locator, pageSource) +} + +func (plugin *rawTextPlugin) GetCurrentContext(ctx context.Context) (*string, error) { + return nil, ErrModeNotSupported +} + +func (plugin *rawTextPlugin) SetViewportSize(ctx context.Context, width, height int) error { + return ErrModeNotSupported +} + +func (plugin *rawTextPlugin) TakeScreenshot(ctx context.Context) ([]byte, error) { + return nil, ErrModeNotSupported +} + +func (plugin *rawTextPlugin) GetElementLocators(ctx context.Context, location *types.Location) ([]string, error) { + return nil, ErrModeNotSupported +} + +func (plugin *rawTextPlugin) GetElementLocation(ctx context.Context, locator string) (*types.Location, error) { + return nil, ErrModeNotSupported +} + +// verify that rawTextPlugin does implement PluginInterface +var _ types.PluginInterface = &rawTextPlugin{}