diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml new file mode 100644 index 0000000..7479817 --- /dev/null +++ b/.github/workflows/go-test.yml @@ -0,0 +1,64 @@ +# Copyright 2026 Specter Ops, Inc. +# +# Licensed under the Apache License, Version 2.0 +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +name: Run Go tests + +on: + pull_request: + branches: + - "main" + - "stage/**" + types: + - "opened" + - "synchronize" + +jobs: + + # TODO: fix existing issues before uncommenting + # vet: + # name: Vet source code + # runs-on: ubuntu-latest + # steps: + # - name: Checkout source code for this repository + # uses: actions/checkout@v4 + # + # - name: Install Go + # uses: actions/setup-go@v5 + # with: + # go-version-file: go.mod + # cache: true + # check-latest: true + # + # - name: Vet Code + # run: | + # go vet ./... + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout source code for this repository + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + check-latest: true + + - name: Run Tests + run: | + go test ./... diff --git a/algo/reach_test.go b/algo/reach_test.go index 68c1818..a87dac0 100644 --- a/algo/reach_test.go +++ b/algo/reach_test.go @@ -6,6 +6,7 @@ import ( "github.com/specterops/dawgs/algo" "github.com/specterops/dawgs/container" + "github.com/specterops/dawgs/container/util" "github.com/specterops/dawgs/graph" "github.com/stretchr/testify/require" ) @@ -21,7 +22,7 @@ import ( // 8 → 9 (bridge from C to D) func TestReachabilityCache(t *testing.T) { var ( - digraph = container.BuildGraph(container.NewCSRGraph, map[uint64][]uint64{ + digraph = util.BuildGraph(container.NewCSRDigraphBuilder, map[uint64][]uint64{ 0: {1}, 1: {2}, 2: {0, 3}, diff --git a/algo/scc.go b/algo/scc.go index 5989dab..29ab550 100644 --- a/algo/scc.go +++ b/algo/scc.go @@ -284,14 +284,14 @@ func (s ComponentGraph) OriginReachable(startID, endID uint64) bool { func NewComponentGraph(ctx context.Context, originGraph container.DirectedGraph) ComponentGraph { var ( componentMembers, memberComponentLookup = StronglyConnectedComponents(ctx, originGraph) - componentDigraph = container.NewCSRGraph() + componentDigraphBuilder = container.NewCSRDigraphBuilder() ) defer util.SLogMeasureFunction("NewComponentGraph")() // Ensure all components are present as vertices, even if they have no edges for componentID := range componentMembers { - componentDigraph.AddNode(uint64(componentID)) + componentDigraphBuilder.AddNode(uint64(componentID)) } originGraph.EachNode(func(node uint64) bool { @@ -299,7 +299,7 @@ func NewComponentGraph(ctx context.Context, originGraph container.DirectedGraph) originGraph.EachAdjacentNode(node, graph.DirectionInbound, func(adjacent uint64) bool { if adjacentComponent := memberComponentLookup[adjacent]; nodeComponent != adjacentComponent { - componentDigraph.AddEdge(adjacentComponent, nodeComponent) + componentDigraphBuilder.AddEdge(adjacentComponent, nodeComponent) } return util.IsContextLive(ctx) @@ -307,7 +307,7 @@ func NewComponentGraph(ctx context.Context, originGraph container.DirectedGraph) originGraph.EachAdjacentNode(node, graph.DirectionOutbound, func(adjacent uint64) bool { if adjacentComponent := memberComponentLookup[adjacent]; nodeComponent != adjacentComponent { - componentDigraph.AddEdge(nodeComponent, adjacentComponent) + componentDigraphBuilder.AddEdge(nodeComponent, adjacentComponent) } return util.IsContextLive(ctx) @@ -319,6 +319,6 @@ func NewComponentGraph(ctx context.Context, originGraph container.DirectedGraph) return ComponentGraph{ componentMembers: componentMembers, memberComponentLookup: memberComponentLookup, - digraph: componentDigraph, + digraph: componentDigraphBuilder.Build(), } } diff --git a/algo/scc_test.go b/algo/scc_test.go index f7ea532..cdee59d 100644 --- a/algo/scc_test.go +++ b/algo/scc_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/specterops/dawgs/container" + "github.com/specterops/dawgs/container/util" "github.com/specterops/dawgs/graph" "github.com/stretchr/testify/require" ) @@ -15,7 +16,7 @@ import ( // 0 -> 1 -> 2 -> 3 func TestSCC_Chain(t *testing.T) { var ( - digraph = container.BuildGraph(container.NewCSRGraph, map[uint64][]uint64{ + digraph = util.BuildGraph(container.NewCSRDigraphBuilder, map[uint64][]uint64{ 0: {1}, 1: {2}, 2: {3}, @@ -48,7 +49,7 @@ func TestSCC_Chain(t *testing.T) { // 1 func TestSCC_SimpleCycle(t *testing.T) { var ( - digraph = container.BuildGraph(container.NewCSRGraph, map[uint64][]uint64{ + digraph = util.BuildGraph(container.NewCSRDigraphBuilder, map[uint64][]uint64{ 0: {0}, // self‑loop component 1: {}, // isolated vertex – must be present as a key! }) @@ -74,7 +75,7 @@ func TestSCC_SimpleCycle(t *testing.T) { // 3 -> 1 func TestSCC_FigureEight(t *testing.T) { var ( - digraph = container.BuildGraph(container.NewCSRGraph, map[uint64][]uint64{ + digraph = util.BuildGraph(container.NewCSRDigraphBuilder, map[uint64][]uint64{ 0: {1}, 1: {2, 3}, 2: {0}, @@ -107,7 +108,7 @@ func TestSCC_FigureEight(t *testing.T) { // 2 → 3 (bridge from A to B) func TestComponentGraph_EdgeDeduplication(t *testing.T) { var ( - digraph = container.BuildGraph(container.NewCSRGraph, map[uint64][]uint64{ + digraph = util.BuildGraph(container.NewCSRDigraphBuilder, map[uint64][]uint64{ 0: {1}, 1: {2}, 2: {0, 3}, @@ -162,7 +163,7 @@ func TestComponentGraph_EdgeDeduplication(t *testing.T) { // 6 -> 7 -> 8 -> 6 func TestComponentHistogram(t *testing.T) { var ( - digraph = container.BuildGraph(container.NewCSRGraph, map[uint64][]uint64{ + digraph = util.BuildGraph(container.NewCSRDigraphBuilder, map[uint64][]uint64{ 0: {1}, 1: {2}, 2: {0}, diff --git a/container/adjacencymap.go b/container/adjacencymap.go index b28e15d..830061f 100644 --- a/container/adjacencymap.go +++ b/container/adjacencymap.go @@ -33,6 +33,7 @@ func BuildAdjacencyMapGraph(adj map[uint64][]uint64) MutableDirectedGraph { return digraph } + func (s *adjacencyMapDigraph) AddNode(node uint64) { s.nodes.Add(node) } diff --git a/container/adjacencymap_test.go b/container/adjacencymap_test.go index 844563a..5cc21e6 100644 --- a/container/adjacencymap_test.go +++ b/container/adjacencymap_test.go @@ -8,6 +8,17 @@ import ( "github.com/stretchr/testify/require" ) +func addAdjacencyMapToMutableDigraph(digraph container.MutableDirectedGraph, adj map[uint64][]uint64) { + for src, outs := range adj { + digraph.AddNode(src) + + for _, dst := range outs { + digraph.AddNode(dst) + digraph.AddEdge(src, dst) + } + } +} + func TestAdjacencyMapDigraph(t *testing.T) { var ( digraph = container.NewAdjacencyMapGraph() @@ -50,7 +61,8 @@ func BenchmarkAdjacencyMapDigraphAdjacency(b *testing.B) { } } - csrGraph := container.BuildGraph(container.NewAdjacencyMapGraph, adj) + digraph := container.NewAdjacencyMapGraph() + addAdjacencyMapToMutableDigraph(digraph, adj) // Use a simple delegate function for testing delegate := func(adjacent uint64) bool { @@ -62,7 +74,7 @@ func BenchmarkAdjacencyMapDigraphAdjacency(b *testing.B) { node := uint64(0) for b.Loop() { - csrGraph.EachAdjacentNode(node, graph.DirectionOutbound, delegate) + digraph.EachAdjacentNode(node, graph.DirectionOutbound, delegate) if node += 1; node >= maxNodes { node = 0 diff --git a/container/csr.go b/container/csr.go index 3c97812..f63ae76 100644 --- a/container/csr.go +++ b/container/csr.go @@ -8,8 +8,8 @@ import ( // csrDigraph implements a mutable directed graph using compressed sparse row storage. type csrDigraph struct { // Mapping between external IDs and dense CSR indices - idToIdx map[uint64]uint64 // external ID → dense index - idxToID []uint64 // dense index → external ID + idToDenseIdx map[uint64]uint64 // external ID → dense index + denseIdxToID []uint64 // dense index → external ID // CSR storage for outgoing edges outOffsets []uint64 // length = NumNodes()+1 @@ -18,240 +18,117 @@ type csrDigraph struct { // CSR storage for incoming edges inOffsets []uint64 inAdj []uint64 - - // Builder‑only temporary adjacency maps (dense indices) - outTmp map[uint64]map[uint64]struct{} - inTmp map[uint64]map[uint64]struct{} - - // frozen indicates whether the CSR slices have been built - frozen bool -} - -// NewCSRGraph creates an empty mutable directed graph that uses CSR internally. -func NewCSRGraph() MutableDirectedGraph { - return &csrDigraph{ - idToIdx: make(map[uint64]uint64), - outTmp: make(map[uint64]map[uint64]struct{}), - inTmp: make(map[uint64]map[uint64]struct{}), - } -} - -// ensureNode registers a vertex if it does not already exist. Returns the dense CSR index for the given external ID. -func (g *csrDigraph) ensureNode(id uint64) uint64 { - if idx, ok := g.idToIdx[id]; ok { - return idx - } - - idx := uint64(len(g.idxToID)) - - g.idToIdx[id] = idx - g.idxToID = append(g.idxToID, id) - - // Allocate empty adjacency sets for the builder. - g.outTmp[idx] = make(map[uint64]struct{}) - g.inTmp[idx] = make(map[uint64]struct{}) - - return idx -} - -// freeze converts the builder maps (outTmp/inTmp) into CSR slices. This function is idempotent. -func (g *csrDigraph) freeze() { - if g.frozen { - return - } - - numNodes := uint64(len(g.idxToID)) - - // Allocate offset slices (len = numNodes+1) - g.outOffsets = make([]uint64, numNodes+1) - g.inOffsets = make([]uint64, numNodes+1) - - // First pass: compute prefix sums (total edge counts per vertex). - var outTotal, inTotal uint64 - for v := uint64(0); v < numNodes; v++ { - outCount := uint64(len(g.outTmp[v])) - inCount := uint64(len(g.inTmp[v])) - - g.outOffsets[v+1] = g.outOffsets[v] + outCount - g.inOffsets[v+1] = g.inOffsets[v] + inCount - - outTotal += outCount - inTotal += inCount - } - - // Allocate adjacency arrays of exact size. - g.outAdj = make([]uint64, outTotal) - g.inAdj = make([]uint64, inTotal) - - // Second pass: fill adjacency arrays. - for v := uint64(0); v < numNodes; v++ { - // Outbound - baseOut := g.outOffsets[v] - i := baseOut - for nbrIdx := range g.outTmp[v] { - g.outAdj[i] = g.idxToID[nbrIdx] // store external ID - i++ - } - - // Inbound - baseIn := g.inOffsets[v] - j := baseIn - for srcIdx := range g.inTmp[v] { - g.inAdj[j] = g.idxToID[srcIdx] - j++ - } - } - - // Discard temporary maps - g.outTmp = nil - g.inTmp = nil - - g.frozen = true } // idx returns the dense CSR index for an external node ID. Returns (idx, true) if the node exists, otherwise (0, false). -func (g *csrDigraph) idx(node uint64) (uint64, bool) { - idx, exists := g.idToIdx[node] +func (s *csrDigraph) idx(node uint64) (uint64, bool) { + idx, exists := s.idToDenseIdx[node] return idx, exists } // csrRange returns the slice of neighbours for a given vertex index and direction. The returned slice contains external IDs. -func (g *csrDigraph) csrRange(idx uint64, dir graph.Direction) []uint64 { +func (s *csrDigraph) csrRange(idx uint64, dir graph.Direction) []uint64 { switch dir { case graph.DirectionOutbound: var ( - start = g.outOffsets[idx] - end = g.outOffsets[idx+1] + start = s.outOffsets[idx] + end = s.outOffsets[idx+1] ) - return g.outAdj[start:end] + return s.outAdj[start:end] case graph.DirectionInbound: var ( - start = g.inOffsets[idx] - end = g.inOffsets[idx+1] + start = s.inOffsets[idx] + end = s.inOffsets[idx+1] ) - return g.inAdj[start:end] + return s.inAdj[start:end] default: var ( - outStart, outEnd = g.outOffsets[idx], g.outOffsets[idx+1] - inStart, inEnd = g.inOffsets[idx], g.inOffsets[idx+1] + outStart, outEnd = s.outOffsets[idx], s.outOffsets[idx+1] + inStart, inEnd = s.inOffsets[idx], s.inOffsets[idx+1] ) merged := make([]uint64, 0, (outEnd-outStart)+(inEnd-inStart)) - merged = append(merged, g.outAdj[outStart:outEnd]...) - merged = append(merged, g.inAdj[inStart:inEnd]...) + merged = append(merged, s.outAdj[outStart:outEnd]...) + merged = append(merged, s.inAdj[inStart:inEnd]...) return merged } } -func (g *csrDigraph) AddNode(node uint64) { - if g.frozen { - panic("AddNode called after graph has been frozen") - } - - g.ensureNode(node) -} - -func (g *csrDigraph) AddEdge(start, end uint64) { - if g.frozen { - panic("AddEdge called after graph has been frozen") - } - - startIdx := g.ensureNode(start) - endIdx := g.ensureNode(end) - - // Outgoing edge start → end - if _, exists := g.outTmp[startIdx][endIdx]; !exists { - g.outTmp[startIdx][endIdx] = struct{}{} - } - // Incoming edge end ← start - if _, exists := g.inTmp[endIdx][startIdx]; !exists { - g.inTmp[endIdx][startIdx] = struct{}{} - } -} - -func (g *csrDigraph) NumNodes() uint64 { - return uint64(len(g.idxToID)) +func (s *csrDigraph) NumNodes() uint64 { + return uint64(len(s.denseIdxToID)) } -func (g *csrDigraph) NumEdges() uint64 { - g.freeze() - +func (s *csrDigraph) NumEdges() uint64 { // each directed edge appears once in outAdj - return uint64(len(g.outAdj)) + return uint64(len(s.outAdj)) } -func (g *csrDigraph) Nodes() cardinality.Duplex[uint64] { - return cardinality.NewBitmap64With(g.idxToID...) +func (s *csrDigraph) Nodes() cardinality.Duplex[uint64] { + return cardinality.NewBitmap64With(s.denseIdxToID...) } -func (g *csrDigraph) EachNode(delegate func(node uint64) bool) { - for _, id := range g.idxToID { +func (s *csrDigraph) EachNode(delegate func(node uint64) bool) { + for _, id := range s.denseIdxToID { if !delegate(id) { return } } } -func (g *csrDigraph) AdjacentNodes(node uint64, direction graph.Direction) []uint64 { - g.freeze() - - if idx, ok := g.idx(node); ok { - return g.csrRange(idx, direction) +func (s *csrDigraph) AdjacentNodes(node uint64, direction graph.Direction) []uint64 { + if idx, ok := s.idx(node); ok { + return s.csrRange(idx, direction) } return nil } -func (g *csrDigraph) Degrees(node uint64, direction graph.Direction) uint64 { - g.freeze() - - if idx, ok := g.idx(node); ok { +func (s *csrDigraph) Degrees(node uint64, direction graph.Direction) uint64 { + if idx, ok := s.idx(node); ok { switch direction { case graph.DirectionOutbound: - return g.outOffsets[idx+1] - g.outOffsets[idx] + return s.outOffsets[idx+1] - s.outOffsets[idx] case graph.DirectionInbound: - return g.inOffsets[idx+1] - g.inOffsets[idx] + return s.inOffsets[idx+1] - s.inOffsets[idx] case graph.DirectionBoth: - return (g.outOffsets[idx+1] - g.outOffsets[idx]) + (g.inOffsets[idx+1] - g.inOffsets[idx]) + return (s.outOffsets[idx+1] - s.outOffsets[idx]) + (s.inOffsets[idx+1] - s.inOffsets[idx]) } } return 0 } -func (g *csrDigraph) EachAdjacentNode(node uint64, direction graph.Direction, delegate func(adjacent uint64) bool) { - g.freeze() - - if idx, exists := g.idx(node); exists { +func (s *csrDigraph) EachAdjacentNode(node uint64, direction graph.Direction, delegate func(adjacent uint64) bool) { + if idx, exists := s.idx(node); exists { switch direction { case graph.DirectionOutbound: - for next, end := g.outOffsets[idx], g.outOffsets[idx+1]; next < end; next++ { - if !delegate(g.outAdj[next]) { + for next, end := s.outOffsets[idx], s.outOffsets[idx+1]; next < end; next++ { + if !delegate(s.outAdj[next]) { return } } case graph.DirectionInbound: - for next, end := g.inOffsets[idx], g.inOffsets[idx+1]; next < end; next++ { - if !delegate(g.inAdj[next]) { + for next, end := s.inOffsets[idx], s.inOffsets[idx+1]; next < end; next++ { + if !delegate(s.inAdj[next]) { return } } default: - for next, end := g.outOffsets[idx], g.outOffsets[idx+1]; next < end; next++ { - if !delegate(g.outAdj[next]) { + for next, end := s.outOffsets[idx], s.outOffsets[idx+1]; next < end; next++ { + if !delegate(s.outAdj[next]) { return } } - for next, end := g.inOffsets[idx], g.inOffsets[idx+1]; next < end; next++ { - if !delegate(g.inAdj[next]) { + for next, end := s.inOffsets[idx], s.inOffsets[idx+1]; next < end; next++ { + if !delegate(s.inAdj[next]) { return } } @@ -259,45 +136,156 @@ func (g *csrDigraph) EachAdjacentNode(node uint64, direction graph.Direction, de } } -func (g *csrDigraph) Normalize() ([]uint64, DirectedGraph) { - g.freeze() - +func (s *csrDigraph) Normalize() ([]uint64, DirectedGraph) { // Reverse map: denseIdx → original ID - reverse := make([]uint64, len(g.idxToID)) - copy(reverse, g.idxToID) + reverse := make([]uint64, len(s.denseIdxToID)) + copy(reverse, s.denseIdxToID) // Build a new CSR graph where the external IDs are the dense indices newGraph := &csrDigraph{ - idToIdx: make(map[uint64]uint64, len(g.idxToID)), - idxToID: make([]uint64, len(g.idxToID)), + idToDenseIdx: make(map[uint64]uint64, len(s.denseIdxToID)), + denseIdxToID: make([]uint64, len(s.denseIdxToID)), // Directly reuse the CSR structure (but translate neighbour IDs). - outOffsets: make([]uint64, len(g.outOffsets)), - inOffsets: make([]uint64, len(g.inOffsets)), - outAdj: make([]uint64, len(g.outAdj)), - inAdj: make([]uint64, len(g.inAdj)), - - frozen: true, + outOffsets: make([]uint64, len(s.outOffsets)), + inOffsets: make([]uint64, len(s.inOffsets)), + outAdj: make([]uint64, len(s.outAdj)), + inAdj: make([]uint64, len(s.inAdj)), } // Populate bitmap + identity maps for dense IDs. - for denseIdx := range g.idxToID { - newGraph.idToIdx[uint64(denseIdx)] = uint64(denseIdx) - newGraph.idxToID[denseIdx] = uint64(denseIdx) + for denseIdx := range s.denseIdxToID { + newGraph.idToDenseIdx[uint64(denseIdx)] = uint64(denseIdx) + newGraph.denseIdxToID[denseIdx] = uint64(denseIdx) } // Copy offsets (they are already correct because vertex ordering is identical). - copy(newGraph.outOffsets, g.outOffsets) - copy(newGraph.inOffsets, g.inOffsets) + copy(newGraph.outOffsets, s.outOffsets) + copy(newGraph.inOffsets, s.inOffsets) // Translate neighbour IDs from original → dense. - for i, origNeighbour := range g.outAdj { - newGraph.outAdj[i] = g.idToIdx[origNeighbour] + for i, origNeighbour := range s.outAdj { + newGraph.outAdj[i] = s.idToDenseIdx[origNeighbour] } - for i, origNeighbour := range g.inAdj { - newGraph.inAdj[i] = g.idToIdx[origNeighbour] + for i, origNeighbour := range s.inAdj { + newGraph.inAdj[i] = s.idToDenseIdx[origNeighbour] } return reverse, newGraph } + +type CSRDigraphBuilder struct { + idToDenseIdx map[uint64]uint64 // external ID → dense index + denseIdxToID []uint64 // dense index → external ID + outTmp AdjacencyMap + inTmp AdjacencyMap +} + +func NewCSRDigraphBuilder() DigraphBuilder { + return &CSRDigraphBuilder{ + idToDenseIdx: make(map[uint64]uint64), + outTmp: AdjacencyMap{}, + inTmp: AdjacencyMap{}, + } +} + +// ensureNode registers a vertex if it does not already exist. Returns the dense CSR index for the given external ID. +func (s *CSRDigraphBuilder) ensureNode(id uint64) uint64 { + if idx, ok := s.idToDenseIdx[id]; ok { + return idx + } + + idx := uint64(len(s.denseIdxToID)) + + s.idToDenseIdx[id] = idx + s.denseIdxToID = append(s.denseIdxToID, id) + + // Allocate empty adjacency sets for the builder. + s.outTmp[idx] = cardinality.NewBitmap64() + s.inTmp[idx] = cardinality.NewBitmap64() + + return idx +} + +func (s *CSRDigraphBuilder) AddNode(node uint64) { + s.ensureNode(node) +} + +func (s *CSRDigraphBuilder) AddEdge(start, end uint64) { + var ( + startIdx = s.ensureNode(start) + endIdx = s.ensureNode(end) + ) + + // Outgoing edge + if existingBitmap, exists := s.outTmp[startIdx]; exists { + existingBitmap.Add(endIdx) + } else { + s.outTmp[startIdx] = cardinality.NewBitmap64With(endIdx) + } + + // Incoming edge + if existingBitmap, exists := s.inTmp[endIdx]; exists { + existingBitmap.Add(startIdx) + } else { + s.inTmp[endIdx] = cardinality.NewBitmap64With(startIdx) + } +} + +func (s *CSRDigraphBuilder) Build() DirectedGraph { + numNodes := uint64(len(s.denseIdxToID)) + + // Allocate offset slices (len = numNodes+1) + var ( + outOffsets = make([]uint64, numNodes+1) + inOffsets = make([]uint64, numNodes+1) + ) + + // Compute prefix sums (total edge counts per vertex) + var outTotal, inTotal uint64 + + for nextNode := uint64(0); nextNode < numNodes; nextNode++ { + outTotal += s.outTmp[nextNode].Cardinality() + inTotal += s.inTmp[nextNode].Cardinality() + + outOffsets[nextNode+1] = outTotal + inOffsets[nextNode+1] = inTotal + } + + // Allocate and fill adjacency arrays + var ( + outAdj = make([]uint64, outTotal) + inAdj = make([]uint64, inTotal) + ) + + for nextNode := uint64(0); nextNode < numNodes; nextNode++ { + var ( + outDenseIdx = outOffsets[nextNode] + inDenseIdx = inOffsets[nextNode] + ) + + s.outTmp[nextNode].Each(func(adjacentDenseIndex uint64) bool { + outAdj[outDenseIdx] = s.denseIdxToID[adjacentDenseIndex] + outDenseIdx++ + + return true + }) + + s.inTmp[nextNode].Each(func(adjacentDenseIndex uint64) bool { + inAdj[inDenseIdx] = s.denseIdxToID[adjacentDenseIndex] + inDenseIdx++ + + return true + }) + } + + return &csrDigraph{ + idToDenseIdx: s.idToDenseIdx, + denseIdxToID: s.denseIdxToID, + outOffsets: outOffsets, + outAdj: outAdj, + inOffsets: inOffsets, + inAdj: inAdj, + } +} diff --git a/container/csr_test.go b/container/csr_test.go index 186c240..5ecf183 100644 --- a/container/csr_test.go +++ b/container/csr_test.go @@ -4,14 +4,15 @@ import ( "testing" "github.com/specterops/dawgs/container" + "github.com/specterops/dawgs/container/util" "github.com/specterops/dawgs/graph" "github.com/stretchr/testify/require" ) func TestCSRDigraph(t *testing.T) { var ( - digraph = container.NewCSRGraph() - expected = map[uint64][]uint64{ + digraphBuilder = container.NewCSRDigraphBuilder() + expected = map[uint64][]uint64{ 1: []uint64{2, 3, 4, 5, 6, 7}, 2: []uint64{3, 4, 5, 6, 7}, 3: []uint64{4, 5, 6, 7}, @@ -22,12 +23,14 @@ func TestCSRDigraph(t *testing.T) { } ) - digraph.AddEdge(2, 1) - digraph.AddEdge(3, 2) - digraph.AddEdge(4, 3) - digraph.AddEdge(5, 3) - digraph.AddEdge(6, 5) - digraph.AddEdge(7, 3) + digraphBuilder.AddEdge(2, 1) + digraphBuilder.AddEdge(3, 2) + digraphBuilder.AddEdge(4, 3) + digraphBuilder.AddEdge(5, 3) + digraphBuilder.AddEdge(6, 5) + digraphBuilder.AddEdge(7, 3) + + digraph := digraphBuilder.Build() for expectedNode, expectedReach := range expected { actualReach := container.Reach(digraph, expectedNode, graph.DirectionInbound).Slice() @@ -50,7 +53,7 @@ func BenchmarkCSRDigraphAdjacency(b *testing.B) { } } - csrGraph := container.BuildGraph(container.NewCSRGraph, adj) + csrGraph := util.BuildGraph(container.NewCSRDigraphBuilder, adj) // Use a simple delegate function for testing delegate := func(adjacent uint64) bool { diff --git a/container/digraph.go b/container/digraph.go index c82e852..5e6a9a7 100644 --- a/container/digraph.go +++ b/container/digraph.go @@ -47,26 +47,17 @@ type DirectedGraph interface { EachAdjacentNode(node uint64, direction graph.Direction, delegate func(adjacent uint64) bool) } -type MutableDirectedGraph interface { - DirectedGraph - +type DigraphBuilder interface { AddNode(node uint64) AddEdge(start, end uint64) + Build() DirectedGraph } -func BuildGraph(constructor func() MutableDirectedGraph, adj map[uint64][]uint64) MutableDirectedGraph { - digraph := constructor() - - for src, outs := range adj { - digraph.AddNode(src) - - for _, dst := range outs { - digraph.AddNode(dst) - digraph.AddEdge(src, dst) - } - } +type MutableDirectedGraph interface { + DirectedGraph - return digraph + AddNode(node uint64) + AddEdge(start, end uint64) } type KindDatabase struct { diff --git a/container/fetch.go b/container/fetch.go index e3ec141..853b725 100644 --- a/container/fetch.go +++ b/container/fetch.go @@ -12,7 +12,7 @@ import ( func FetchDirectedGraph(ctx context.Context, db graph.Database, criteria graph.Criteria) (DirectedGraph, error) { var ( measuref = util.SLogMeasureFunction("FetchDirectedGraph") - digraph = NewCSRGraph() + builder = NewCSRDigraphBuilder() numResults = uint64(0) ) @@ -29,7 +29,7 @@ func FetchDirectedGraph(ctx context.Context, db graph.Database, criteria graph.C return err } - digraph.AddEdge(startID.Uint64(), endID.Uint64()) + builder.AddEdge(startID.Uint64(), endID.Uint64()) numResults += 1 } @@ -44,6 +44,8 @@ func FetchDirectedGraph(ctx context.Context, db graph.Database, criteria graph.C return nil, err } + digraph := builder.Build() + measuref( slog.Uint64("num_nodes", digraph.NumNodes()), slog.Uint64("num_edges", numResults), diff --git a/container/util/builder.go b/container/util/builder.go new file mode 100644 index 0000000..41eba94 --- /dev/null +++ b/container/util/builder.go @@ -0,0 +1,18 @@ +package util + +import "github.com/specterops/dawgs/container" + +func BuildGraph(constructor func() container.DigraphBuilder, adj map[uint64][]uint64) container.DirectedGraph { + builder := constructor() + + for src, outs := range adj { + builder.AddNode(src) + + for _, dst := range outs { + builder.AddNode(dst) + builder.AddEdge(src, dst) + } + } + + return builder.Build() +} diff --git a/drivers/neo4j/result.go b/drivers/neo4j/result.go index db7bb66..485cd12 100644 --- a/drivers/neo4j/result.go +++ b/drivers/neo4j/result.go @@ -23,7 +23,17 @@ func (s *internalResult) Mapper() graph.ValueMapper { return NewValueMapper() } +func (s *internalResult) Keys() []string { + if s.driverResult == nil || s.driverResult.Record() == nil { + return nil + } + return s.driverResult.Record().Keys +} + func (s *internalResult) Values() []any { + if s.driverResult == nil || s.driverResult.Record() == nil { + return nil + } return s.driverResult.Record().Values } diff --git a/drivers/pg/result.go b/drivers/pg/result.go index 412bdaf..d877b7b 100644 --- a/drivers/pg/result.go +++ b/drivers/pg/result.go @@ -11,6 +11,7 @@ type queryResult struct { ctx context.Context rows pgx.Rows values []any + keys []string kindMapper KindMapper } @@ -18,8 +19,17 @@ func (s *queryResult) Values() []any { return s.values } +func (s *queryResult) Keys() []string { + return s.keys +} + func (s *queryResult) Next() bool { if s.rows.Next() { + s.keys = []string{} + for _, desc := range s.rows.FieldDescriptions() { + s.keys = append(s.keys, desc.Name) + } + // This error check exists just as a guard for a successful return of this function. The expectation is that // the pgx type will have error information attached to it which is reflected by the Error receiver function // of this type diff --git a/go.mod b/go.mod index e294cab..8f83909 100644 --- a/go.mod +++ b/go.mod @@ -36,3 +36,7 @@ require ( google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/specterops/dawgs/tools/dawgrun/cmd/dawgrun => ./tools/dawgrun/cmd/dawgrun + +tool github.com/specterops/dawgs/tools/dawgrun/cmd/dawgrun diff --git a/go.work b/go.work new file mode 100644 index 0000000..36b99d5 --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.25.4 + +use ( + . + ./tools/dawgrun + ./tools/dawgrun/pkg/go-repl +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..324d9b8 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,11 @@ +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= diff --git a/graph/literal.go b/graph/literal.go new file mode 100644 index 0000000..c234641 --- /dev/null +++ b/graph/literal.go @@ -0,0 +1,8 @@ +package graph + +type Literal struct { + Value any `json:"value"` + Key string `json:"key"` +} + +type Literals []Literal diff --git a/graph/query.go b/graph/query.go index 1f4114f..c8cc033 100644 --- a/graph/query.go +++ b/graph/query.go @@ -4,6 +4,7 @@ import "fmt" type Result interface { Next() bool + Keys() []string Values() []any Mapper() ValueMapper @@ -37,6 +38,10 @@ type ErrorResult struct { err error } +func (s ErrorResult) Keys() []string { + return nil +} + func (s ErrorResult) Values() []any { return nil } diff --git a/ops/ops.go b/ops/ops.go index 79f45c4..9123ecb 100644 --- a/ops/ops.go +++ b/ops/ops.go @@ -180,26 +180,33 @@ func FetchNodesByQuery(tx graph.Transaction, query string, limit int) (graph.Nod } } -func FetchPathSetByQuery(tx graph.Transaction, query string) (graph.PathSet, error) { +type QueryResult struct { + Paths graph.PathSet + Literals graph.Literals +} + +func FetchByQuery(tx graph.Transaction, query string) (QueryResult, error) { var ( currentPath graph.Path - pathSet graph.PathSet + result = QueryResult{ + Paths: graph.NewPathSet(), + Literals: graph.Literals{}, + } ) - if result := tx.Query(query, map[string]any{}); result.Error() != nil { - return pathSet, result.Error() + if queryResult := tx.Query(query, map[string]any{}); queryResult.Error() != nil { + return result, queryResult.Error() } else { - defer result.Close() + defer queryResult.Close() - for result.Next() { + for queryResult.Next() { var ( relationship = &graph.Relationship{} node = &graph.Node{} path = &graph.Path{} - mapper = result.Mapper() + mapper = queryResult.Mapper() ) - - for _, nextValue := range result.Values() { + for i, nextValue := range queryResult.Values() { if mapper.Map(nextValue, relationship) { currentPath.Edges = append(currentPath.Edges, relationship) relationship = &graph.Relationship{} @@ -207,29 +214,38 @@ func FetchPathSetByQuery(tx graph.Transaction, query string) (graph.PathSet, err currentPath.Nodes = append(currentPath.Nodes, node) node = &graph.Node{} } else if mapper.Map(nextValue, path) { - pathSet = append(pathSet, *path) + result.Paths = append(result.Paths, *path) path = &graph.Path{} + } else { + // This is a catch-all in order to support string / boolean / numeric values + literal := graph.Literal{Value: nextValue} + if i < len(queryResult.Keys()) { + literal.Key = queryResult.Keys()[i] + } + + result.Literals = append(result.Literals, literal) } } if tx.GraphQueryMemoryLimit() > 0 { var ( currentPathSize = size.OfSlice(currentPath.Edges) + size.OfSlice(currentPath.Nodes) - pathSetSize = size.Of(pathSet) + pathSetSize = size.Of(result.Paths) + literalSize = size.Of(result.Literals) ) - if currentPathSize > tx.GraphQueryMemoryLimit() || pathSetSize > tx.GraphQueryMemoryLimit() { - return pathSet, fmt.Errorf("%s - Limit: %.2f MB", "query required more memory than allowed", tx.GraphQueryMemoryLimit().Mebibytes()) + if currentPathSize > tx.GraphQueryMemoryLimit() || pathSetSize+literalSize > tx.GraphQueryMemoryLimit() { + return result, fmt.Errorf("%s - Limit: %.2f MB", "query required more memory than allowed", tx.GraphQueryMemoryLimit().Mebibytes()) } } } // If there were elements added to the current path ensure that it's added to the path set before returning if len(currentPath.Nodes) > 0 || len(currentPath.Edges) > 0 { - pathSet = append(pathSet, currentPath) + result.Paths = append(result.Paths, currentPath) } - return pathSet, result.Error() + return result, queryResult.Error() } } diff --git a/tools/dawgrun/cmd/dawgrun/art.txt b/tools/dawgrun/cmd/dawgrun/art.txt new file mode 100644 index 0000000..4131ca9 --- /dev/null +++ b/tools/dawgrun/cmd/dawgrun/art.txt @@ -0,0 +1,5 @@ + .--~~,__ + :-....,-------`~~'._.' + `-,,, ,_ ;'~U' + _,-' ,'`-__; '--. + (_/'~~ ''''(; diff --git a/tools/dawgrun/cmd/dawgrun/banner.txt b/tools/dawgrun/cmd/dawgrun/banner.txt new file mode 100644 index 0000000..9a5fb3f --- /dev/null +++ b/tools/dawgrun/cmd/dawgrun/banner.txt @@ -0,0 +1,18 @@ + ,---. ,-.-. _,---. + _,..---._ .--.' \ ,-..-.-./ \==\ _.='.'-, \ +/==/, - \ \==\-/\ \ |, \=/\=|- |==|/==.'- / +|==| _ _\/==/-|_\ ||- |/ |/ , /==/==/ - .-' +|==| .=. |\==\, - \\, , _|==|==|_ /_,-. +|==|,| | -|/==/ - ,|| - - , |==|==| , \_.' ) +|==| '=' /==/- /\ - \\ , - /==/\==\- , ( +|==|-, _`/\==\ _.\=\.-'|- /\ /==/ /==/ _ , / +`-.`.____.' `--` `--` `--` `--`------' + .-._ + .-.,.---. .--.-. .-.-./==/ \ .-._ + /==/ ` \/==/ -|/=/ ||==|, \/ /, / + |==|-, .=., |==| ,||=| -||==|- \| | + |==| '=' /==|- | =/ ||==| , | -| + |==|- , .'|==|, \/ - ||==| - _ | + |==|_ . ,'.|==|- , /|==| /\ , | + /==/ /\ , )==/ , _ .' /==/, | |- | + `--`-`--`--'`--`..---' `--`./ `--` diff --git a/tools/dawgrun/cmd/dawgrun/main.go b/tools/dawgrun/cmd/dawgrun/main.go new file mode 100644 index 0000000..b3d65ce --- /dev/null +++ b/tools/dawgrun/cmd/dawgrun/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "log/slog" + "os" + "path" + "runtime/trace" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/google/shlex" + repl "github.com/openengineer/go-repl" + + "github.com/specterops/dawgs/tools/dawgrun/pkg/commands" +) + +var ( + //go:embed art.txt + art string + + //go:embed banner.txt + banner string + + bannerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#87dc70")). + Background(lipgloss.Color("#533d25")) +) + +func main() { + fmt.Printf("\n%s\n%s", + art, + bannerStyle.Render(banner), + ) + fmt.Printf("\n\n\n") + fmt.Printf(" ::: DAWGRUN REPL ::: Type 'help' for more info\n") + + userConfigDir, err := os.UserConfigDir() + if err != nil { + panic(fmt.Errorf("could not get user config dir: %w", err)) + } + + // TODO: Someday, also load a config file from here for highlighting color scheme, colors enablement, etc + appConfigBaseDir := path.Join(userConfigDir, "dawgrun") + if err := os.MkdirAll(appConfigBaseDir, 0o750); err != nil { + panic(fmt.Errorf("could not create dawgrun config dir: %w", err)) + } + + handler := new(handler) + handler.cmdScope = commands.NewScope() + handler.r = repl.NewRepl(handler, &repl.Options{ + HistoryFilePath: path.Join(appConfigBaseDir, "history.txt"), + HistoryMaxLines: 1000, + StatusWidgets: &repl.StatusWidgetFns{ + Right: makeConnectionsStatusWidget(handler.cmdScope), + }, + }) + + if err := handler.r.Loop(); err != nil { + slog.Error("repl encountered error: %w", slog.String("error", err.Error())) + } +} + +var _ repl.Handler = (*handler)(nil) + +type handler struct { + r *repl.Repl + cmdScope *commands.Scope +} + +func (h *handler) Prompt() string { + return "dawgrun > " +} + +func (h *handler) Tab(buffer string) string { + return "" +} + +func (h *handler) Eval(line string) string { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + fields, err := shlex.Split(line) + if err != nil { + return fmt.Sprintf("Unparseable command: '%s': %s", line, err) + } + + if len(fields) == 0 { + return "Woof!" + } + + command := strings.ToLower(fields[0]) + rest := fields[1:] + if cmd, ok := commands.Registry()[command]; ok { + cmdCtx := commands.NewCommandContext(ctx, h.r, h.cmdScope) + defer trace.StartRegion(cmdCtx, fmt.Sprintf("command-%s", command)).End() + err := cmd.Fn(cmdCtx, rest) + if err != nil { + return fmt.Sprintf("%s failed: %v", command, err) + } + + out := cmdCtx.OutputString() + if !strings.HasSuffix(out, "\n") { + return out + "\n" + } else { + return out + } + } + + return fmt.Sprintf("Unknown command %s; try `help`?", command) +} + +func makeConnectionsStatusWidget(cmdScope *commands.Scope) repl.StatusWidgetFn { + return func(r *repl.Repl) string { + if numConns := cmdScope.NumConns(); numConns == 0 { + return "No connections" + } else { + return fmt.Sprintf("%d connection(s)", numConns) + } + } +} diff --git a/tools/dawgrun/go.mod b/tools/dawgrun/go.mod new file mode 100644 index 0000000..bdcc38d --- /dev/null +++ b/tools/dawgrun/go.mod @@ -0,0 +1,53 @@ +module github.com/specterops/dawgs/tools/dawgrun + +go 1.25.4 + +require ( + github.com/alecthomas/chroma/v2 v2.13.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/davecgh/go-spew v1.1.1 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/kanmu/go-sqlfmt v0.0.2-0.20200215095417-d1e63e2ee5eb + github.com/mitchellh/go-wordwrap v1.0.1 + github.com/openengineer/go-repl v0.0.0-00010101000000-000000000000 + github.com/specterops/dawgs v0.3.2 +) + +require ( + github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/axiomhq/hyperloglog v0.2.6 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/gammazero/deque v1.2.0 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgtype v1.14.4 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kamstrup/intmap v0.5.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mschoch/smat v0.2.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/neo4j/neo4j-go-driver/v5 v5.28.4 // indirect + github.com/pkg/errors v0.8.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect +) + +replace github.com/openengineer/go-repl => ./pkg/go-repl diff --git a/tools/dawgrun/go.sum b/tools/dawgrun/go.sum new file mode 100644 index 0000000..e2ee95d --- /dev/null +++ b/tools/dawgrun/go.sum @@ -0,0 +1,293 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw= +github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY= +github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= +github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI= +github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZoRJw= +github.com/axiomhq/hyperloglog v0.2.6/go.mod h1:YjX/dQqCR/7QYX0g8mu8UZAjpIenz1FKM71UEsjFoTo= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= +github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/gammazero/deque v1.2.0 h1:scEFO8Uidhw6KDU5qg1HA5fYwM0+us2qdeJqm43bitU= +github.com/gammazero/deque v1.2.0/go.mod h1:JVrR+Bj1NMQbPnYclvDlvSX0nVGReLrQZ0aUMuWLctg= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgtype v1.14.4 h1:fKuNiCumbKTAIxQwXfB/nsrnkEI6bPJrrSiMKgbJ2j8= +github.com/jackc/pgtype v1.14.4/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.18.2 h1:xVpYkNR5pk5bMCZGfClbO962UIqVABcAGt7ha1s/FeU= +github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk= +github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4= +github.com/kanmu/go-sqlfmt v0.0.2-0.20200215095417-d1e63e2ee5eb h1:Ztn62UtaXoFlHJIrH0AuNQrMhE355paIqZn3ik6bHNk= +github.com/kanmu/go-sqlfmt v0.0.2-0.20200215095417-d1e63e2ee5eb/go.mod h1:1+jKeqi65LLLs1GSNFRn4G/dkgg3TWeT6DTZLLQP2eM= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/neo4j/neo4j-go-driver/v5 v5.28.4 h1:7toxehVcYkZbyxV4W3Ib9VcnyRBQPucF+VwNNmtSXi4= +github.com/neo4j/neo4j-go-driver/v5 v5.28.4/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k= +github.com/openengineer/go-terminal v0.0.0-20220304032943-93486212aca4/go.mod h1:Dx5mNI0A2naWQySM7zXOl/NT5QWs2sfvcQxq1tCbQVY= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/specterops/dawgs v0.3.2 h1:xXYQdDcCenP0zmlbvDpWpEyaZHISqZLqXQnDVBJDRBA= +github.com/specterops/dawgs v0.3.2/go.mod h1:Ic7/TUXbLiSLLK954tncvAHkPpnOrI6FPx5asusavwQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/tools/dawgrun/justfile b/tools/dawgrun/justfile new file mode 100644 index 0000000..19141ad --- /dev/null +++ b/tools/dawgrun/justfile @@ -0,0 +1,24 @@ +default: build + +vet: + go mod tidy + go fmt ./... + go vet ./... + +build *BUILDARGS: vet + go build -o dawgrun {{BUILDARGS}} ./cmd/... + +build-with-dawgs path_to_dawgs *BUILDARGS: + go work edit -replace=github.com/specterops/dawgs={{path_to_dawgs}} + just build {{BUILDARGS}} + +build-with-upstream *BUILDARGS: + go work edit -dropreplace=github.com/specterops/dawgs + just build {{BUILDARGS}} + +clean: + @rm -f dawgrun + +[arg("port", long)] +debug port="38697": build + dlv exec --continue --accept-multiclient --headless --listen 127.0.0.1:{{port}} ./dawgrun diff --git a/tools/dawgrun/pkg/commands/cypher.go b/tools/dawgrun/pkg/commands/cypher.go new file mode 100644 index 0000000..f047948 --- /dev/null +++ b/tools/dawgrun/pkg/commands/cypher.go @@ -0,0 +1,172 @@ +package commands + +import ( + "flag" + "fmt" + + "github.com/davecgh/go-spew/spew" + "github.com/kanmu/go-sqlfmt/sqlfmt" + "github.com/specterops/dawgs/cypher/models/pgsql/format" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" + "github.com/specterops/dawgs/graph" + + "github.com/specterops/dawgs/tools/dawgrun/pkg/stubs" +) + +func parseCmd() CommandDesc { + return CommandDesc{ + args: []string{"<...query>"}, + help: "Parses and dumps a Cypher query to AST form.", + + Fn: func(ctx *CommandContext, fields []string) error { + query, err := parseQueryArray(fields) + if err != nil { + return fmt.Errorf("error trying to parse query '%s': %w", fields, err) + } + + ctx.output.WriteHighlighted(spew.Sdump(query), "golang", "monokai") + return nil + }, + } +} + +func translateToPsqlCmd() CommandDesc { + flagSet := flag.NewFlagSet("translate-psql", flag.ContinueOnError) + + var ( + kindMapperConnRef = "" + dumpTranslatedAst = false + ) + flagSet.StringVar(&kindMapperConnRef, "conn", "", "Connection reference for choosing a kind mapper") + flagSet.BoolVar(&dumpTranslatedAst, "dump-pg-ast", false, "Whether to dump the translator's constructed AST") + + return CommandDesc{ + args: []string{"[flags]", "<...query>"}, + help: "Parses a query and converts it to the underlying PostgreSQL query", + desc: "Does a bunch of magic to fully translate a Cypher query into a PostgreSQL query", + flags: flagSet, + + Fn: func(ctx *CommandContext, fields []string) error { + if err := flagSet.Parse(fields); err != nil { + return fmt.Errorf("could not parse flags: %w", err) + } + + fields = flagSet.Args() + query, err := parseQueryArray(fields) + if err != nil { + return fmt.Errorf("error trying to parse query '%s': %w", fields, err) + } + + kindMapper := stubs.EmptyMapper() + if kindMapperConnRef != "" { + // Fetch kinds regardless of if it's already loaded. + kindMap, err := loadKindMap(ctx, kindMapperConnRef) + if err != nil { + return fmt.Errorf("could not load kind map for explain: %w", err) + } + kindMapper = stubs.MapperFromKindMap(kindMap) + } + + result, err := translate.Translate(ctx, query, kindMapper, nil) + if err != nil { + return fmt.Errorf("could not translate cypher query to pgsql: %w", err) + } + if dumpTranslatedAst { + fmt.Fprintf(ctx.output, "TRANSLATOR AST\n\n") + ctx.output.WriteHighlighted(spew.Sdump(result.Statement), "golang", "monokai") + fmt.Fprintf(ctx.output, "\n") + } + + sqlQuery, err := format.SyntaxNode(result.Statement) + if err != nil { + return fmt.Errorf("could not format translated statement into a string query: %w", err) + } + + formattedQuery, err := sqlfmt.Format(sqlQuery, &sqlfmt.Options{ + Distance: 0, + }) + if err != nil { + ctx.output.Warnf("could not format query: %s", err.Error()) + formattedQuery = sqlQuery + } + + ctx.output.WriteHighlighted(formattedQuery, "postgres", "monokai") + return nil + }, + } +} + +func explainAsPsqlCmd() CommandDesc { + return CommandDesc{ + args: []string{"", "<...query>"}, + help: "Explains a translated query over an active PG connection", + desc: "Asks the PG query planner to explain the (translated) Cypher query in PG terms", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) < 2 { + return fmt.Errorf("invalid usage, requires: ") + } + + connName := fields[0] + conn, ok := ctx.scope.connections[connName] + if !ok { + return fmt.Errorf("connection %s not found; did you `open` it?", connName) + } + + // Fetch kinds regardless of if it's already loaded. + kindMap, err := loadKindMap(ctx, connName) + if err != nil { + return fmt.Errorf("could not load kind map for explain: %w", err) + } + + query, err := parseQueryArray(fields[1:]) + if err != nil { + return fmt.Errorf("could not parse query: %w", err) + } + + // Populate a DumbKindMapper from the database's kinds table + kindMapper := stubs.MapperFromKindMap(kindMap) + result, err := translate.Translate(ctx, query, kindMapper, nil) + if err != nil { + return fmt.Errorf("could not translate cypher query to pgsql: %w", err) + } + + sqlQuery, err := format.SyntaxNode(result.Statement) + if err != nil { + return fmt.Errorf("could not format translated statement into a string query: %w", err) + } + + formattedQuery, err := sqlfmt.Format(sqlQuery, &sqlfmt.Options{ + Distance: 2, + }) + if err != nil { + ctx.output.Warnf("could not format query: %s", err.Error()) + formattedQuery = sqlQuery + } + explainSQLQuery := fmt.Sprintf("EXPLAIN %s", formattedQuery) + ctx.output.WriteHighlighted(explainSQLQuery, "postgres", "monokai") + fmt.Fprint(ctx.output, "\n\n") + + err = conn.ReadTransaction(ctx, func(tx graph.Transaction) error { + result := tx.Raw(explainSQLQuery, nil) + if err := result.Error(); err != nil { + return fmt.Errorf("error running raw query: '%s': %w", explainSQLQuery, err) + } + defer result.Close() + + var value string + for result.Next() { + graph.ScanNextResult(result, &value) + fmt.Fprintf(ctx.output, " %s\n", value) + } + + return nil + }) + if err != nil { + return fmt.Errorf("could not run EXPLAIN query: %w", err) + } + + return nil + }, + } +} diff --git a/tools/dawgrun/pkg/commands/db.go b/tools/dawgrun/pkg/commands/db.go new file mode 100644 index 0000000..13152b3 --- /dev/null +++ b/tools/dawgrun/pkg/commands/db.go @@ -0,0 +1,185 @@ +package commands + +import ( + "fmt" + "strconv" + + "github.com/davecgh/go-spew/spew" + "github.com/specterops/dawgs" + "github.com/specterops/dawgs/drivers/pg" + dawgsPg "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" + + "github.com/specterops/dawgs/tools/dawgrun/pkg/stubs" +) + +func openPGDBCmd() CommandDesc { + return CommandDesc{ + args: []string{"", ""}, + help: "Connects to a specified DAWGS-compatible Postgres DB to do graph introspection.", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) < 2 { + return fmt.Errorf("invalid usage: open-pg-db ") + } + + name := fields[0] + connStr := fields[1] + connPool, err := dawgsPg.NewPool(connStr) + if err != nil { + return fmt.Errorf("error opening connection pool: %w", err) + } + + query, err := dawgs.Open(ctx, "pg", dawgs.Config{ + ConnectionString: connStr, + Pool: connPool, + }) + if err != nil { + return fmt.Errorf("error opening database connection '%s': %w", connStr, err) + } + + fmt.Fprintf(ctx.output, "Opened connection '%s': %s\n", name, connStr) + ctx.scope.connections[name] = query + + return nil + }, + } +} + +func getPGDBKinds() CommandDesc { + return CommandDesc{ + args: []string{""}, + help: "Loads/shows the kind mapping from the specified DB into the 'active set'", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) != 1 { + return fmt.Errorf("invalid usage: load-db-kinds ") + } + + connName := fields[0] + if kindMap, err := loadKindMap(ctx, connName); err != nil { + return fmt.Errorf("could not load kind map: %w", err) + } else { + ctx.scope.connKindMaps[connName] = kindMap + fmt.Fprintf(ctx.output, "Loaded kind map from connection '%s':\n", connName) + ctx.output.WriteHighlighted(spew.Sdump(kindMap), "golang", "monokai") + } + + return nil + }, + } +} + +func loadKindMap(ctx *CommandContext, connName string) (stubs.KindMap, error) { + conn, ok := ctx.scope.connections[connName] + if !ok { + return nil, fmt.Errorf("unknown connection %s; did you `open` it?", connName) + } + + // Force a refresh from the database backend + if err := conn.RefreshKinds(ctx); err != nil { + return nil, fmt.Errorf("could not refresh kinds for connection %s: %w", connName, err) + } + + // Load kinds list + kinds, err := conn.FetchKinds(ctx) + if err != nil { + return nil, fmt.Errorf("could not fetch kinds for connection %s: %w", connName, err) + } + + // Coerce a pg.Driver out of the conn + driver, ok := conn.(*pg.Driver) + if !ok { + return nil, fmt.Errorf("connection %s is not a 'pg' connection", connName) + } + + // Map the kinds to their IDs + kindIds, err := driver.MapKinds(ctx, kinds) + if err != nil { + return nil, fmt.Errorf("could not map kinds to IDs: %s", err) + } + + kindMap := make(stubs.KindMap) + for idx, kind := range kinds { + kindMap[kindIds[idx]] = kind + } + + ctx.scope.connKindMaps[connName] = kindMap + + return kindMap, nil +} + +func lookupKindCmd() CommandDesc { + return CommandDesc{ + args: []string{"", ""}, + help: "Looks up a kind from database based on kind name", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) < 2 { + return fmt.Errorf("invalid usage: lookup-kind ") + } + + connName := fields[0] + kindName := fields[1] + + kindMap, ok := ctx.scope.connKindMaps[connName] + if !ok { + // Try to fetch the kind map if the connection is open + var err error + kindMap, err = loadKindMap(ctx, connName) + if err != nil { + return fmt.Errorf("could not fetch kind map: %w", err) + } + } + + mapper := stubs.MapperFromKindMap(kindMap) + if kindID, err := mapper.GetIDByKind(graph.StringKind(kindName)); err != nil { + return fmt.Errorf("could not look up kind: %w", err) + } else { + fmt.Fprintf(ctx.output, "Kind %s => %d", kindName, kindID) + } + + return nil + }, + } +} + +func lookupKindIDCmd() CommandDesc { + return CommandDesc{ + args: []string{"", ""}, + help: "Looks up a kind from database based on kind ID", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) < 2 { + return fmt.Errorf("invalid usage: lookup-kind-id ") + } + + connName := fields[0] + kindIDStr := fields[1] + + kindID, err := strconv.ParseInt(kindIDStr, 10, 16) + if err != nil { + return fmt.Errorf("could not parse kind ID as int: %s: %w", kindIDStr, err) + } + + kindMap, ok := ctx.scope.connKindMaps[connName] + if !ok { + // Try to fetch the kind map if the connection is open + var err error + kindMap, err = loadKindMap(ctx, connName) + if err != nil { + return fmt.Errorf("could not fetch kind map: %w", err) + } + } + + mapper := stubs.MapperFromKindMap(kindMap) + if kind, err := mapper.GetKindByID(int16(kindID)); err != nil { + return fmt.Errorf("could not look up kind: %w", err) + } else { + fmt.Fprintf(ctx.output, "Kind ID %d => %s", kindID, kind) + } + + return nil + }, + } +} diff --git a/tools/dawgrun/pkg/commands/help.go b/tools/dawgrun/pkg/commands/help.go new file mode 100644 index 0000000..7ef15a7 --- /dev/null +++ b/tools/dawgrun/pkg/commands/help.go @@ -0,0 +1,80 @@ +package commands + +import ( + "flag" + "fmt" + "maps" + "slices" + "strings" + + "github.com/mitchellh/go-wordwrap" +) + +func getFlagSetHelpDetails(flagSet *flag.FlagSet) string { + flagDefaults := new(strings.Builder) + oldOutput := flagSet.Output() + + flagSet.SetOutput(flagDefaults) + flagSet.PrintDefaults() + flagSet.SetOutput(oldOutput) + + return flagDefaults.String() +} + +func helpCmd() CommandDesc { + return CommandDesc{ + args: []string{"[command]"}, + help: "This help message, but also more detailed help for individual commands", + desc: "Get help for all commands", + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) == 0 { + // HELP OVERVIEW + maxNameLength := 0 + for name := range cmdRegistry { + if nameLen := len(name); nameLen > maxNameLength { + maxNameLength = nameLen + } + } + + sortedCommands := slices.Sorted(maps.Keys(cmdRegistry)) + for _, name := range sortedCommands { + commandLeft := fmt.Sprintf( + "%s%s%s", + strings.Repeat(" ", 4), + name, + strings.Repeat(" ", maxNameLength-(len(name))), + ) + cmd := cmdRegistry[name] + spacerPad := strings.Repeat(" ", len(commandLeft)) + fmt.Fprintf(ctx.output, "%s%s%s\n", commandLeft, spacerPad, cmd.help) + } + } else { + // COMMAND HELP + name := fields[0] + cmd, ok := cmdRegistry[name] + if !ok { + return fmt.Errorf("unknown command: %s", name) + } + + wrappedHelp := indentLines(wordwrap.WrapString(cmd.help, 80), 1) + fmt.Fprintf(ctx.output, "\nHELP: %s %s\n\n", name, strings.Join(cmd.args, " ")) + fmt.Fprintf(ctx.output, "%s\n", wrappedHelp) + if strings.TrimSpace(cmd.desc) != "" { + fmt.Fprint(ctx.output, "\n") + fmt.Fprintf(ctx.output, "%s\n", indentLines(wordwrap.WrapString(cmd.desc, 80), 1)) + } + if cmd.flags != nil { + flagDefaults := getFlagSetHelpDetails(cmd.flags) + fmt.Fprintf(ctx.output, "\n%s\n%s\n", + indentLines("flags:", 1), + indentLines(wordwrap.WrapString(flagDefaults, 80), 2), + ) + } + fmt.Fprint(ctx.output, "END HELP\n") + } + + return nil + }, + } +} diff --git a/tools/dawgrun/pkg/commands/helpers.go b/tools/dawgrun/pkg/commands/helpers.go new file mode 100644 index 0000000..a3235a1 --- /dev/null +++ b/tools/dawgrun/pkg/commands/helpers.go @@ -0,0 +1,33 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/alecthomas/chroma/v2/quick" + cypherFrontend "github.com/specterops/dawgs/cypher/frontend" + cypherModels "github.com/specterops/dawgs/cypher/models/cypher" +) + +func parseQueryArray(fields []string) (*cypherModels.RegularQuery, error) { + cypherCtx := cypherFrontend.DefaultCypherContext() + return cypherFrontend.ParseCypher(cypherCtx, strings.Join(fields, " ")) +} + +func indentLines(text string, indentCount int) string { + builder := new(strings.Builder) + for line := range strings.Lines(text) { + fmt.Fprintf(builder, "%s%s", strings.Repeat("\t", indentCount), line) + } + + return builder.String() +} + +func highlightText(text, lexer, style string) (string, error) { + builder := new(strings.Builder) + if err := quick.Highlight(builder, text, lexer, "terminal16m", style); err != nil { + return "", fmt.Errorf("could not highlight source text `%s`: %w", text, err) + } + + return builder.String(), nil +} diff --git a/tools/dawgrun/pkg/commands/registry.go b/tools/dawgrun/pkg/commands/registry.go new file mode 100644 index 0000000..4ca98c9 --- /dev/null +++ b/tools/dawgrun/pkg/commands/registry.go @@ -0,0 +1,23 @@ +// Package commands holds all of the repl commands along with infrastructure types and helpers +package commands + +var cmdRegistry map[string]CommandDesc = map[string]CommandDesc{ + "exit": quitCmd(), + "explain-psql": explainAsPsqlCmd(), + "load-db-kinds": getPGDBKinds(), + "lookup-kind": lookupKindCmd(), + "lookup-kind-id": lookupKindIDCmd(), + "open-pg-db": openPGDBCmd(), + "parse": parseCmd(), + "quit": quitCmd(), + "runtime-trace": runtimeTraceCmd(), + "translate-psql": translateToPsqlCmd(), +} + +func init() { + cmdRegistry["help"] = helpCmd() +} + +func Registry() map[string]CommandDesc { + return cmdRegistry +} diff --git a/tools/dawgrun/pkg/commands/runtime.go b/tools/dawgrun/pkg/commands/runtime.go new file mode 100644 index 0000000..d95e68a --- /dev/null +++ b/tools/dawgrun/pkg/commands/runtime.go @@ -0,0 +1,91 @@ +package commands + +import ( + "fmt" + "os" + "runtime/trace" + "strings" +) + +func quitCmd() CommandDesc { + return CommandDesc{ + args: []string{}, + help: "Quit", + desc: "Exits the REPL session", + + Fn: func(ctx *CommandContext, fields []string) error { + ctx.instance.Quit() + return nil + }, + } +} + +func runtimeTraceCmd() CommandDesc { + state := make(map[string]any) + state["run"] = false + state["tracefile"] = nil + + usage := "runtime-trace [tracefile]" + + return CommandDesc{ + args: []string{"start|stop", "[tracefile]"}, + help: "Manage runtime tracing", + desc: `start [tracefile] - Start tracing with output to [tracefile] if provided, otherwise trace.out +stop - Stop runtime tracing and close the trace file`, + + Fn: func(ctx *CommandContext, fields []string) error { + if len(fields) == 0 { + return fmt.Errorf("invalid usage: %s", usage) + } + subcmd := strings.ToLower(fields[0]) + switch subcmd { + case "start": + if running, ok := state["run"].(bool); ok && running { + return fmt.Errorf("runtime tracing is already enabled") + } + + var traceOut string + if len(fields) > 1 { + traceOut = fields[1] + } else { + traceOut = "trace.out" + } + + traceFile, err := os.Create(traceOut) + if err != nil { + return fmt.Errorf("error creating tracefile: %w", err) + } + + if err := trace.Start(traceFile); err != nil { + return fmt.Errorf("could not start tracing: %w", err) + } + + state["run"] = true + state["tracefile"] = traceFile + + fmt.Fprintf(ctx.output, "Started runtime tracing to %s", traceOut) + return nil + case "stop": + if running, ok := state["run"].(bool); ok && !running { + return fmt.Errorf("runtime tracing is not running") + } + + trace.Stop() + traceFile, ok := state["tracefile"].(*os.File) + if !ok { + return fmt.Errorf("could not get open tracing file") + } + + traceFile.Close() + + state["run"] = true + state["tracefile"] = nil + + fmt.Fprint(ctx.output, "Stopped runtime tracing") + return nil + default: + return fmt.Errorf("invalid usage: %s", usage) + } + }, + } +} diff --git a/tools/dawgrun/pkg/commands/types.go b/tools/dawgrun/pkg/commands/types.go new file mode 100644 index 0000000..d3bd76a --- /dev/null +++ b/tools/dawgrun/pkg/commands/types.go @@ -0,0 +1,117 @@ +package commands + +import ( + "context" + "flag" + "fmt" + "io" + "log/slog" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/openengineer/go-repl" + "github.com/specterops/dawgs/graph" + + "github.com/specterops/dawgs/tools/dawgrun/pkg/stubs" +) + +type ( + CommandFn func(*CommandContext, []string) error + CommandContext struct { + context.Context + + // instance is the running instance of the repl.Repl + instance *repl.Repl + // output is a convenience type to make issuing warnings and formatting outputs easier + output *CommandOutput + + // scope is a singleton instance held by the command manager that holds any persistent state for a command. + scope *Scope + } + CommandDesc struct { + Fn CommandFn + args []string + flags *flag.FlagSet + desc string + help string + state map[string]any + } + CommandOutput struct { + warnings []string + outputBuilder strings.Builder + } + // Scope is a grab-bag of miscellaneous objects that store "persistent" scope for commands to cross-reference + Scope struct { + connections map[string]graph.Database + connKindMaps map[string]stubs.KindMap + } +) + +func NewCommandContext(ctx context.Context, instance *repl.Repl, scope *Scope) *CommandContext { + return &CommandContext{ + Context: ctx, + output: new(CommandOutput), + instance: instance, + scope: scope, + } +} + +func (cc *CommandContext) warningStyle(text string) string { + return lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("202")). + Render(text) +} + +func (cc *CommandContext) OutputString() string { + builder := new(strings.Builder) + for _, warning := range cc.output.warnings { + fmt.Fprintf(builder, " * %s\n\n", cc.warningStyle(warning)) + } + + builder.WriteString(cc.output.outputBuilder.String()) + + return builder.String() +} + +var _ (io.Writer) = (*CommandOutput)(nil) + +func (co *CommandOutput) Warn(text string) { + co.warnings = append(co.warnings, text) +} + +func (co *CommandOutput) Warnf(text string, args ...any) { + co.warnings = append(co.warnings, fmt.Sprintf(text, args...)) +} + +func (co *CommandOutput) Write(p []byte) (n int, err error) { + return co.outputBuilder.Write(p) +} + +func (co *CommandOutput) WriteIndented(text string, indentCount int) { + co.outputBuilder.WriteString(indentLines(text, indentCount)) +} + +func (co *CommandOutput) WriteHighlighted(text, lexer, style string) { + highlighted, err := highlightText(text, lexer, style) + if err != nil { + slog.Error("could not highlight source text; writing non-highlighted text to output", + slog.String("text", text), + slog.String("error", err.Error()), + ) + co.outputBuilder.WriteString(text) + } else { + co.outputBuilder.WriteString(highlighted) + } +} + +func NewScope() *Scope { + return &Scope{ + connections: make(map[string]graph.Database), + connKindMaps: make(map[string]stubs.KindMap), + } +} + +func (s *Scope) NumConns() int { + return len(s.connections) +} diff --git a/tools/dawgrun/pkg/go-repl/.gitignore b/tools/dawgrun/pkg/go-repl/.gitignore new file mode 100644 index 0000000..4f17582 --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/.gitignore @@ -0,0 +1,6 @@ +main +go.sum +*.log +log.* +examples/basic_repl +examples/shell_wrapper diff --git a/tools/dawgrun/pkg/go-repl/LICENSE b/tools/dawgrun/pkg/go-repl/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/tools/dawgrun/pkg/go-repl/Makefile b/tools/dawgrun/pkg/go-repl/Makefile new file mode 100644 index 0000000..e2c592d --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/Makefile @@ -0,0 +1,15 @@ +all: ./examples/basic_repl + +#./examples/basic_repl: ./*.go ./examples/basic_repl.go + #cd ./examples; \ + #go build basic_repl.go + +./examples/%: ./examples/%.go ./*.go + cd ./examples; \ + go build $(notdir $<) + +test: ./examples/basic_repl + ./examples/basic_repl + +test-shell_wrapper: ./examples/shell_wrapper + ./examples/shell_wrapper diff --git a/tools/dawgrun/pkg/go-repl/README.md b/tools/dawgrun/pkg/go-repl/README.md new file mode 100644 index 0000000..8843ea0 --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/README.md @@ -0,0 +1,146 @@ +# go-repl + +## NOTICE + +Forked from https://github.com/OpenEngineer/go-repl @ 9990dd87c3fb9c039d3c2d1f00ceace8906b6644. +Changes applied: +- Added `repl.Options` to manage internal settings knobs +- Added the ability to render custom status bar components +- Support for OS-level paste into input line +- Added history load/save to file + +## Original README + +Lightweight Golang REPL library, inspired by GNU Readline. You provide the `Eval` function, and `go-repl` does the rest. + +Your REPLs that use this library will enjoy the following features: +* Session history with *reverse-search* + * Ctrl-R to start *reverse-search* + * Most edit commands, except the most basic ones, exit the *reverse-search* mode + * Use Up/Down to cycle through a filtered list of history entries +* The input buffer is redrawn when a resize is detected +* Status bar at bottom with current working dir and other info +* Truncation of very long inputs (status bar displays info about cursor position) +* Common edit and movement commands: + * Right/Left: move cursor one character at a time + * Ctrl-Right/Left: move cursor one word at a time + * Up/Down: cycle through history + * Backspace/Delete: works as expected + * Shift-Enter: insert newline into buffer without invoking `Eval` + * Ctrl-A or Home: move to start of buffer + * Ctrl-E or End: move to end of buffer + * Ctrl-W: delete preceding word + * Ctrl-Q: delete following word + * Ctrl-C or Esc: ignore current input and reset buffer + * Ctrl-D: quit REPL + * Ctrl-U: clear buffer to start + * Ctrl-K: clear buffer to end + * Ctrl-L: reset prompt at top and redraw buffer + * Ctrl-Y: insert previous deletion (from Ctrl-K, Ctrl-U, Ctrl-Q or Ctrl-W) + +Notes: +* Doesn't depend on *ncurses* +* Performance hasn't yet been optimized and I haven't yet tested all corner cases exhaustively +* Might not work in Windows command prompt (keystroke codes could differ, ANSI escape sequences might not be supported, the method that sets terminal to raw mode might not work) +* No vi edit mode +* No support for clipboards yet + +# Usage + +Fetch this library with the following command: +```shell +$ go get -u github.com/openengineer/go-repl +``` + +In order to create your own REPL you have to define a type that implements the `Handler` interface: +```golang +type Handler interface { + Prompt() string + Tab(buffer string) string + Eval(line string) string +} +``` + +Here is a complete example (can also be found in `./examples/basic_repl.go`): + +```golang +package main + +import ( + "fmt" + "log" + "strconv" + "strings" + + repl "github.com/openengineer/go-repl" +) + +var helpMessage = `help display this message +add add two numbers +quit quit this program` + +// implements repl.Handler interface +type MyHandler struct { + r *repl.Repl +} + +func main() { + fmt.Println("Welcome, type \"help\" for more info") + + h := &MyHandler{} + h.r = repl.NewRepl(h) + + // start the terminal loop + if err := h.r.Loop(); err != nil { + log.Fatal(err) + } +} + +func (h *MyHandler) Prompt() string { + return "> " +} + +func (h *MyHandler) Tab(buffer string) string { + return "" // do nothing +} + +func (h *MyHandler) Eval(line string) string { + fields := strings.Fields(line) + + if len(fields) == 0 { + return "" + } else { + cmd, args := fields[0], fields[1:] + + switch cmd { + case "help": + return helpMessage + case "add": + if len(args) != 2 { + return "\"add\" expects 2 args" + } else { + return add(args[0], args[1]) + } + case "quit": + h.r.Quit() + return "" + default: + return fmt.Sprintf("unrecognized command \"%s\"", cmd) + } + } +} + +func add(a_ string, b_ string) string { + a, err := strconv.Atoi(a_) + if err != nil { + return "first arg is not an integer" + } + + b, err := strconv.Atoi(b_) + if err != nil { + return "second arg is not an integer" + } + + return strconv.Itoa(a + b) +} +``` diff --git a/tools/dawgrun/pkg/go-repl/ansi.go b/tools/dawgrun/pkg/go-repl/ansi.go new file mode 100644 index 0000000..1dbe901 --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/ansi.go @@ -0,0 +1,90 @@ +package repl + +import ( + "fmt" + "os" +) + +// TODO: dont use the functions that aren't supported by Windows + +const _ESC = "\033" + +func csi1(n int, char byte) { + fmt.Fprintf(os.Stdout, "%s[%d%c", _ESC, n, char) +} + +func csi2(n int, m int, char byte) { + fmt.Fprintf(os.Stdout, "%s[%d;%d%c", _ESC, n, m, char) +} + +func esc1(c byte) { + fmt.Fprintf(os.Stdout, "%s[%c", _ESC, c) +} + +func control(char byte) { + fmt.Fprintf(os.Stdout, "%c", char) +} + +func moveLeft() { + csi1(1, 'D') +} + +func moveRight() { + csi1(1, 'C') +} + +func clearScreen() { + csi1(2, 'J') +} + +func moveToRowStart() { + csi1(1, 'G') +} + +func moveToScreenStart() { + csi2(1, 1, 'H') +} + +func moveToRow(y int) { + csi2(y+1, 1, 'H') +} + +func clearRow() { + csi1(2, 'K') +} + +func clearRowAfterCursor() { + csi1(0, 'K') +} + +func clearRows(n int) { + for i := 0; i < n; i++ { + csi1(2, 'K') + + csi1(1, 'F') + } +} + +// input: 0-based +// moves to 1-based +func moveToCol(x int) { + csi1(x+1, 'G') +} + +func queryCursorPos() { + csi1(6, 'n') +} + +// from 0-based to 1-based! +func moveCursorTo(x, y int) { + csi2(y+1, x+1, 'H') +} + +func highlight() { + // black text (30) on a grey/white background + fmt.Fprintf(os.Stdout, "%s[48;5;247m%s[30m", _ESC, _ESC) +} + +func resetDecorations() { + fmt.Fprintf(os.Stdout, "%s[0m", _ESC) +} diff --git a/tools/dawgrun/pkg/go-repl/examples/basic_repl.go b/tools/dawgrun/pkg/go-repl/examples/basic_repl.go new file mode 100644 index 0000000..749553a --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/examples/basic_repl.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + "log" + "strconv" + "strings" + "time" + + repl "github.com/openengineer/go-repl" +) + +var helpMessage = `help display this message +add add two numbers +sleep sleep for 5s +read read some user input +quit quit this program` + +// implements repl.Handler interface +type MyHandler struct { + r *repl.Repl +} + +func main() { + fmt.Println("Welcome, type \"help\" for more info") + + h := &MyHandler{} + h.r = repl.NewRepl(h) + + if err := h.r.Loop(); err != nil { + log.Fatal(err) + } +} + +func (h *MyHandler) Prompt() string { + return "> " +} + +func (h *MyHandler) Tab(buffer string) string { + return "" +} + +// first return value is for stdout, second return value is for history +func (h *MyHandler) Eval(buffer string) string { + fields := strings.Fields(buffer) + + if len(fields) == 0 { + return "" + } else { + cmd, args := fields[0], fields[1:] + + switch cmd { + case "help": + return helpMessage + case "add": + if len(args) != 2 { + return "\"add\" expects 2 args" + } else { + return add(args[0], args[1]) + } + case "sleep": + time.Sleep(5 * time.Second) + return "" + case "read": + info := h.r.ReadLine(true) + return "read=" + info + case "quit": + h.r.Quit() + return "" + default: + return fmt.Sprintf("unrecognized command \"%s\"", cmd) + } + } +} + +func add(a_ string, b_ string) string { + a, err := strconv.Atoi(a_) + if err != nil { + return "first arg is not an integer" + } + + b, err := strconv.Atoi(b_) + if err != nil { + return "second arg is not an integer" + } + + return strconv.Itoa(a + b) +} diff --git a/tools/dawgrun/pkg/go-repl/examples/shell_wrapper.go b/tools/dawgrun/pkg/go-repl/examples/shell_wrapper.go new file mode 100644 index 0000000..642148f --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/examples/shell_wrapper.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + + repl "github.com/openengineer/go-repl" +) + +type ShellWrapper struct { + r *repl.Repl +} + +func (h *ShellWrapper) Prompt() string { + return "> " +} + +func (h *ShellWrapper) Tab(buffer string) string { + // a tab is simply 2 spaces here + return " " +} + +func (h *ShellWrapper) Eval(buffer string) string { + // upon eval the Stdin should be unblocked + if strings.TrimSpace(buffer) != "" { + endCmd := make(chan bool) + + if buffer == "quit" { + h.r.Quit() + return "" + } + + go func() { + h.r.UnmakeRaw() + defer h.r.MakeRaw() + + fields := strings.Fields(buffer) + + cmdName, args := fields[0], fields[1:] + + cmd := exec.Command(cmdName, args...) + + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + + err := cmd.Start() + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + + endCmd <- false + } else { + cmd.Wait() + endCmd <- true + } + }() + + ok := <-endCmd + + if ok { + return "" + } else { + return "" + } + } else { + return "" + } +} + +func main() { + fmt.Println("Try \"vi\" or \"top\" and see what happens...") + + h := &ShellWrapper{} + h.r = repl.NewRepl(h) + + if err := h.r.Loop(); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + } +} diff --git a/tools/dawgrun/pkg/go-repl/go.mod b/tools/dawgrun/pkg/go-repl/go.mod new file mode 100644 index 0000000..3ff0a8b --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/go.mod @@ -0,0 +1,8 @@ +module github.com/openengineer/go-repl + +go 1.15 + +require ( + github.com/openengineer/go-terminal v0.0.0-20220304032943-93486212aca4 // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 +) diff --git a/tools/dawgrun/pkg/go-repl/handler.go b/tools/dawgrun/pkg/go-repl/handler.go new file mode 100644 index 0000000..9e240ac --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/handler.go @@ -0,0 +1,8 @@ +package repl + +// Implement this interface in order to use `Repl` with your custom logic. +type Handler interface { + Prompt() string + Eval(buffer string) string + Tab(buffer string) string +} diff --git a/tools/dawgrun/pkg/go-repl/repl.go b/tools/dawgrun/pkg/go-repl/repl.go new file mode 100644 index 0000000..6b2f50c --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/repl.go @@ -0,0 +1,1569 @@ +// Lightweight Golang REPL library, inspired by GNU readline. You provide the Eval function, and go-repl does the rest. +package repl + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "regexp" + "strconv" + "strings" + "time" + + "golang.org/x/term" +) + +// Period between polls for terminal size changes. +// 10ms is the default, human reaction times are an order of magnitude slower than this interval, +// and auto generated escape sequence bytes are an order of magnitude faster than this interval. +var SIZE_POLLING_INTERVAL = 10 * time.Millisecond + +type ( + StatusWidgetFn func(*Repl) string + StatusWidgetFns struct { + Left StatusWidgetFn + Right StatusWidgetFn + } +) + +type Repl struct { + handler Handler + + StatusWidgets *StatusWidgetFns + + history [][]byte // simply keep everything, it doesn't matter + historyIdx int // -1 for last + historyMaxLines int + historyFile *os.File // open history file, so we can keep appending + + phraseRe *regexp.Regexp + + reader *_StdinReader + + buffer []byte // input bytes are accumulated + backup []byte // we can go into a history line, and start editing it + prevDel []byte // previous deletion + filter []byte // for reverse search + bufferPos int // position in the buffer (0-based) + viewStart int // usually 0, but can be positive in case of very large inputs + viewEnd int // + promptRow int // 0-based + width int + height int + + onEnd func() + debug *os.File +} + +type Options struct { + // HistoryFilePath is the path to the file that console history is stored in + HistoryFilePath string + // HistoryFileMaxLines is the maximum number of lines of the history that should be kept + // before it gets truncated + HistoryMaxLines int + // StatusWidgets is a struct containing widgets used for content when rendering the status + // bar to screen + StatusWidgets *StatusWidgetFns +} + +// Create a new Repl using your custom Handler. +func NewRepl(handler Handler, opts *Options) *Repl { + r := &Repl{ + handler: handler, + history: make([][]byte, 0), + historyIdx: -1, + historyFile: nil, + historyMaxLines: 1000, + phraseRe: regexp.MustCompile(`([0-9a-zA-Z_\-\.]+)`), + reader: newStdinReader(), + buffer: nil, + backup: nil, + prevDel: nil, + filter: nil, + bufferPos: 0, + viewStart: 0, + viewEnd: -1, + promptRow: -1, + width: 0, + height: 0, + onEnd: nil, + debug: nil, + } + + debug := os.Getenv("REPL_DEBUG_LOG") + if debug != "" { + debug, err := os.Create(debug) + if err != nil { + panic(fmt.Errorf("error start repl (debug): %w", err)) + } + r.debug = debug + } + + if opts != nil { + if err := r.loadOptions(opts); err != nil { + panic(fmt.Errorf("error starting repl (options): %w", err)) + } + } + + return r +} + +/////////////////// +// internal methods +/////////////////// + +func (r *Repl) loadOptions(opts *Options) error { + // Open history file from HistoryFilePath + historyFile, err := os.OpenFile(opts.HistoryFilePath, os.O_CREATE|os.O_RDWR|io.SeekStart, 0o660) + if err != nil { + return fmt.Errorf("could not open history file: %w", err) + } + + r.historyFile = historyFile + r.historyMaxLines = opts.HistoryMaxLines + + // Load history file into the buffer + if err := r.loadHistory(); err != nil { + return fmt.Errorf("could not load history from file: %w", err) + } + + r.StatusWidgets = opts.StatusWidgets + + return nil +} + +func (r *Repl) loadHistory() error { + historyReader := bufio.NewReader(r.historyFile) + reading := true + for reading { + line, err := historyReader.ReadBytes('\n') + if err != nil { + switch err { + case io.EOF: + reading = false + break + default: + return fmt.Errorf("could not read from history file: %w", err) + } + } + + if line == nil { + continue + } + + r.history = append(r.history, bytes.TrimSpace(line)) + } + + return nil +} + +func (r *Repl) saveHistory() error { + if err := r.historyFile.Truncate(0); err != nil { + return fmt.Errorf("could not truncate history file: %w", err) + } + + if _, err := r.historyFile.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("could not seek to beginning after truncate: %w", err) + } + + // truncate history buffer before save. + if len(r.history) > r.historyMaxLines { + r.history = r.history[0:r.historyMaxLines] + } + + historyWriter := bufio.NewWriter(r.historyFile) + for _, line := range r.history { + line := bytes.TrimSpace(line) + // Filter blank lines + if len(line) == 0 { + continue + } + + if n, err := historyWriter.Write(append(line, byte('\n'))); n < len(line) || err != nil { + return fmt.Errorf("could not write history to file (%d bytes written): %w", n, err) + } + } + + if err := historyWriter.Flush(); err != nil { + return fmt.Errorf("could not flush writer: %w", err) + } + + if err := r.historyFile.Close(); err != nil { + return fmt.Errorf("could not close history file: %w", err) + } + + return nil +} + +func (r *Repl) getWidth() int { + return r.width +} + +func (r *Repl) getHeight() int { + return r.height +} + +func (r *Repl) innerHeight() int { + if r.statusVisible() { + return r.getHeight() - 1 + } else { + return r.getHeight() + } +} + +func (r *Repl) log(format string, args ...interface{}) { + if r.debug != nil { + fmt.Fprintf(r.debug, format, args...) + } +} + +func (r *Repl) notifySizeChange() { + getSize := func() (int, int) { + w, h, err := term.GetSize(0) + if err != nil { + panic(err) + } + + return w, h + } + + r.width, r.height = getSize() + + go func() { + for { + <-time.After(SIZE_POLLING_INTERVAL) + + newW, newH := getSize() + + r.resize(newW, newH) + } + }() +} + +func (r *Repl) resize(w, h int) { + if w != r.width || h != r.height { + r.width, r.height = w, h + + r.force(r.buffer, r.bufferPos) + } +} + +func (r *Repl) searchActive() bool { + return r.filter != nil +} + +func (r *Repl) stopSearch() { + r.filter = nil + + r.clearStatus() + r.writeStatus() +} + +// turn stdin bytes into something useful +func (r *Repl) dispatch(b []byte) { + n := len(b) + + r.log("keypress: %v\n", b) + + if n == 1 { + switch b[0] { + case 0: // NULL, or CTRL-2 + return + case 1: // CTRL-A + r.moveToBufferStart() + case 2: // CTRL-B + r.moveLeftOneChar() + case 3: // CTRL-C + if r.searchActive() { + r.stopSearch() + } + + r.clearBuffer() + r.writeStatus() + case 4: // CTRL-D + r.quit() + case 5: // CTRL-E + r.moveToBufferEnd() + case 6: // CTRL-F + r.moveRightOneChar() + case 8: // CTRL-H + r.backspaceActiveBuffer() + case 9: // TAB + if r.searchActive() { + r.stopSearch() + } else { + r.tab() + } + case 10: // SHIFT-ENTER + if r.searchActive() { + r.stopSearch() + } else { + r.clearStatus() + r.addBytesToBuffer([]byte{'\n'}) + r.writeStatus() + } + case 11: // CTRL-K + if r.searchActive() { + r.stopSearch() + } else { + r.clearToEnd() + } + case 12: // CTRL-L + r.redrawScreen() + case 13: // RETURN + if r.searchActive() { + r.stopSearch() + } else { + r.evalBuffer() + } + case 14: // CTRL-N + r.historyForward() + case 16: // CTRL-P + r.historyBack() + case 17: // CTRL-Q + if r.searchActive() { + r.stopSearch() + } else { + r.clearOnePhraseRight() + } + case 18: // CTRL-R + if !r.searchActive() { + r.startReverseSearch() + } + case 21: // CTRL-U + if r.searchActive() { + r.stopSearch() + } else { + r.clearToStart() + } + case 22: // CTRL-V + return + case 25: // CTRL-Y + if r.searchActive() { + r.stopSearch() + } else { + r.clearStatus() + r.insertPrevDel() + r.writeStatus() + } + case 23: // CTRL-W + if r.searchActive() { + r.stopSearch() + } else { + r.clearOnePhraseLeft() + } + case 27: // ESC + if r.searchActive() { + r.stopSearch() + } else { + r.clearBuffer() + r.writeStatus() + } + case 127: // BACKSPACE + r.backspaceActiveBuffer() + default: + if b[0] >= 32 { + if r.searchActive() { + r.filter = append(r.filter, b[0]) + + r.updateSearchResult() + } else { + r.clearStatus() + r.addBytesToBuffer([]byte{b[0]}) + } + r.writeStatus() + } + } + } else if n == 2 && b[0] == 195 { + // ALT + KEY + } else if n > 2 && b[0] == 27 && b[1] == 79 { // [ESCAPE, O, ...] + switch b[2] { + case 80: // F1 + case 81: // F2 + // ... + default: + // function keys not yet supported + } + } else if n > 2 && b[0] == 27 && b[1] == 91 { // [ESCAPE, OPEN_BRACKET, ...] + if n == 3 { + switch b[2] { + case 65: + r.historyBack() + case 66: + r.historyForward() + case 67: // ArrowRight + r.moveRightOneChar() + case 68: // ArrowLeft + r.moveLeftOneChar() + case 72: + r.moveToBufferStart() + case 70: + r.moveToBufferEnd() + } + } else if n == 4 { + if b[2] == 51 && b[3] == 126 { + r.deleteChar() + } + } else if n == 6 && b[2] == 49 && b[3] == 59 { + if b[4] == 53 && b[5] == 68 { // CTRL-ArrowLeft + r.moveLeftOnePhrase() + } else if b[4] == 53 && b[5] == 67 { + r.moveRightOnePhrase() + } else if b[4] == 53 && b[5] == 66 { + // r.moveDownOneLine() + } else if b[4] == 53 && b[5] == 65 { + // r.moveUpOneLine() + } + } else if len(b) > 5 && b[n-1] == 82 { + parts := strings.Split(string(b[2:n-1]), ";") + row, err := strconv.Atoi(parts[0]) + if err == nil { + col, err := strconv.Atoi(parts[1]) + if err == nil { + r.handleCursorQuery(col-1, row-1) + } + } + } + } else if len(b) > 6 && b[n-1] == 82 { + // go backwards until the esc char + for i := n - 2; i >= 0; i-- { + if b[i] == 27 && b[i+1] == 91 { + parts := strings.Split(string(b[i+2:n-1]), ";") + row, err := strconv.Atoi(parts[0]) + if err == nil { + col, err := strconv.Atoi(parts[1]) + if err == nil { + r.handleCursorQuery(col-1, row-1) + } + } + + printable := make([]byte, 0) + for _, b_ := range b[0:i] { + if b_ >= 32 { + printable = append(printable, b_) + } + } + + if len(printable) > 0 { + r.clearStatus() + r.addBytesToBuffer(printable) + r.writeStatus() + } + + break + } + } + } else { + r.cleanAndAddToBuffer(b) + } + + return +} + +func (r *Repl) handleCursorQuery(x, y int) { + r.updatePromptRow(y) + + r.writeStatus() +} + +func (r *Repl) printPrompt() { + moveToRowStart() + fmt.Print(r.handler.Prompt()) +} + +func (r *Repl) resetBuffer() { + r.bufferPos = 0 + r.buffer = make([]byte, 0) + r.printPrompt() + r.viewStart = 0 + r.viewEnd = -1 +} + +func (r *Repl) overflow() bool { + b := r.calcHeight() > r.innerHeight() + if !b { + r.viewStart = 0 + r.viewEnd = -1 + } + return b +} + +func (r *Repl) viewOverflow() bool { + return r.calcViewHeight() > r.innerHeight() +} + +func (r *Repl) boundPromptRow() { + n := r.viewEnd + if n < 0 { + n = r.bufferLen() + } + + xe, ye := r.cursorCoord(n) + + if ye >= r.innerHeight() { + moveCursorTo(xe, ye) + fmt.Print("\n") + r.updatePromptRow(r.promptRow - (ye + 1 - r.innerHeight())) + } +} + +func (r *Repl) addBytesToBuffer(bs []byte) { + if r.bufferPos == r.bufferLen() { + xBef, _ := r.cursorCoord(-1) + + r.bufferPos += len(bs) + len_ := r.bufferLen() + r.buffer = append(r.buffer, bs...) + + if !r.overflow() { + needSync := false + for _, b := range bs { + r.writeByte(b) + + if b != '\n' && xBef == r.getWidth()-1 { + needSync = true + } + } + + if needSync { + r.syncCursor() + } + + r.boundPromptRow() + + return + } else { + // reset prev changes + r.bufferPos -= len(bs) + r.buffer = r.buffer[0:len_] + } + } + + tail := r.buffer[r.bufferPos:] + + newBuffer := make([]byte, 0) + newBuffer = append(newBuffer, r.buffer[0:r.bufferPos]...) + newBuffer = append(newBuffer, bs...) + newBuffer = append(newBuffer, tail...) + + newPos := r.bufferPos + len(bs) + + r.force(newBuffer, newPos) // force should take into account extra long lines +} + +func (r *Repl) promptLen() int { + return len(r.handler.Prompt()) +} + +func (r *Repl) bufferLen() int { + return len(r.buffer) +} + +func relCursorCoord(buffer []byte, x0 int, bufferPos int, w int) (int, int) { + x := x0 + y := 0 + + for j, c := range buffer { + if j >= bufferPos { + break + } else if c == '\n' { + x = 0 + y += 1 + } else { + x += 1 + } + + if x == w { + x = 0 + y += 1 + } + } + + return x, y +} + +func calcHeight(buffer []byte, x0 int, w int) int { + _, y := relCursorCoord(buffer, x0, len(buffer), w) + return y + 1 +} + +func (r *Repl) calcHeight() int { + return calcHeight(r.buffer, r.promptLen(), r.getWidth()) +} + +func (r *Repl) calcViewHeight() int { + if r.viewEnd > r.bufferLen() { + r.viewEnd = r.bufferLen() + } + + return calcHeight(r.buffer[r.viewStart:r.viewEnd], r.promptLen(), r.getWidth()) +} + +func (r *Repl) calcViewStartHeight() int { + return calcHeight(r.buffer[0:r.viewStart], r.promptLen(), r.getWidth()) +} + +func (r *Repl) calcViewEndHeight() int { + return r.calcHeight() - r.calcViewHeight() +} + +// i is 0-based index in current buffer +func (r *Repl) cursorCoord(bufferPos int) (int, int) { + w := r.getWidth() + + if bufferPos < 0 { + bufferPos = r.bufferPos + } + + x, y := relCursorCoord(r.buffer[r.viewStart:], r.promptLen(), bufferPos-r.viewStart, w) + + y += r.promptRow + + return x, y +} + +// return bufferPos that matches (x,y) as best as possible +func (r *Repl) calcBufferPos(x, y int) int { + xc := r.promptLen() + yc := r.promptRow + + for i, c := range r.buffer[r.viewStart:] { + if yc > y { + r.log("overshoot\n") + return i - 1 + r.viewStart + } else if yc == y && xc >= x { + r.log("calc pos for %d,%d -> %d (%d,%d)\n", x, y, i+r.viewStart, xc, yc) + return i + r.viewStart + } + + if c == '\n' { + xc = 0 + yc += 1 + } else { + xc += 1 + } + + if xc == r.getWidth() { + xc = 0 + yc += 1 + } + + } + + if r.viewEnd >= 0 { + return r.viewEnd + } else { + return r.bufferLen() + } +} + +func (r *Repl) clearAfterPrompt() { + moveCursorTo(0, r.getHeight()-1) + + if r.promptRow < 0 { + r.updatePromptRow(0) + } + + dy := (r.getHeight() - 1 - r.promptRow) + + clearRows(dy) +} + +// clear as much as possible +func (r *Repl) clearBuffer() { + moveCursorTo(0, r.getHeight()-1) + + r.log("clearing buffer\n") + if r.promptRow < 0 { + r.updatePromptRow(0) + } + + dy := (r.getHeight() - 1 - r.promptRow) + + clearRows(dy) + clearRow() + + r.resetBuffer() +} + +func copyBytes(b []byte) []byte { + l := make([]byte, len(b)) + + for i, c := range b { + l[i] = c + } + + return l +} + +func (r *Repl) adjustBufferView() { + if r.bufferPos < r.viewStart { + r.viewStart = r.bufferPos + r.viewEnd = r.bufferLen() + + for r.viewOverflow() { + r.viewEnd -= 1 + } + } else if r.bufferPos > r.viewEnd { + r.viewEnd = r.bufferPos + for r.viewOverflow() { + r.viewStart += 1 + } + } else if r.viewOverflow() { + r.viewEnd = r.bufferLen() + + for r.viewOverflow() { + r.viewEnd -= 1 + } + } else { + for !r.viewOverflow() && r.viewEnd < r.bufferLen() { + r.viewEnd += 1 + } + + for r.viewOverflow() { + r.viewEnd -= 1 + } + } +} + +// this works for a single line +func (r *Repl) force(newBuffer []byte, bufferPos int) { + newBuffer = copyBytes(newBuffer) + + r.clearStatus() + + r.log("overflow? %d vs %d\n", calcHeight(newBuffer, r.promptLen(), r.getWidth()), r.innerHeight()) + if calcHeight(newBuffer, r.promptLen(), r.getWidth()) > r.innerHeight() { + viewStart_, viewEnd_ := r.viewStart, r.viewEnd + r.clearScreen() + r.buffer = newBuffer + r.bufferPos = bufferPos + r.viewStart, r.viewEnd = viewStart_, viewEnd_ + r.log("viewStart: %d, viewEnd: %d\n", r.viewStart, r.viewEnd) + r.adjustBufferView() + + r.log("writing bytes from %d to %d (instead of 0 to %d) (bpos: %d)\n", r.viewStart, r.viewEnd, r.bufferLen(), r.bufferPos) + + for _, b := range r.buffer[r.viewStart:r.viewEnd] { + r.writeByte(b) + } + + r.syncCursor() + // what is the appropriate bufferOffset? The minimal movement to keep the /move + } else { + r.clearBuffer() + + // TODO: writeBytes instead + r.addBytesToBuffer(newBuffer) + + if bufferPos >= r.bufferLen() { + bufferPos = r.bufferLen() + } + + r.bufferPos = bufferPos + + r.log("bufferPos: %d, bufferLen: %d, width: %d\n", r.bufferPos, len(r.buffer), r.getWidth()) + r.syncCursor() + } + + r.writeStatus() +} + +func (r *Repl) syncCursor() { + x, y := r.cursorCoord(-1) + moveCursorTo(x, y) +} + +func (r *Repl) evalBuffer() { + r.clearStatus() + + r.newLine() + + // input that is sent to stdin while the handler is blocking, is returned the next time we read bytes from the stdinreader, followed by a sequence indicating the new cursor position (due to queryCursorPos() being called below), so the routine that handles the cursor pos query should also handle any preceding bytes + out := r.handler.Eval(strings.TrimSpace(string(r.buffer))) + + if len(out) > 0 { + outLines := strings.Split(out, "\n") + + for _, outLine := range outLines { + fmt.Print(outLine) + r.newLine() + } + } + + r.appendToHistory(r.buffer) + r.historyIdx = -1 + + r.backup = nil + + r.resetBuffer() + + queryCursorPos() +} + +func (r *Repl) redraw() { + r.force(r.buffer, r.bufferPos) +} + +func (r *Repl) syncCursorOverflow() { + if r.overflow() { + r.redraw() + } else { + r.syncCursor() + } +} + +func (r *Repl) moveToBufferEnd() { + if r.searchActive() { + r.stopSearch() + } else { + r.bufferPos = r.bufferLen() + + r.syncCursorOverflow() + } +} + +func (r *Repl) moveToBufferStart() { + if r.searchActive() { + r.stopSearch() + } else { + r.bufferPos = 0 + + r.syncCursorOverflow() + } +} + +func (r *Repl) moveLeftOneChar() { + if r.searchActive() { + r.stopSearch() + } else { + if r.bufferPos > 0 { + r.bufferPos -= 1 + + if r.overflow() { + if r.bufferPos <= r.viewStart { + r.redraw() + return + } + } + + r.syncCursor() + } + } +} + +func (r *Repl) moveRightOneChar() { + if r.searchActive() { + r.stopSearch() + } else { + if r.bufferPos < r.bufferLen() { + r.bufferPos += 1 + + if r.overflow() { + if r.bufferPos >= r.viewEnd { + r.redraw() + return + } + } + + r.syncCursor() + } + } +} + +func (r *Repl) moveUpOneLine() { + x, y := r.cursorCoord(-1) + + h0 := r.calcViewStartHeight() + _, y0 := r.cursorCoord(r.viewStart) + + if ((h0 > 0) && (y >= y0)) || y > y0 { + // problem is that y is in view space, and + r.bufferPos = r.calcBufferPos(x, y-1) + + if r.overflow() { + if r.bufferPos <= r.viewStart { + r.redraw() + return + } + } + + r.syncCursor() + } +} + +func (r *Repl) moveDownOneLine() { + x, y := r.cursorCoord(-1) + + _, ye := r.cursorCoord(r.viewEnd) + he := r.calcViewEndHeight() + + if y < ye || (y <= ye && he > 0) { + r.bufferPos = r.calcBufferPos(x, y+1) + + if r.overflow() { + if r.bufferPos >= r.viewEnd { + r.redraw() + return + } + } + + r.syncCursor() + } +} + +func (r *Repl) moveLeftOnePhrase() { + newPos, ok := r.prevPhrasePos() + if ok { + r.bufferPos = newPos + + if r.overflow() { + if r.bufferPos <= r.viewStart { + r.redraw() + return + } + } + + r.syncCursor() + } +} + +func (r *Repl) moveRightOnePhrase() { + newPos, ok := r.nextPhrasePos() + if ok { + r.bufferPos = newPos + + if r.overflow() { + if r.bufferPos >= r.viewEnd { + r.redraw() + return + } + } + + r.syncCursor() + } +} + +// dont append if the same as the previous +func (r *Repl) appendToHistory(entry []byte) { + n := len(r.history) + + if n == 0 { + r.history = append(r.history, entry) + } else if string(r.history[n-1]) != string(entry) { + r.history = append(r.history, entry) + } +} + +func (r *Repl) useHistoryEntry(i int) { + if i == -1 { + r.historyIdx = -1 + + if r.backup != nil { + r.force(r.backup, len(r.backup)) + } + + r.backup = nil + } else { + if r.backup == nil { + r.backup = r.buffer + } + + r.historyIdx = i + + entry := r.history[i] + + r.force(entry, len(entry)) + } +} + +func (r *Repl) historyForward() { + if r.searchActive() { + if r.historyIdx >= 0 && r.historyIdx < len(r.history)-1 { + for i := r.historyIdx + 1; i < len(r.history); i++ { + if r.filterMatches(r.history[i]) { + r.useHistoryEntry(i) + return + } + } + } + } else { + if r.historyIdx != -1 { + if r.historyIdx < len(r.history)-1 { + r.useHistoryEntry(r.historyIdx + 1) + } else { + r.useHistoryEntry(-1) + } + } + } +} + +func (r *Repl) historyBack() { + if r.searchActive() { + if r.historyIdx > 0 { + for i := r.historyIdx - 1; i >= 0; i-- { + if r.filterMatches(r.history[i]) { + r.useHistoryEntry(i) + return + } + } + } + } else { + if r.historyIdx == -1 { + if len(r.history) > 0 { + r.useHistoryEntry(len(r.history) - 1) + } + } else if r.historyIdx > 0 { + r.useHistoryEntry(r.historyIdx - 1) + } + } +} + +func (r *Repl) startReverseSearch() { + r.filter = make([]byte, 0) + + r.clearStatus() + r.writeStatus() +} + +func (r *Repl) tab() { + prec := string(r.buffer[0:r.bufferPos]) + + extra := r.handler.Tab(prec) + + if len(extra) > 0 { + r.addBytesToBuffer([]byte(extra)) + } +} + +func (r *Repl) quit() { + r.clearAfterPrompt() + r.saveHistory() + + fmt.Print("\n\r") + + moveToRowStart() + + r.UnmakeRaw() + + os.Exit(0) +} + +func (r *Repl) redrawScreen() { + buffer := r.buffer + bufferPos := r.bufferPos + + r.clearScreen() + + r.force(buffer, bufferPos) +} + +func (r *Repl) clearScreen() { + clearScreen() + + moveToScreenStart() + + r.updatePromptRow(0) + + r.resetBuffer() +} + +func (r *Repl) backspaceActiveBuffer() { + if r.searchActive() { + n := len(r.filter) + if n > 0 { + r.filter = r.filter[0 : n-1] + } + + r.updateSearchResult() + + r.clearStatus() + r.writeStatus() + } else { + r.backspace() + } +} + +func (r *Repl) backspace() { + n := r.bufferLen() + + if n > 0 { + if r.bufferPos > 0 { + newPos := r.bufferPos - 1 + newBuffer := append(r.buffer[0:newPos], r.buffer[newPos+1:len(r.buffer)]...) + + _, y0 := r.cursorCoord(-1) + x1, y1 := r.cursorCoord(newPos) + + if y0 == y1 && r.bufferPos == len(r.buffer) && !r.overflow() { + moveToCol(x1) + clearRowAfterCursor() + r.buffer = newBuffer + r.bufferPos = newPos + } else { + r.force(newBuffer, newPos) + } + } + } +} + +func (r *Repl) deleteChar() { + if r.searchActive() { + r.stopSearch() + } else { + if r.bufferPos < r.bufferLen() { + newBuffer := make([]byte, 0) + newBuffer = append(newBuffer, r.buffer[0:r.bufferPos]...) + + if r.bufferPos < r.bufferLen()-1 { + newBuffer = append(newBuffer, r.buffer[r.bufferPos+1:]...) + } + + newPos := r.bufferPos + + r.force(newBuffer, newPos) + } + } +} + +func (r *Repl) clearToEnd() { + if r.bufferPos != r.bufferLen() { + newBuffer := r.buffer[0:r.bufferPos] + + r.prevDel = r.buffer[r.bufferPos:] + + r.force(newBuffer, r.bufferPos) + } +} + +func (r *Repl) clearToStart() { + if r.bufferPos > 0 { + newBuffer := r.buffer[r.bufferPos:] + + r.prevDel = r.buffer[0:r.bufferPos] + + r.force(newBuffer, 0) + } +} + +func (r *Repl) phraseStartPositions() []int { + if len(r.buffer) == 0 { + return []int{0} + } + + re := r.phraseRe + + indices := re.FindAllIndex(r.buffer, -1) + + res := make([]int, 0) + + for i, match := range indices { + start := match[0] + stop := match[1] + if i == 0 && start != 0 { + res = append(res, 0) + } + + res = append(res, start, stop) + + if i == len(indices)-1 && stop != len(r.buffer) { + res = append(res, len(r.buffer)) + } + } + + if len(res) == 0 || res[len(res)-1] != len(r.buffer) { + res = append(res, len(r.buffer)) + } + + return res +} + +func (r *Repl) nextPhrasePos() (int, bool) { + var res int + if r.bufferPos == r.bufferLen() { + res = r.bufferLen() + } else { + indices := r.phraseStartPositions() + + for _, idx := range indices { + if idx > r.bufferPos { + res = idx + break + } + } + } + + return res, res != r.bufferPos +} + +func (r *Repl) prevPhrasePos() (int, bool) { + var res int + if r.bufferPos == 0 { + res = 0 + } else { + indices := r.phraseStartPositions() + + for i := len(indices) - 1; i >= 0; i-- { + idx := indices[i] + if idx < r.bufferPos { + res = idx + break + } + } + } + + return res, res != r.bufferPos +} + +func (r *Repl) clearOnePhraseLeft() { + idx, ok := r.prevPhrasePos() + if ok { + newBuffer := append(r.buffer[0:idx], r.buffer[r.bufferPos:]...) + + newPos := idx + + r.prevDel = r.buffer[idx:r.bufferPos] + + _, y0 := r.cursorCoord(-1) + x1, y1 := r.cursorCoord(newPos) + + if r.bufferPos == r.bufferLen() && y0 == y1 && x1 > 0 && !r.overflow() { + r.bufferPos = newPos + r.buffer = newBuffer + r.syncCursor() + clearRowAfterCursor() + } else { + r.force(newBuffer, newPos) + } + } +} + +func (r *Repl) clearOnePhraseRight() { + idx, ok := r.nextPhrasePos() + if ok { + newBuffer := make([]byte, 0) + newBuffer = append(newBuffer, r.buffer[0:r.bufferPos]...) + newBuffer = append(newBuffer, r.buffer[idx:]...) + + newPos := r.bufferPos + + r.prevDel = r.buffer[r.bufferPos:idx] + + r.force(newBuffer, newPos) + } +} + +func (r *Repl) cleanAndAddToBuffer(msg []byte) { + // remove bad chars + // XXX: what about unicode? + filtered := make([]byte, 0) + + for _, c := range msg { + if c == '\t' { + filtered = append(filtered, ' ') + } else if c >= 32 && c < 127 { + filtered = append(filtered, c) + } + } + + r.addBytesToBuffer(filtered) +} + +func (r *Repl) insertPrevDel() { + r.addBytesToBuffer(r.prevDel) +} + +func (r *Repl) updatePromptRow(row int) { + if row >= r.getHeight() { + row = r.getHeight() - 1 + } else if row < 0 { + row = 0 + } + + r.promptRow = row + + r.log("prompt row %d/%d\n", r.promptRow, r.innerHeight()-1) +} + +func (r *Repl) writeByte(b byte) { + if b == '\n' { + r.newLine() + } else { + // should be a printable character + fmt.Fprintf(os.Stdout, "%c", b) + } +} + +func (r *Repl) newLine() { + fmt.Fprintf(os.Stdout, "\n\r") + + // every newLine means the status line is pushed below +} + +func (r *Repl) CwdStatusWidget() string { + cwd, err := os.Getwd() + if err != nil { + cwd = "" + } + + return cwd +} + +func (r *Repl) VisStatusWidget() string { + vis := "All" + + if r.viewEnd < 0 { + r.viewEnd = r.bufferLen() + } + + if r.viewEnd < r.bufferLen() && r.viewStart == 0 { + vis = "Start" + } else if r.viewEnd == r.bufferLen() && r.viewStart > 0 { + vis = "End" + } else if r.viewEnd < r.bufferLen() && r.viewStart > 0 { + vis = fmt.Sprintf("%d", int(float64(r.bufferPos)/float64(r.bufferLen())*100)) + "%" + } + + return vis +} + +// one left aligned and one right aligned +func (r *Repl) statusFields() (string, string) { + var leftWidget, rightWidget string + if r.StatusWidgets == nil || r.StatusWidgets.Left == nil { + leftWidget = r.CwdStatusWidget() + } else { + leftWidget = r.StatusWidgets.Left(r) + } + + if r.StatusWidgets == nil || r.StatusWidgets.Right == nil { + rightWidget = r.VisStatusWidget() + } else { + rightWidget = r.StatusWidgets.Right(r) + } + + return leftWidget, rightWidget +} + +func (r *Repl) statusVisible() bool { + if r.getWidth() < 10 { + return false + } else { + return true + } +} + +func (r *Repl) clearStatus() { + if r.statusVisible() { + moveCursorTo(0, r.getHeight()-1) + + clearRow() + + r.syncCursor() + } +} + +func (r *Repl) filterStatus() string { + tot := 0 + cur := -1 + for i := len(r.history) - 1; i >= 0; i-- { + entry := r.history[i] + if r.filterMatches(entry) { + if i == r.historyIdx { + cur = tot + } + + tot += 1 + } + } + + if tot == 0 { + return "No matches" + } else if cur != -1 { + return fmt.Sprintf("%d/%d matches", cur+1, tot) + } else { + panic("unexpected") + } +} + +func (r *Repl) writeStatus() { + if !r.statusVisible() { + r.syncCursor() + return + } + + r.boundPromptRow() + + moveCursorTo(0, r.getHeight()-1) + + w := r.getWidth() + if r.searchActive() { + pref := "Reverse-search: " + fmt.Print(pref) + fmt.Print(string(r.filter)) // cursor stays here + + // print some status about the matches + if len(r.filter) > 0 && w > len(r.filter)+len(pref)+10 { + info := r.filterStatus() + + for i := 0; i < w-len(info)-len(pref)-len(r.filter); i++ { + fmt.Print(" ") + } + + fmt.Print(info) + + moveToCol(len(pref) + len(r.filter)) + } + } else { + left, right := r.statusFields() + if len(left) > w-len(right) { + left = left[0 : w-len(right)] + } + + // Start highlighting + highlight() + fmt.Print(left) + + // Re-highlight in case a custom status widget blew up the colors + highlight() + for i := 0; i < w-len(left)-len(right); i++ { + fmt.Print(" ") + } + + // Re-highlight in case a custom status widget blew up the colors + highlight() + fmt.Print(right) + + // end highlighting + resetDecorations() + + r.syncCursor() + } +} + +// use a simple match criterium now, could be improved +func (r *Repl) filterMatches(bs []byte) bool { + return strings.Contains(string(bs), string(r.filter)) +} + +func (r *Repl) updateSearchResult() { + if r.filter == nil || len(r.history) == 0 || len(r.filter) == 0 { + return + } + + // prefer currently selected entry + if r.historyIdx != -1 { + if r.filterMatches(r.buffer) { + return + } + } + + for i := len(r.history) - 1; i >= 0; i-- { + if r.filterMatches(r.history[i]) { + r.useHistoryEntry(i) + return + } + } +} + +/////////////////// +// exported methods +/////////////////// + +// Start the REPL loop. +// +// Loop sets the terminal to raw mode, so any further calls to fmt.Print or similar, might not behave as expected and can garble your REPL. +func (r *Repl) Loop() error { + // the terminal needs to be in raw mode, so we can intercept the control sequences + // (the default canonical mode isn't good enough for repl's) + if err := r.MakeRaw(); err != nil { + return err + } + + r.reader.start() + + r.notifySizeChange() + + r.printPrompt() + + queryCursorPos() // get initial prompt position + + // loop forever + for { + r.reader.read() + + bts := <-r.reader.bytes + + r.dispatch(bts) + } + + return nil +} + +// Exit the REPL program cleanly. Performs the following steps: +// 1. cleans the screen +// 2. returns the cursor to the appropriate position +// 3. unsets terminal raw mode +// +// Important: use this method instead of os.Exit. +func (r *Repl) Quit() { + r.quit() +} + +// Unset the raw mode in case you want to run a curses-like command inside your REPL session (e.g. vi or top). Remember to call MakeRaw after the command finishes. +func (r *Repl) UnmakeRaw() { + r.onEnd() + + r.onEnd = nil +} + +// Explicitely set the terminal back to raw mode after a call to UnmakeRaw. +func (r *Repl) MakeRaw() error { + // we need the term package as a platform independent way of setting the connected terminal emulator to raw mode + fd := int(os.Stdin.Fd()) + + oldState, err := term.MakeRaw(fd) + if err != nil { + return err + } + + r.onEnd = func() { + term.Restore(fd, oldState) + } + + return nil +} + +func (r *Repl) ReadLine(echo bool) string { + buffer := make([]byte, 0) + + for { + r.reader.read() + + bts := <-r.reader.bytes + + // a mini version of dispatch + if len(bts) == 1 && bts[0] == 13 { + if echo { + fmt.Print("\n\r") + } + break + } else { + for _, b := range bts { + if b == 27 { + break + } else if b >= 32 { + if echo { + fmt.Print(string([]byte{b})) + } + + buffer = append(buffer, b) + } + } + } + } + + return string(buffer) +} diff --git a/tools/dawgrun/pkg/go-repl/stdinreader.go b/tools/dawgrun/pkg/go-repl/stdinreader.go new file mode 100644 index 0000000..b9c6494 --- /dev/null +++ b/tools/dawgrun/pkg/go-repl/stdinreader.go @@ -0,0 +1,91 @@ +package repl + +import ( + "bufio" + "os" + "sync" + "time" +) + +// This is a cut-off time for grouping auto-generated escape sequences. +const MACHINE_INTERVAL = time.Millisecond + +// _StdinReader collects inputs and keeps sequences of auto-generated bytes together as a group (eg. ansi escape sequences) +type _StdinReader struct { + reader *bufio.Reader + lastTime time.Time + buffer []byte + lock *sync.Mutex + + bytes chan []byte +} + +func newStdinReader() *_StdinReader { + return &_StdinReader{ + reader: nil, + lastTime: time.Time{}, + buffer: make([]byte, 0), + lock: &sync.Mutex{}, + + bytes: make(chan []byte), + } +} + +func (r *_StdinReader) start() { + go func() { + for { + <-time.After(MACHINE_INTERVAL) + + r.lock.Lock() + + if len(r.buffer) > 0 { + if time.Now().After(r.lastTime.Add(MACHINE_INTERVAL)) { + msg := r.buffer + + r.buffer = make([]byte, 0) + + r.bytes <- msg + } + } + + r.lock.Unlock() + } + }() +} + +func (r *_StdinReader) read() { + if r.reader != nil { + return + } + + r.reader = bufio.NewReader(os.Stdin) + r.lastTime = time.Now() + + go func() { + for { + b, err := r.reader.ReadByte() + if err != nil { + panic(err) + } + + stopNow := false + if b == 13 && time.Now().After(r.lastTime.Add(MACHINE_INTERVAL)) { + // it is unlikely that a carriage return followed by some text is pasted into the terminal, so we can use this as a queu to quit + stopNow = true + } + + r.lastTime = time.Now() + + r.lock.Lock() + + r.buffer = append(r.buffer, b) + + r.lock.Unlock() + + if stopNow { + r.reader = nil + return + } + } + }() +} diff --git a/tools/dawgrun/pkg/stubs/kindmapper.go b/tools/dawgrun/pkg/stubs/kindmapper.go new file mode 100644 index 0000000..6bdbce1 --- /dev/null +++ b/tools/dawgrun/pkg/stubs/kindmapper.go @@ -0,0 +1,90 @@ +// Package stubs has various bits and bobs to stub out internal behaviors +package stubs + +import ( + "context" + "errors" + "fmt" + + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/graph" +) + +var ( + ErrNoSuchKind = errors.New("no such kind") + ErrNoSuchKindID = errors.New("no such kind with ID") +) + +type KindMap map[int16]graph.Kind + +func (km KindMap) Invert() map[graph.Kind]int16 { + inverse := make(map[graph.Kind]int16) + for kindID, kind := range km { + inverse[kind] = kindID + } + + return inverse +} + +type DumbKindMapper struct { + idToKind KindMap + kindToID map[graph.Kind]int16 + lastID int16 +} + +var _ pgsql.KindMapper = (*DumbKindMapper)(nil) + +func EmptyMapper() *DumbKindMapper { + return &DumbKindMapper{ + idToKind: make(KindMap), + kindToID: make(map[graph.Kind]int16), + lastID: -1, + } +} + +func MapperFromKindMap(kindMap KindMap) *DumbKindMapper { + return &DumbKindMapper{ + idToKind: kindMap, + kindToID: kindMap.Invert(), + lastID: -1, + } +} + +func (k *DumbKindMapper) MapKinds(ctx context.Context, kinds graph.Kinds) ([]int16, error) { + return k.AssertKinds(ctx, kinds) +} + +// AssertKinds tries to return IDs of `graph.Kind`s that are already known, inserting any kinds not known +// into the schema. +func (k *DumbKindMapper) AssertKinds(ctx context.Context, kinds graph.Kinds) ([]int16, error) { + kindIDs := make([]int16, 0) + for _, kind := range kinds { + if mappedKindID, ok := k.kindToID[kind]; !ok { + newID := k.lastID + 1 + k.lastID += 1 + k.kindToID[kind] = newID + k.idToKind[newID] = kind + kindIDs = append(kindIDs, newID) + } else { + kindIDs = append(kindIDs, mappedKindID) + } + } + + return kindIDs, nil +} + +func (k *DumbKindMapper) GetKindByID(id int16) (graph.Kind, error) { + if kind, ok := k.idToKind[id]; !ok { + return nil, fmt.Errorf("%w: %d", ErrNoSuchKindID, id) + } else { + return kind, nil + } +} + +func (k *DumbKindMapper) GetIDByKind(kind graph.Kind) (int16, error) { + if kindID, ok := k.kindToID[kind]; !ok { + return -1, fmt.Errorf("%w: %v", ErrNoSuchKind, kind) + } else { + return kindID, nil + } +}