diff --git a/e2e/go.mod b/e2e/go.mod index 4ac67736e2..c3a72b7800 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -91,7 +91,6 @@ require ( github.com/olekukonko/tablewriter v1.1.4 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/peterh/liner v1.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -99,7 +98,9 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/reeflective/readline v1.3.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shirou/gopsutil/v4 v4.26.5 // indirect github.com/shopspring/decimal v1.4.0 // indirect diff --git a/e2e/go.sum b/e2e/go.sum index f417d53a74..135971c3e5 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -118,6 +118,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF2 github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= @@ -159,7 +161,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= @@ -200,8 +201,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= -github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -219,8 +218,12 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/reeflective/readline v1.3.0 h1:uh9c2SEmyoy7A/auequfXZjvK0NP5HVEAJFcL9Uf7qE= +github.com/reeflective/readline v1.3.0/go.mod h1:bOpqx2/VqGlIoobyWR1Vgt/p5FiMfIHj4OicPuw6RfU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= @@ -312,7 +315,6 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/go.mod b/go.mod index 846f0c4c4c..e0127d781c 100644 --- a/go.mod +++ b/go.mod @@ -19,10 +19,10 @@ require ( github.com/olekukonko/tablewriter v1.1.4 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 - github.com/peterh/liner v1.2.2 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 + github.com/reeflective/readline v1.3.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/sergi/go-diff v1.4.0 github.com/sirupsen/logrus v1.9.4 @@ -99,6 +99,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect diff --git a/go.sum b/go.sum index 32f8ad0c61..53a6d93a8f 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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= @@ -68,6 +70,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF2 github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= @@ -109,7 +113,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= @@ -130,8 +133,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= -github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -145,6 +146,10 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/reeflective/readline v1.3.0 h1:uh9c2SEmyoy7A/auequfXZjvK0NP5HVEAJFcL9Uf7qE= +github.com/reeflective/readline v1.3.0/go.mod h1:bOpqx2/VqGlIoobyWR1Vgt/p5FiMfIHj4OicPuw6RfU= +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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= @@ -270,7 +275,6 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20211117180635-dee7805ff2e1/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= diff --git a/v1/repl/repl.go b/v1/repl/repl.go index 87a7ddd7db..94812a06fc 100644 --- a/v1/repl/repl.go +++ b/v1/repl/repl.go @@ -9,6 +9,8 @@ package repl import ( + "bufio" + "bytes" "context" "encoding/json" "errors" @@ -19,8 +21,9 @@ import ( "strconv" "strings" "sync" + "time" - "github.com/peterh/liner" + "github.com/reeflective/readline" "github.com/open-policy-agent/opa/internal/future" pr "github.com/open-policy-agent/opa/internal/presentation" @@ -72,6 +75,7 @@ type REPL struct { prettyLimit int report [][2]string target string // target type (wasm, rego, etc.) + history *replHistory mtx sync.Mutex } @@ -167,26 +171,42 @@ func (r *REPL) WithStderrWriter(w io.Writer) *REPL { return r } +// newShell initializes the readline line-reader used by Loop. Bracketed paste +// is enabled so that pasted tabs/newlines are inserted literally instead of +// triggering tab-completion or submitting the line (issue #962). +func (r *REPL) newShell() *readline.Shell { + line := readline.NewShell() + // Enabling bracketed paste is the fix for #962; surface a warning if the + // config key ever stops being honored so the bug can't silently return. + if err := line.Config.Set("enable-bracketed-paste", true); err != nil { + fmt.Fprintln(r.stderrWriter(), "warning: failed to enable bracketed paste:", err) + } + line.Prompt.Primary(r.getPrompt) + line.Completer = r.complete + r.loadHistory(line) + return line +} + // Loop will run until the user enters "exit", Ctrl+C, Ctrl+D, or an unexpected error occurs. func (r *REPL) Loop(ctx context.Context) error { - // Initialize the liner library. - line := liner.NewLiner() - defer line.Close() - line.SetCtrlCAborts(true) - line.SetMultiLineMode(true) - r.loadHistory(line) + line := r.newShell() if len(r.banner) > 0 { fmt.Fprintln(r.output, r.banner) } - line.SetCompleter(r.complete) - loop: + // Restoring the prompt (and resuming history persistence) here means every + // path back into the main loop is covered by a single reset, so a future + // exit path can't forget to undo the exit-prompt overrides below. + if r.history != nil { + r.history.resume() + } + line.Prompt.Primary(r.getPrompt) for { - input, err := line.Prompt(r.getPrompt()) + input, err := line.Readline() // prompt on ctrl+d if err == io.EOF { @@ -194,7 +214,7 @@ loop: } // reset on ctrl+c - if err == liner.ErrPromptAborted { + if errors.Is(err, readline.ErrInterrupt) { continue } @@ -212,15 +232,20 @@ loop: fmt.Fprintln(r.output, err) } } - - line.AppendHistory(input) } exitPrompt: fmt.Fprintln(r.output) + // The exit-prompt confirmation ("y"/"n") is control input, not a query, so + // pause persistence while it is on screen. The loop: label resumes it. + if r.history != nil { + r.history.pause() + } + line.Prompt.Primary(func() string { return exitPromptMessage }) + for { - input, err := line.Prompt(exitPromptMessage) + input, err := line.Readline() // exit on ctrl+d if err == io.EOF { @@ -228,7 +253,7 @@ exitPrompt: } // reset on ctrl+c - if err == liner.ErrPromptAborted { + if errors.Is(err, readline.ErrInterrupt) { goto loop } @@ -247,7 +272,6 @@ exitPrompt: } exit: - r.saveHistory(line) return nil } @@ -379,7 +403,14 @@ func (r *REPL) SetOPAVersionReport(report [][2]string) { r.report = report } -func (r *REPL) complete(line string) []string { +// complete adapts completeCandidates to the readline completer signature. +// Only the text up to the cursor is used for prefix matching, matching the +// behaviour of the previous line-reader. +func (r *REPL) complete(line []rune, cursor int) readline.Completions { + return readline.CompleteValues(r.completeCandidates(string(line[:cursor]))...) +} + +func (r *REPL) completeCandidates(line string) []string { c := []string{} set := map[string]struct{}{} ctx := context.Background() @@ -1244,13 +1275,166 @@ func (r *REPL) getPrompt() string { return r.initPrompt } -func (r *REPL) loadHistory(prompt *liner.State) { - if f, err := os.Open(r.historyPath); err == nil { - _, _ = prompt.ReadHistory(f) // ignore error - f.Close() +func (r *REPL) loadHistory(line *readline.Shell) { + if r.historyPath == "" { + return } + h, err := newREPLHistory(r.historyPath) + if err != nil { + // Non-fatal: newREPLHistory still returns a usable (empty, writable) + // source, so the session continues without prior history. + fmt.Fprintln(r.stderrWriter(), "warning: failed to load REPL history:", err) + } + r.history = h + line.History.Add("repl", h) } +// replHistoryItem is one persisted history entry. The JSON shape matches the +// file format used by reeflective/readline so the two readers stay +// interchangeable. +type replHistoryItem struct { + DateTime time.Time `json:"datetime"` + Block string `json:"block"` +} + +// replHistory is a file-backed readline history source tailored to the REPL: +// +// - Legacy plain-text history files written by the previous line-reader +// (peterh/liner, one command per line) are migrated in place to the +// JSON-lines format on load, so prior history survives the upgrade instead +// of being silently dropped (see the #8882 review). +// - REPL control input is not persisted: the "exit" command is always +// skipped, and persistence is paused around the Ctrl+D exit prompt so its +// confirmation answer is not recorded. This matches the previous reader, +// which only appended a line once it had been evaluated as a query. +type replHistory struct { + path string + items []replHistoryItem + persist bool +} + +// newREPLHistory loads (migrating if necessary) the history file at path. A +// load error is returned but is not fatal: the returned source is always +// usable, falling back to empty in-memory history that still accepts writes. +func newREPLHistory(path string) (*replHistory, error) { + h := &replHistory{path: path, persist: true} + migrated, err := h.load() + if err != nil { + return h, err + } + if migrated { + // Rewrite once so legacy plain-text entries are stored in the new + // format; subsequent accepted lines are simply appended. + return h, h.flush() + } + return h, nil +} + +// load reads the history file into memory, reporting whether any legacy +// plain-text lines were encountered (and thus whether the file must be +// rewritten). A missing file is not an error. +func (h *replHistory) load() (migrated bool, err error) { + f, err := os.Open(h.path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + + var item replHistoryItem + if err := json.Unmarshal([]byte(line), &item); err == nil && item.Block != "" { + h.items = append(h.items, item) + continue + } + + // Not JSON: a legacy one-command-per-line entry. Preserve it and flag + // the file for rewriting into the new format. + if block := strings.TrimSpace(line); block != "" { + h.items = append(h.items, replHistoryItem{Block: block}) + migrated = true + } + } + + return migrated, scanner.Err() +} + +// flush rewrites the entire history file from the in-memory items. +func (h *replHistory) flush() error { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + for i := range h.items { + if err := enc.Encode(h.items[i]); err != nil { + return err + } + } + return os.WriteFile(h.path, buf.Bytes(), 0o600) +} + +// pause and resume toggle whether accepted lines are persisted; they keep the +// exit-prompt confirmation out of the history. +func (h *replHistory) pause() { h.persist = false } +func (h *replHistory) resume() { h.persist = true } + +// Write implements the readline history source interface. readline calls it for +// every accepted line, before the REPL inspects the returned input. +func (h *replHistory) Write(line string) (int, error) { + block := strings.TrimSpace(line) + if !h.persist || block == "" { + return len(h.items), nil + } + + // Never persist the exit command: it is control input, not a query. + if c := newCommand(block); c != nil && c.op == "exit" { + return len(h.items), nil + } + + // Skip consecutive duplicates, mirroring readline's own file source. + if n := len(h.items); n > 0 && h.items[n-1].Block == block { + return n, nil + } + + item := replHistoryItem{DateTime: time.Now(), Block: block} + h.items = append(h.items, item) + + if h.path == "" { + return len(h.items), nil + } + + data, err := json.Marshal(item) + if err != nil { + return len(h.items), err + } + + f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return len(h.items), err + } + defer f.Close() + + _, err = f.Write(append(data, '\n')) + return len(h.items), err +} + +// GetLine implements the readline history source interface. +func (h *replHistory) GetLine(pos int) (string, error) { + if pos < 0 || pos >= len(h.items) { + return "", fmt.Errorf("history index %d out of range", pos) + } + return h.items[pos].Block, nil +} + +// Len implements the readline history source interface. +func (h *replHistory) Len() int { return len(h.items) } + +// Dump implements the readline history source interface. +func (h *replHistory) Dump() any { return h.items } + func (r *REPL) loadModules(ctx context.Context, txn storage.Transaction) (map[string]*ast.Module, error) { modules := make(map[string]*ast.Module) @@ -1308,13 +1492,6 @@ func (r *REPL) printTypes(_ context.Context, typeEnv *ast.TypeEnv, body ast.Body } } -func (r *REPL) saveHistory(prompt *liner.State) { - if f, err := os.Create(r.historyPath); err == nil { - _, _ = prompt.WriteHistory(f) // ignore error - f.Close() - } -} - func (r *REPL) stderrWriter() io.Writer { if r.stderr != nil { return r.stderr diff --git a/v1/repl/repl_test.go b/v1/repl/repl_test.go index e06ffc3e1f..f18056e289 100644 --- a/v1/repl/repl_test.go +++ b/v1/repl/repl_test.go @@ -17,6 +17,8 @@ import ( "strings" "testing" + "github.com/reeflective/readline" + "github.com/open-policy-agent/opa/internal/presentation" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/storage" @@ -212,7 +214,7 @@ r = 3 if { true }`) } buf.Reset() - result := repl.complete("") + result := repl.completeCandidates("") expected := []string{ "data.a.b.c.p", "data.a.b.c.q", @@ -227,7 +229,7 @@ r = 3 if { true }`) t.Fatalf("Expected %v but got: %v", expected, result) } - result = repl.complete("data.a.b") + result = repl.completeCandidates("data.a.b") expected = []string{ "data.a.b.c.p", "data.a.b.c.q", @@ -241,7 +243,7 @@ r = 3 if { true }`) t.Fatalf("Expected %v but got: %v", expected, result) } - result = repl.complete("data.a.b.c.p[x]") + result = repl.completeCandidates("data.a.b.c.p[x]") expected = []string{} if !slices.Equal(result, expected) { @@ -255,7 +257,7 @@ r = 3 if { true }`) t.Fatalf("Unexpected error: %v", err) } - result = repl.complete("x") + result = repl.completeCandidates("x") expected = []string{"xyz"} if !slices.Equal(result, expected) { @@ -263,6 +265,152 @@ r = 3 if { true }`) } } +func TestREPLBracketedPasteTabNotCompleted(t *testing.T) { + feed := func(seq string) (line string, completerCalls int) { + store := newTestStore() + var buf bytes.Buffer + repl := newRepl(store, &buf) + // Seed a completion candidate so a tab has something to complete to. + if err := repl.OneShot(t.Context(), "import data.foo.bar as barbaz"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + shell := repl.newShell() + shell.Completer = func(l []rune, c int) readline.Completions { + completerCalls++ + return repl.complete(l, c) + } + + // The line-reader renders terminal escapes to os.Stdout; redirect it to + // keep test output clean. Safe because these tests do not run in parallel. + devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatalf("open devnull: %v", err) + } + defer devnull.Close() + origStdout := os.Stdout + os.Stdout = devnull + defer func() { os.Stdout = origStdout }() + + shell.Keys.Feed(false, []rune(seq)...) + got, err := shell.Readline() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return got, completerCalls + } + + const ( + bpStart = "\x1b[200~" + bpEnd = "\x1b[201~" + payload = "x = [\"a\",\tbarbaz]" + ) + + // Bracketed paste: the tab must survive and completion must not fire. + if got, calls := feed(bpStart + payload + bpEnd + "\r"); got != payload || calls != 0 { + t.Fatalf("bracketed paste corrupted (regression of #962): got %q (want %q), completer called %d time(s) (want 0)", got, payload, calls) + } + + // Sanity check: a raw tab (no paste markers) does trigger completion, which + // is the behaviour bracketed paste protects against. This guards the test + // itself from silently passing due to a mis-wired completer. + if _, calls := feed(payload + "\r"); calls == 0 { + t.Fatal("expected a raw tab to invoke the completer; test setup is not exercising completion") + } +} + +func TestREPLHistoryMigratesLegacyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "history") + + // A legacy peterh/liner history file: one command per line, plain text. + legacy := "a := 1\ndata.a.b.c\n" + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + + h, err := newREPLHistory(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Prior history must be preserved, in order, rather than silently dropped. + if got := []string{lineAt(t, h, 0), lineAt(t, h, 1)}; !reflect.DeepEqual(got, []string{"a := 1", "data.a.b.c"}) { + t.Fatalf("legacy history not preserved: got %v", got) + } + + // The file must have been rewritten in the JSON-lines format so a later + // launch reads it natively. + bs, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(strings.TrimSpace(string(bs)), "\n") { + var item replHistoryItem + if err := json.Unmarshal([]byte(line), &item); err != nil { + t.Fatalf("line %d not migrated to JSON: %q (%v)", i, line, err) + } + } + + // Reloading the migrated file yields the same entries (no double-counting). + h2, err := newREPLHistory(path) + if err != nil { + t.Fatalf("unexpected error reloading: %v", err) + } + if h2.Len() != 2 { + t.Fatalf("expected 2 entries after reload, got %d", h2.Len()) + } +} + +func TestREPLHistoryDoesNotPersistControlInput(t *testing.T) { + path := filepath.Join(t.TempDir(), "history") + h, err := newREPLHistory(path) + if err != nil { + t.Fatal(err) + } + + // "exit" is control input, never a query, so it must not be recorded. + if _, err := h.Write("exit"); err != nil { + t.Fatal(err) + } + if _, err := h.Write(" EXIT "); err != nil { // parsed case-insensitively + t.Fatal(err) + } + if h.Len() != 0 { + t.Fatalf("exit should not be persisted, got %d entries", h.Len()) + } + + // While paused (exit prompt on screen), confirmation answers are dropped. + h.pause() + if _, err := h.Write("y"); err != nil { + t.Fatal(err) + } + if h.Len() != 0 { + t.Fatalf("paused history should not persist, got %d entries", h.Len()) + } + + // Resuming restores normal persistence, and "y" is a legitimate query. + h.resume() + if _, err := h.Write("y"); err != nil { + t.Fatal(err) + } + // Consecutive duplicates collapse, matching readline's file source. + if _, err := h.Write("y"); err != nil { + t.Fatal(err) + } + if h.Len() != 1 || lineAt(t, h, 0) != "y" { + t.Fatalf("expected single \"y\" entry, got %d entries", h.Len()) + } +} + +func lineAt(t *testing.T, h *replHistory, pos int) string { + t.Helper() + line, err := h.GetLine(pos) + if err != nil { + t.Fatalf("GetLine(%d): %v", pos, err) + } + return line +} + func TestDump(t *testing.T) { ctx := t.Context() input := `{"a": [1,2,3,4]}`