feat(sedmat): add paragraph addressing and structure introspection

Add paragraph-number addressing (5d, 3s/.*/text/, $a/text/) and
`docs structure` / `docs cat -N` commands for paragraph-level
document manipulation.

New commands:
- `docs structure` — numbered paragraph list with types (text + JSON)
- `docs cat -N` — cat with [N] paragraph prefixes

Address syntax for `docs sed`:
- Nd (delete paragraph N), N,Md (range delete), $d (last)
- Ns/pat/repl/ (substitute within paragraph N)
- Na/text/ (append after N), Ni/text/ (insert before N)
- --tab flag for multi-tab document support

Testing:
- 24 new unit tests covering parseAddress (13 cases),
  parseFullExpr_Addressed (10 cases), resolveAddress (6 cases),
  and buildParagraphMap (8 cases). All pass.
- Manual testing against live Google Docs verified: structure,
  cat -N, addressed substitute, delete, append, insert, dollar
  addressing, and range delete.
- Bug found and fixed during manual testing: addressed a/text/
  and i/text/ were parsed by parseAICommand (expects a/pat/text/)
  which put text in the pattern field instead of replacement,
  producing empty paragraphs. Added parseAddressedAICommand for
  the single-field addressed form.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
daniel 2026-03-01 14:15:08 -05:00
parent cb16e4f42f
commit 3e85dcf8ba
10 changed files with 1376 additions and 5 deletions

View File

@ -309,6 +309,48 @@ s/![logo]/!(https:\/\/new-logo.png)/ # match by alt text
---
## Paragraph Addressing
Target specific paragraphs by number using address prefixes. Use `gog docs structure` to see paragraph numbers.
```bash
# Introspection — see paragraph numbers, types, and content
gog docs structure <DOC_ID> # show numbered structure
gog docs cat <DOC_ID> -N # cat with [N] prefixes
# Delete by paragraph number
gog docs sed <DOC_ID> '5d' # delete paragraph 5
gog docs sed <DOC_ID> '3,7d' # delete paragraphs 3-7
gog docs sed <DOC_ID> '$d' # delete last paragraph
# Substitute within addressed paragraphs
gog docs sed <DOC_ID> '5s/.*/New text/' # replace all text in paragraph 5
gog docs sed <DOC_ID> '3,7s/old/new/g' # replace within paragraphs 3-7
# Insert/Append around addressed paragraphs
gog docs sed <DOC_ID> '5a/New line/' # append after paragraph 5
gog docs sed <DOC_ID> '3i/Before text/' # insert before paragraph 3
gog docs sed <DOC_ID> '$a/Last line/' # append after last paragraph
```
### Address Syntax
| Address | Meaning |
|---------|---------|
| `N` | Paragraph number N (1-based) |
| `N,M` | Range from paragraph N to M |
| `$` | Last paragraph |
| `N,$` | From paragraph N to end |
### Multi-Tab Support
```bash
gog docs structure <DOC_ID> --tab "Sheet1"
gog docs sed <DOC_ID> --tab "Sheet1" '3d'
```
---
## Batch Mode
Create a `.sed` file with one expression per line. Comments start with `#`.

View File

@ -38,6 +38,7 @@ type DocsCmd struct {
Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"`
Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"`
Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"`
Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"`
}
type DocsExportCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
@ -276,6 +277,7 @@ type DocsCatCmd struct {
Tab string `name:"tab" help:"Tab title or ID to read (omit for default behavior)"`
AllTabs bool `name:"all-tabs" help:"Show all tabs with headers"`
Raw bool `name:"raw" help:"Output the raw Google Docs API JSON response without modifications"`
Numbered bool `name:"numbered" short:"N" help:"Prefix each paragraph with its number"`
}
func (c *DocsCatCmd) Run(ctx context.Context, flags *RootFlags) error {
@ -340,6 +342,10 @@ func (c *DocsCatCmd) Run(ctx context.Context, flags *RootFlags) error {
return errors.New("doc not found")
}
if c.Numbered {
return c.printNumbered(ctx, doc, "")
}
text := docsPlainText(doc, c.MaxBytes)
if outfmt.IsJSON(ctx) {
@ -545,6 +551,9 @@ func (c *DocsCatCmd) runWithTabs(ctx context.Context, svc *docs.Service, id stri
if tab == nil {
return fmt.Errorf("tab not found: %s", c.Tab)
}
if c.Numbered {
return c.printNumbered(ctx, doc, c.Tab)
}
text := tabPlainText(tab, c.MaxBytes)
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
@ -937,6 +946,99 @@ func (c *DocsClearCmd) Run(ctx context.Context, flags *RootFlags) error {
return sedCmd.Run(ctx, flags)
}
// --- Structure / Numbered commands ---
// DocsStructureCmd displays document structure with numbered paragraphs.
type DocsStructureCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Tab string `name:"tab" help:"Tab title or ID (omit for default)"`
}
func (c *DocsStructureCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
id := strings.TrimSpace(c.DocID)
if id == "" {
return usage("empty docId")
}
svc, err := newDocsService(ctx, account)
if err != nil {
return err
}
getCall := svc.Documents.Get(id)
if c.Tab != "" {
getCall = getCall.IncludeTabsContent(true)
}
doc, err := getCall.Context(ctx).Do()
if err != nil {
if isDocsNotFound(err) {
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
}
return err
}
if doc == nil {
return errors.New("doc not found")
}
pm, err := buildParagraphMap(doc, c.Tab)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, pm)
}
u.Out().Printf(" # TYPE CONTENT")
for _, p := range pm.Paragraphs {
prefix := ""
if p.IsBullet {
prefix = strings.Repeat(" ", p.NestLevel) + "* "
}
text := p.Text
if len(text) > 60 {
text = text[:57] + "..."
}
if p.ElemType == "table" {
text = fmt.Sprintf("[table %dx%d] %s", p.TableRows, p.TableCols, text)
}
u.Out().Printf("%2d %-18s %s%s", p.Num, p.Type, prefix, text)
}
return nil
}
// printNumbered prints document content with [N] paragraph number prefixes.
func (c *DocsCatCmd) printNumbered(ctx context.Context, doc *docs.Document, tabID string) error {
pm, err := buildParagraphMap(doc, tabID)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, pm)
}
for _, p := range pm.Paragraphs {
text := p.Text
if p.ElemType == "table" {
text = fmt.Sprintf("[table %dx%d] %s", p.TableRows, p.TableCols, text)
}
if _, err := fmt.Fprintf(os.Stdout, "[%d] %s\n", p.Num, text); err != nil {
return err
}
}
return nil
}
type DocsFindReplaceCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Find string `arg:"" name:"find" help:"Text to find"`

View File

@ -0,0 +1,209 @@
package cmd
import (
"context"
"errors"
"fmt"
"strings"
"google.golang.org/api/docs/v1"
)
// docParagraph represents a single numbered element in a Google Doc's structure.
type docParagraph struct {
Num int `json:"num"`
StartIndex int64 `json:"startIndex"`
EndIndex int64 `json:"endIndex"`
Type string `json:"type"`
IsBullet bool `json:"bullet"`
NestLevel int `json:"nestLevel,omitempty"`
Text string `json:"text"`
ElemType string `json:"elemType"` // "paragraph", "table", "toc", "sectionBreak"
TableRows int `json:"tableRows,omitempty"`
TableCols int `json:"tableCols,omitempty"`
}
// paragraphMap holds the structured view of a Google Doc's content.
type paragraphMap struct {
DocumentID string `json:"documentId"`
RevisionID string `json:"revisionId"`
TabID string `json:"tab,omitempty"`
Paragraphs []docParagraph `json:"paragraphs"`
}
// buildParagraphMap traverses the document body and numbers each paragraph
// and table sequentially (1-based). The initial SectionBreak at index 0 is
// skipped as it is not user-editable.
func buildParagraphMap(doc *docs.Document, tabID string) (*paragraphMap, error) {
if doc == nil {
return nil, fmt.Errorf("nil document")
}
var content []*docs.StructuralElement
var revisionID string
if tabID != "" && len(doc.Tabs) > 0 {
tabs := flattenTabs(doc.Tabs)
tab := findTab(tabs, tabID)
if tab == nil {
return nil, fmt.Errorf("tab not found: %s", tabID)
}
if tab.DocumentTab == nil || tab.DocumentTab.Body == nil {
return nil, fmt.Errorf("tab has no content: %s", tabID)
}
content = tab.DocumentTab.Body.Content
if tab.TabProperties != nil {
tabID = tab.TabProperties.TabId
}
} else {
if doc.Body == nil {
return nil, fmt.Errorf("document has no body")
}
content = doc.Body.Content
}
revisionID = doc.RevisionId
pm := &paragraphMap{
DocumentID: doc.DocumentId,
RevisionID: revisionID,
TabID: tabID,
}
num := 0
for _, el := range content {
if el == nil {
continue
}
switch {
case el.SectionBreak != nil:
// Skip section breaks — not user-editable.
continue
case el.Paragraph != nil:
num++
dp := docParagraph{
Num: num,
StartIndex: el.StartIndex,
EndIndex: el.EndIndex,
ElemType: "paragraph",
Text: paragraphText(el.Paragraph),
}
// Extract named style type.
if el.Paragraph.ParagraphStyle != nil {
dp.Type = el.Paragraph.ParagraphStyle.NamedStyleType
}
if dp.Type == "" {
dp.Type = "NORMAL_TEXT"
}
// Extract bullet info.
if el.Paragraph.Bullet != nil {
dp.IsBullet = true
dp.NestLevel = int(el.Paragraph.Bullet.NestingLevel)
}
pm.Paragraphs = append(pm.Paragraphs, dp)
case el.Table != nil:
num++
rows := len(el.Table.TableRows)
cols := 0
if rows > 0 && len(el.Table.TableRows[0].TableCells) > 0 {
cols = len(el.Table.TableRows[0].TableCells)
}
dp := docParagraph{
Num: num,
StartIndex: el.StartIndex,
EndIndex: el.EndIndex,
Type: "TABLE",
ElemType: "table",
Text: tablePreviewText(el.Table),
TableRows: rows,
TableCols: cols,
}
pm.Paragraphs = append(pm.Paragraphs, dp)
case el.TableOfContents != nil:
num++
dp := docParagraph{
Num: num,
StartIndex: el.StartIndex,
EndIndex: el.EndIndex,
Type: "TABLE_OF_CONTENTS",
ElemType: "toc",
Text: "[table of contents]",
}
pm.Paragraphs = append(pm.Paragraphs, dp)
}
}
return pm, nil
}
// paragraphText extracts the plain text from a Paragraph element.
func paragraphText(p *docs.Paragraph) string {
if p == nil {
return ""
}
var sb strings.Builder
for _, elem := range p.Elements {
if elem.TextRun != nil {
sb.WriteString(elem.TextRun.Content)
}
}
// Trim the trailing newline that Google Docs adds to every paragraph.
return strings.TrimRight(sb.String(), "\n")
}
// get returns the paragraph at the given 1-based number.
func (pm *paragraphMap) get(num int) (*docParagraph, error) {
if num < 1 || num > len(pm.Paragraphs) {
return nil, fmt.Errorf("paragraph %d out of range (document has %d paragraphs)", num, len(pm.Paragraphs))
}
return &pm.Paragraphs[num-1], nil
}
// fetchAndBuildMap fetches the document and builds a paragraph map.
func fetchAndBuildMap(ctx context.Context, svc *docs.Service, docID, tabID string) (*paragraphMap, error) {
getCall := svc.Documents.Get(docID)
if tabID != "" {
getCall = getCall.IncludeTabsContent(true)
}
doc, err := getCall.Context(ctx).Do()
if err != nil {
if isDocsNotFound(err) {
return nil, fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID)
}
return nil, err
}
if doc == nil {
return nil, errors.New("doc not found")
}
return buildParagraphMap(doc, tabID)
}
// tablePreviewText returns a short preview of the table content.
func tablePreviewText(t *docs.Table) string {
if t == nil || len(t.TableRows) == 0 {
return "[empty table]"
}
// Show first row cells as a preview.
var cells []string
for _, cell := range t.TableRows[0].TableCells {
var text strings.Builder
for _, el := range cell.Content {
if el.Paragraph != nil {
text.WriteString(paragraphText(el.Paragraph))
}
}
cells = append(cells, strings.TrimSpace(text.String()))
}
preview := strings.Join(cells, " | ")
if len(preview) > 60 {
preview = preview[:57] + "..."
}
return preview
}

View File

@ -0,0 +1,404 @@
package cmd
import (
"testing"
"google.golang.org/api/docs/v1"
)
// testDoc returns a realistic Google Doc with multiple paragraph types.
func testDoc() *docs.Document {
return &docs.Document{
DocumentId: "test-doc-1",
RevisionId: "rev-abc",
Body: &docs.Body{
Content: []*docs.StructuralElement{
{
SectionBreak: &docs.SectionBreak{},
StartIndex: 0,
EndIndex: 0,
},
{
StartIndex: 0,
EndIndex: 27,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "TITLE"},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Meeting Notes 2026-02-23\n"}},
},
},
},
{
StartIndex: 27,
EndIndex: 38,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1"},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Attendees\n"}},
},
},
},
{
StartIndex: 38,
EndIndex: 57,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Alice, Bob, Carol\n"}},
},
},
},
{
StartIndex: 57,
EndIndex: 68,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1"},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Discussion\n"}},
},
},
},
{
StartIndex: 68,
EndIndex: 94,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
Bullet: &docs.Bullet{NestingLevel: 0},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Very fun! Delightful to use\n"}},
},
},
},
{
StartIndex: 94,
EndIndex: 115,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
Bullet: &docs.Bullet{NestingLevel: 0},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Dev sandbox is cool\n"}},
},
},
},
},
},
}
}
func TestBuildParagraphMap_Basic(t *testing.T) {
doc := testDoc()
pm, err := buildParagraphMap(doc, "")
if err != nil {
t.Fatalf("buildParagraphMap: %v", err)
}
if pm.DocumentID != "test-doc-1" {
t.Fatalf("unexpected documentId: %s", pm.DocumentID)
}
if pm.RevisionID != "rev-abc" {
t.Fatalf("unexpected revisionId: %s", pm.RevisionID)
}
// Should have 6 paragraphs (section break skipped).
if len(pm.Paragraphs) != 6 {
t.Fatalf("expected 6 paragraphs, got %d", len(pm.Paragraphs))
}
// Check first paragraph (title).
p1 := pm.Paragraphs[0]
if p1.Num != 1 {
t.Errorf("p1.Num = %d, want 1", p1.Num)
}
if p1.Type != "TITLE" {
t.Errorf("p1.Type = %q, want TITLE", p1.Type)
}
if p1.Text != "Meeting Notes 2026-02-23" {
t.Errorf("p1.Text = %q, want 'Meeting Notes 2026-02-23'", p1.Text)
}
if p1.IsBullet {
t.Error("p1 should not be a bullet")
}
if p1.ElemType != "paragraph" {
t.Errorf("p1.ElemType = %q, want paragraph", p1.ElemType)
}
// Check heading.
p2 := pm.Paragraphs[1]
if p2.Type != "HEADING_1" {
t.Errorf("p2.Type = %q, want HEADING_1", p2.Type)
}
// Check bullet paragraph.
p5 := pm.Paragraphs[4]
if !p5.IsBullet {
t.Error("p5 should be a bullet")
}
if p5.NestLevel != 0 {
t.Errorf("p5.NestLevel = %d, want 0", p5.NestLevel)
}
if p5.Text != "Very fun! Delightful to use" {
t.Errorf("p5.Text = %q", p5.Text)
}
// Check indices.
if p1.StartIndex != 0 || p1.EndIndex != 27 {
t.Errorf("p1 indices: start=%d end=%d, want 0-27", p1.StartIndex, p1.EndIndex)
}
}
func TestBuildParagraphMap_WithTable(t *testing.T) {
doc := &docs.Document{
DocumentId: "doc-table",
Body: &docs.Body{
Content: []*docs.StructuralElement{
{
SectionBreak: &docs.SectionBreak{},
StartIndex: 0,
EndIndex: 0,
},
{
StartIndex: 0,
EndIndex: 7,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Hello\n"}},
},
},
},
{
StartIndex: 7,
EndIndex: 30,
Table: &docs.Table{
TableRows: []*docs.TableRow{
{
TableCells: []*docs.TableCell{
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "A"}}}}}}},
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "B"}}}}}}},
},
},
{
TableCells: []*docs.TableCell{
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "C"}}}}}}},
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "D"}}}}}}},
},
},
},
},
},
},
},
}
pm, err := buildParagraphMap(doc, "")
if err != nil {
t.Fatalf("buildParagraphMap: %v", err)
}
if len(pm.Paragraphs) != 2 {
t.Fatalf("expected 2 paragraphs, got %d", len(pm.Paragraphs))
}
table := pm.Paragraphs[1]
if table.ElemType != "table" {
t.Fatalf("expected table, got %s", table.ElemType)
}
if table.TableRows != 2 || table.TableCols != 2 {
t.Fatalf("table dimensions: %dx%d, want 2x2", table.TableRows, table.TableCols)
}
if table.Type != "TABLE" {
t.Errorf("table.Type = %q, want TABLE", table.Type)
}
if table.Text != "A | B" {
t.Errorf("table preview = %q, want 'A | B'", table.Text)
}
}
func TestBuildParagraphMap_NilDoc(t *testing.T) {
_, err := buildParagraphMap(nil, "")
if err == nil {
t.Fatal("expected error for nil doc")
}
}
func TestBuildParagraphMap_NoBody(t *testing.T) {
doc := &docs.Document{DocumentId: "no-body"}
_, err := buildParagraphMap(doc, "")
if err == nil {
t.Fatal("expected error for doc with no body")
}
}
func TestBuildParagraphMap_DefaultStyleType(t *testing.T) {
doc := &docs.Document{
DocumentId: "doc-no-style",
Body: &docs.Body{
Content: []*docs.StructuralElement{
{
StartIndex: 0,
EndIndex: 6,
Paragraph: &docs.Paragraph{
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Hello\n"}},
},
},
},
},
},
}
pm, err := buildParagraphMap(doc, "")
if err != nil {
t.Fatalf("buildParagraphMap: %v", err)
}
if len(pm.Paragraphs) != 1 {
t.Fatalf("expected 1 paragraph, got %d", len(pm.Paragraphs))
}
if pm.Paragraphs[0].Type != "NORMAL_TEXT" {
t.Errorf("expected NORMAL_TEXT default, got %q", pm.Paragraphs[0].Type)
}
}
func TestParagraphMap_Get(t *testing.T) {
pm := &paragraphMap{
Paragraphs: []docParagraph{
{Num: 1, Text: "first"},
{Num: 2, Text: "second"},
},
}
p, err := pm.get(1)
if err != nil {
t.Fatalf("get(1): %v", err)
}
if p.Text != "first" {
t.Fatalf("get(1).Text = %q, want first", p.Text)
}
_, err = pm.get(0)
if err == nil {
t.Fatal("get(0) should fail")
}
_, err = pm.get(3)
if err == nil {
t.Fatal("get(3) should fail")
}
}
func TestBuildParagraphMap_WithNestedBullets(t *testing.T) {
doc := &docs.Document{
DocumentId: "doc-nested",
Body: &docs.Body{
Content: []*docs.StructuralElement{
{
StartIndex: 0,
EndIndex: 8,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
Bullet: &docs.Bullet{NestingLevel: 0},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Top\n"}},
},
},
},
{
StartIndex: 8,
EndIndex: 18,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
Bullet: &docs.Bullet{NestingLevel: 1},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Nested\n"}},
},
},
},
},
},
}
pm, err := buildParagraphMap(doc, "")
if err != nil {
t.Fatalf("buildParagraphMap: %v", err)
}
if len(pm.Paragraphs) != 2 {
t.Fatalf("expected 2 paragraphs, got %d", len(pm.Paragraphs))
}
if pm.Paragraphs[0].NestLevel != 0 {
t.Errorf("p1 nest level = %d, want 0", pm.Paragraphs[0].NestLevel)
}
if pm.Paragraphs[1].NestLevel != 1 {
t.Errorf("p2 nest level = %d, want 1", pm.Paragraphs[1].NestLevel)
}
}
func TestBuildParagraphMap_WithTab(t *testing.T) {
doc := &docs.Document{
DocumentId: "doc-tabs",
RevisionId: "rev-tab",
Tabs: []*docs.Tab{
{
TabProperties: &docs.TabProperties{
TabId: "t.0",
Title: "Main",
},
DocumentTab: &docs.DocumentTab{
Body: &docs.Body{
Content: []*docs.StructuralElement{
{
StartIndex: 0,
EndIndex: 10,
Paragraph: &docs.Paragraph{
ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "NORMAL_TEXT"},
Elements: []*docs.ParagraphElement{
{TextRun: &docs.TextRun{Content: "Tab text\n"}},
},
},
},
},
},
},
},
},
}
pm, err := buildParagraphMap(doc, "Main")
if err != nil {
t.Fatalf("buildParagraphMap with tab: %v", err)
}
if pm.TabID != "t.0" {
t.Errorf("tabID = %q, want t.0", pm.TabID)
}
if len(pm.Paragraphs) != 1 {
t.Fatalf("expected 1 paragraph, got %d", len(pm.Paragraphs))
}
if pm.Paragraphs[0].Text != "Tab text" {
t.Errorf("text = %q, want 'Tab text'", pm.Paragraphs[0].Text)
}
}
func TestBuildParagraphMap_TabNotFound(t *testing.T) {
doc := &docs.Document{
DocumentId: "doc-tabs",
Tabs: []*docs.Tab{
{
TabProperties: &docs.TabProperties{
TabId: "t.0",
Title: "Main",
},
DocumentTab: &docs.DocumentTab{
Body: &docs.Body{},
},
},
},
}
_, err := buildParagraphMap(doc, "Nonexistent")
if err == nil {
t.Fatal("expected tab not found error")
}
}

View File

@ -20,6 +20,7 @@ type DocsSedCmd struct {
Expression string `arg:"" optional:"" name:"expression" help:"sed expression: s/pattern/replacement/flags"`
Expressions []string `short:"e" help:"Additional sed expressions (repeatable)"`
File string `short:"f" help:"Read sed expressions from file (one per line, # comments)"`
Tab string `name:"tab" help:"Tab title or ID for paragraph addressing"`
}
// parseExpressionLines splits data into trimmed non-empty, non-comment lines.
@ -75,6 +76,14 @@ func (c *DocsSedCmd) collectExpressions() ([]string, error) {
return exprs, nil
}
// sedAddress represents a paragraph-number address prefix on a sed expression.
// Addresses target specific paragraphs by number (1-based), or $ for last.
type sedAddress struct {
Start int // 1-based paragraph number, -1 = last ($)
End int // 0 = same as Start (single paragraph), -1 = last ($)
HasRange bool // true if comma-separated range was given
}
type sedExpr struct {
pattern string
replacement string // escaped for Go's regexp.ReplaceAllString ($$ = literal $, ${N} = backref)
@ -85,6 +94,7 @@ type sedExpr struct {
command byte // 0 for s//, 'd' for delete, 'a' for append, 'i' for insert, 'y' for transliterate
brace *braceExpr // optional brace expression for SEDMAT v3.5 syntax
braceSpans []*braceSpan // positioned brace spans for inline scoping
addr *sedAddress // optional paragraph address prefix (e.g., 5, 3,7, $)
}
type indexedExpr struct {
@ -252,6 +262,23 @@ func (c *DocsSedCmd) runPositionalInsert(ctx context.Context, u *ui.UI, account,
// runSingle executes a single sed expression, routing to the appropriate handler
// based on the expression type (command, table, positional, cell, image, native, or manual).
func (c *DocsSedCmd) runSingle(ctx context.Context, u *ui.UI, account, id string, expr sedExpr) error {
// Handle addressed expressions (paragraph-number targeting)
if expr.addr != nil {
switch expr.command {
case 'd':
return c.runAddressedDelete(ctx, u, account, id, c.Tab, expr)
case 'a':
return c.runAddressedAppend(ctx, u, account, id, c.Tab, expr)
case 'i':
return c.runAddressedInsert(ctx, u, account, id, c.Tab, expr)
case 0:
// s// substitution scoped to addressed paragraphs
return c.runAddressedSubstitute(ctx, u, account, id, c.Tab, expr)
default:
return fmt.Errorf("addressed %c command not supported", expr.command)
}
}
// Handle non-substitution commands
switch expr.command {
case 'd':
@ -327,9 +354,13 @@ func (c *DocsSedCmd) runBatch(ctx context.Context, u *ui.UI, account, id string,
// reliably fetch images when mixed with other batch operations.
var imageExprs []indexedExpr
var addressedExprs []indexedExpr
for i, expr := range exprs {
ie := indexedExpr{i, expr}
switch classifyExprForBatch(expr) {
case exprCatAddressed:
addressedExprs = append(addressedExprs, ie)
case exprCatPositional:
positionalExprs = append(positionalExprs, ie)
case exprCatImage:
@ -365,6 +396,14 @@ func (c *DocsSedCmd) runBatch(ctx context.Context, u *ui.UI, account, id string,
}
}
// Run addressed expressions sequentially (each changes doc state via paragraph map)
for _, ie := range addressedExprs {
if singleErr := c.runSingle(ctx, u, account, id, ie.expr); singleErr != nil {
return fmt.Errorf("expression %d: %w", ie.index+1, singleErr)
}
totalReplaced++
}
// Batch all native expressions into one API call
if len(nativeExprs) > 0 {
var requests []*docs.Request
@ -484,10 +523,15 @@ const (
exprCatImagePattern // image pattern in search (!(n), ![re])
exprCatNative // plain text replace via native API
exprCatManual // requires manual formatting path
exprCatAddressed // paragraph-addressed — sequential, changes doc state
)
// classifyExprForBatch determines how an expression should be processed in batch mode.
func classifyExprForBatch(expr sedExpr) exprCategory {
// Addressed expressions must run sequentially — they change document state
if expr.addr != nil {
return exprCatAddressed
}
if expr.command == 0 && expr.cellRef == nil && expr.tableRef == 0 &&
(expr.pattern == "^$" || expr.pattern == "^" || expr.pattern == "$") {
return exprCatPositional

View File

@ -220,3 +220,269 @@ func extractParagraphText(p *docs.Paragraph) string {
}
return strings.TrimRight(sb.String(), "\n")
}
// --- Addressed command implementations ---
// resolveAddress converts a sedAddress into a slice of target paragraphs from the map.
func resolveAddress(addr *sedAddress, pm *paragraphMap) ([]docParagraph, error) {
if addr == nil {
return nil, fmt.Errorf("nil address")
}
if len(pm.Paragraphs) == 0 {
return nil, fmt.Errorf("document has no paragraphs")
}
last := len(pm.Paragraphs)
start := addr.Start
if start == -1 {
start = last
}
if start < 1 || start > last {
return nil, fmt.Errorf("address %d out of range (document has %d paragraphs)", start, last)
}
if !addr.HasRange {
return []docParagraph{pm.Paragraphs[start-1]}, nil
}
end := addr.End
if end == 0 {
end = start
}
if end == -1 {
end = last
}
if end < 1 || end > last {
return nil, fmt.Errorf("address end %d out of range (document has %d paragraphs)", end, last)
}
return pm.Paragraphs[start-1 : end], nil
}
// runAddressedDelete deletes paragraphs by address (number or range).
func (c *DocsSedCmd) runAddressedDelete(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return err
}
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
if err != nil {
return err
}
targets, err := resolveAddress(expr.addr, pm)
if err != nil {
return err
}
// Build delete requests in reverse order to preserve indices
var requests []*docs.Request
for i := len(targets) - 1; i >= 0; i-- {
para := targets[i]
startIndex := para.StartIndex
endIndex := para.EndIndex
isLast := para.Num == len(pm.Paragraphs)
if isLast && para.Num > 1 {
// Last paragraph: delete from end of previous paragraph to our end-1
prev := pm.Paragraphs[para.Num-2]
startIndex = prev.EndIndex - 1
endIndex = para.EndIndex - 1
} else if isLast && para.Num == 1 {
// Only paragraph: just clear the text
if para.StartIndex >= para.EndIndex-1 {
continue // empty paragraph, skip
}
endIndex = para.EndIndex - 1
}
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: startIndex,
EndIndex: endIndex,
TabId: pm.TabID,
},
},
})
}
if len(requests) == 0 {
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: "0"})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (addressed delete): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "deleted", Value: fmt.Sprintf("%d paragraphs", len(targets))})
}
// runAddressedAppend inserts text after the addressed paragraph(s).
func (c *DocsSedCmd) runAddressedAppend(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return err
}
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
if err != nil {
return err
}
targets, err := resolveAddress(expr.addr, pm)
if err != nil {
return err
}
insertText := strings.ReplaceAll(expr.replacement, "\\n", "\n")
if !strings.HasSuffix(insertText, "\n") {
insertText = "\n" + insertText
} else {
insertText = "\n" + insertText[:len(insertText)-1]
}
// Insert in reverse order to preserve indices
var requests []*docs.Request
for i := len(targets) - 1; i >= 0; i-- {
para := targets[i]
// Insert before the trailing \n of the paragraph
idx := para.EndIndex - 1
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: idx, TabId: pm.TabID},
Text: insertText,
},
})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (addressed append): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "appended", Value: fmt.Sprintf("%d paragraphs", len(targets))})
}
// runAddressedInsert inserts text before the addressed paragraph(s).
func (c *DocsSedCmd) runAddressedInsert(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return err
}
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
if err != nil {
return err
}
targets, err := resolveAddress(expr.addr, pm)
if err != nil {
return err
}
insertText := strings.ReplaceAll(expr.replacement, "\\n", "\n")
if !strings.HasSuffix(insertText, "\n") {
insertText += "\n"
}
// Insert in reverse order to preserve indices
var requests []*docs.Request
for i := len(targets) - 1; i >= 0; i-- {
para := targets[i]
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: para.StartIndex, TabId: pm.TabID},
Text: insertText,
},
})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (addressed insert): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "inserted", Value: fmt.Sprintf("%d paragraphs", len(targets))})
}
// runAddressedSubstitute applies a substitution only within the addressed paragraph(s).
func (c *DocsSedCmd) runAddressedSubstitute(ctx context.Context, u *ui.UI, account, id, tabID string, expr sedExpr) error {
docsSvc, err := newDocsService(ctx, account)
if err != nil {
return err
}
pm, err := fetchAndBuildMap(ctx, docsSvc, id, tabID)
if err != nil {
return err
}
targets, err := resolveAddress(expr.addr, pm)
if err != nil {
return err
}
re, compileErr := expr.compilePattern()
if compileErr != nil {
return fmt.Errorf("compile pattern: %w", compileErr)
}
// For each target paragraph, find matches and apply substitutions.
// Work in reverse order to preserve indices.
var requests []*docs.Request
replaced := 0
for i := len(targets) - 1; i >= 0; i-- {
para := targets[i]
text := para.Text
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
continue
}
if !expr.global {
matches = matches[:1]
}
// Process matches in reverse order within this paragraph
for j := len(matches) - 1; j >= 0; j-- {
m := matches[j]
matchText := text[m[0]:m[1]]
replText := re.ReplaceAllString(matchText, expr.replacement)
// Unescape Go regex $$ to literal $
replText = literalReplacement(replText)
absStart := para.StartIndex + int64(m[0])
absEnd := para.StartIndex + int64(m[1])
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Range: &docs.Range{
StartIndex: absStart,
EndIndex: absEnd,
TabId: pm.TabID,
},
},
})
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Location: &docs.Location{Index: absStart, TabId: pm.TabID},
Text: replText,
},
})
replaced++
}
}
if len(requests) == 0 {
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "replaced", Value: 0})
}
if _, err := batchUpdate(ctx, docsSvc, id, requests); err != nil {
return fmt.Errorf("batch update (addressed substitute): %w", err)
}
return sedOutputOK(ctx, u, id, sedOutputKV{Key: "replaced", Value: replaced})
}

View File

@ -514,3 +514,139 @@ func TestParseHexColor(t *testing.T) {
assert.InDelta(t, 1.0, g, 0.01)
assert.InDelta(t, 0.0, b, 0.01)
}
// --- Tests for paragraph addressing ---
func TestParseAddress(t *testing.T) {
tests := []struct {
name string
input string
wantAddr *sedAddress
wantRest string
wantErr bool
}{
{"single number", "5d", &sedAddress{Start: 5}, "d", false},
{"single dollar", "$d", &sedAddress{Start: -1}, "d", false},
{"range", "3,7d", &sedAddress{Start: 3, End: 7, HasRange: true}, "d", false},
{"range with dollar", "3,$d", &sedAddress{Start: 3, End: -1, HasRange: true}, "d", false},
{"no address s-cmd", "s/foo/bar/", nil, "s/foo/bar/", false},
{"no address d-cmd", "d/foo/", nil, "d/foo/", false},
{"bare number", "5", &sedAddress{Start: 5}, "", false},
{"address with s-cmd", "5s/foo/bar/", &sedAddress{Start: 5}, "s/foo/bar/", false},
{"address with a-cmd", "5a/text/", &sedAddress{Start: 5}, "a/text/", false},
{"dollar with s-cmd", "$s/.*/new/", &sedAddress{Start: -1}, "s/.*/new/", false},
{"range end < start", "7,3d", nil, "", true},
{"range missing end", "3,", nil, "", true},
{"empty", "", nil, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addr, rest, err := parseAddress(tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tt.wantAddr == nil {
assert.Nil(t, addr)
} else {
require.NotNil(t, addr)
assert.Equal(t, tt.wantAddr.Start, addr.Start)
assert.Equal(t, tt.wantAddr.End, addr.End)
assert.Equal(t, tt.wantAddr.HasRange, addr.HasRange)
}
assert.Equal(t, tt.wantRest, rest)
})
}
}
func TestParseFullExpr_Addressed(t *testing.T) {
tests := []struct {
name string
input string
wantAddr *sedAddress
wantCmd byte
wantPat string
wantRepl string
wantErr bool
}{
{"bare delete", "5d", &sedAddress{Start: 5}, 'd', "", "", false},
{"range delete", "3,7d", &sedAddress{Start: 3, End: 7, HasRange: true}, 'd', "", "", false},
{"dollar delete", "$d", &sedAddress{Start: -1}, 'd', "", "", false},
{"addressed s-cmd", "5s/foo/bar/", &sedAddress{Start: 5}, 0, "foo", "bar", false},
{"range s-cmd", "3,7s/old/new/g", &sedAddress{Start: 3, End: 7, HasRange: true}, 0, "old", "new", false},
{"addressed append", "5a/new text/", &sedAddress{Start: 5}, 'a', "", "new text", false},
{"addressed insert", "3i/before text/", &sedAddress{Start: 3}, 'i', "", "before text", false},
{"dollar append", "$a/last line/", &sedAddress{Start: -1}, 'a', "", "last line", false},
{"addressed d-with-pattern", "5d/foo/", &sedAddress{Start: 5}, 'd', "foo", "", false},
{"bare number error", "5", nil, 0, "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expr, err := parseFullExpr(tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tt.wantAddr == nil {
assert.Nil(t, expr.addr)
} else {
require.NotNil(t, expr.addr)
assert.Equal(t, tt.wantAddr.Start, expr.addr.Start)
assert.Equal(t, tt.wantAddr.End, expr.addr.End)
assert.Equal(t, tt.wantAddr.HasRange, expr.addr.HasRange)
}
assert.Equal(t, tt.wantCmd, expr.command)
if tt.wantPat != "" {
assert.Equal(t, tt.wantPat, expr.pattern)
}
})
}
}
func TestResolveAddress(t *testing.T) {
pm := &paragraphMap{
Paragraphs: []docParagraph{
{Num: 1, Text: "first", StartIndex: 0, EndIndex: 6},
{Num: 2, Text: "second", StartIndex: 6, EndIndex: 13},
{Num: 3, Text: "third", StartIndex: 13, EndIndex: 19},
{Num: 4, Text: "fourth", StartIndex: 19, EndIndex: 26},
{Num: 5, Text: "fifth", StartIndex: 26, EndIndex: 32},
},
}
// Single address
targets, err := resolveAddress(&sedAddress{Start: 3}, pm)
require.NoError(t, err)
assert.Len(t, targets, 1)
assert.Equal(t, "third", targets[0].Text)
// Last paragraph ($)
targets, err = resolveAddress(&sedAddress{Start: -1}, pm)
require.NoError(t, err)
assert.Len(t, targets, 1)
assert.Equal(t, "fifth", targets[0].Text)
// Range
targets, err = resolveAddress(&sedAddress{Start: 2, End: 4, HasRange: true}, pm)
require.NoError(t, err)
assert.Len(t, targets, 3)
assert.Equal(t, "second", targets[0].Text)
assert.Equal(t, "fourth", targets[2].Text)
// Range ending with $
targets, err = resolveAddress(&sedAddress{Start: 3, End: -1, HasRange: true}, pm)
require.NoError(t, err)
assert.Len(t, targets, 3)
assert.Equal(t, "third", targets[0].Text)
assert.Equal(t, "fifth", targets[2].Text)
// Out of range
_, err = resolveAddress(&sedAddress{Start: 10}, pm)
assert.Error(t, err)
// Empty paragraph map
_, err = resolveAddress(&sedAddress{Start: 1}, &paragraphMap{})
assert.Error(t, err)
}

View File

@ -152,7 +152,7 @@ func TestSedIntegration_MarkdownBulletToPlain(t *testing.T) {
func TestSedIntegration_LongDocument(t *testing.T) {
// Build a document with many paragraphs
paras := make([]docParagraph, 50)
paras := make([]testDocParagraph, 50)
for i := range paras {
if i%5 == 0 {
paras[i] = para(plain("Replace this target word here"))

View File

@ -62,7 +62,7 @@ func mockDocsServerAdvanced(t *testing.T, doc *docs.Document, onBatchUpdate func
}
// buildDoc constructs a realistic multi-paragraph Google Doc for testing.
func buildDoc(paragraphs ...docParagraph) *docs.Document {
func buildDoc(paragraphs ...testDocParagraph) *docs.Document {
content := make([]*docs.StructuralElement, 0, len(paragraphs))
idx := int64(1) // Google Docs indices start at 1 (0 is reserved)
@ -190,7 +190,7 @@ type textRun struct {
style *docs.TextStyle
}
type docParagraph struct {
type testDocParagraph struct {
runs []textRun
}
@ -202,8 +202,8 @@ func bold(text string) textRun {
return textRun{text: text, style: &docs.TextStyle{Bold: true}}
}
func para(runs ...textRun) docParagraph {
return docParagraph{runs: runs}
func para(runs ...textRun) testDocParagraph {
return testDocParagraph{runs: runs}
}
// runSedIntegration runs a DocsSedCmd against a mock server and returns captured requests.

View File

@ -284,13 +284,167 @@ func parseMarkdownReplacement(repl string) (text string, formats []string) {
return text, formats
}
// parseAddress strips an optional paragraph-number address prefix from a raw expression.
// Addresses are: N (single), N,M (range), $ (last), $-N (offset from last).
// Returns nil address and the original string if no address prefix found.
func parseAddress(raw string) (*sedAddress, string, error) {
if len(raw) == 0 {
return nil, raw, nil
}
// Check for $ (last paragraph) prefix
if raw[0] == '$' {
if len(raw) == 1 {
return &sedAddress{Start: -1}, "", nil
}
remaining := raw[1:]
// $,N range
if remaining[0] == ',' {
return nil, raw, fmt.Errorf("invalid address: $ cannot be range start (use N,$ instead)")
}
// $ followed by a command
return &sedAddress{Start: -1}, remaining, nil
}
// Check for leading digits
if raw[0] < '0' || raw[0] > '9' {
return nil, raw, nil
}
// Parse the start number
i := 0
for i < len(raw) && raw[i] >= '0' && raw[i] <= '9' {
i++
}
start, err := strconv.Atoi(raw[:i])
if err != nil || start < 1 {
return nil, raw, nil
}
remaining := raw[i:]
// Check for comma (range)
if len(remaining) > 0 && remaining[0] == ',' {
remaining = remaining[1:]
if len(remaining) == 0 {
return nil, raw, fmt.Errorf("invalid address: range missing end")
}
// End can be $ or a number
if remaining[0] == '$' {
return &sedAddress{Start: start, End: -1, HasRange: true}, remaining[1:], nil
}
j := 0
for j < len(remaining) && remaining[j] >= '0' && remaining[j] <= '9' {
j++
}
if j == 0 {
return nil, raw, fmt.Errorf("invalid address: range end must be a number or $")
}
end, endErr := strconv.Atoi(remaining[:j])
if endErr != nil || end < 1 {
return nil, raw, fmt.Errorf("invalid address: range end must be >= 1")
}
if end < start {
return nil, raw, fmt.Errorf("invalid address: range end (%d) < start (%d)", end, start)
}
return &sedAddress{Start: start, End: end, HasRange: true}, remaining[j:], nil
}
// Single address — but only if followed by a command character, not more digits
// that could be part of something else (like a pattern).
// We need to distinguish "5d" (address 5 + delete) from "5" by itself.
if len(remaining) == 0 {
// Bare number with nothing after — treat as addressed bare command (needs a command)
return &sedAddress{Start: start}, "", nil
}
return &sedAddress{Start: start}, remaining, nil
}
// parseFullExpr parses a raw expression string into a sedExpr, handling all command types
// (s//, d//, a//, i//, y//) and flags (g, i, m, N for nth occurrence).
// Supports optional paragraph-number address prefix: 5d, 3,7s/foo/bar/, $a/text/.
func parseFullExpr(raw string) (sedExpr, error) {
if len(raw) == 0 {
return sedExpr{}, fmt.Errorf("empty expression")
}
// Try to parse an address prefix
addr, remaining, addrErr := parseAddress(raw)
if addrErr != nil {
return sedExpr{}, addrErr
}
// If we got an address, parse the remaining expression
if addr != nil && remaining != "" {
// Remaining starts with a command character
var expr sedExpr
var err error
// Check for non-substitution commands
if len(remaining) >= 1 {
switch remaining[0] {
case 'd':
if len(remaining) == 1 {
// Bare addressed delete: "5d" or "3,7d"
expr = sedExpr{command: 'd'}
expr.addr = addr
return expr, nil
}
if len(remaining) >= 2 && !isAlphanumeric(remaining[1]) {
expr, err = parseDCommand(remaining)
if err != nil {
return sedExpr{}, err
}
expr.addr = addr
return expr, nil
}
case 'a':
if len(remaining) >= 2 && !isAlphanumeric(remaining[1]) {
expr, err = parseAddressedAICommand(remaining, 'a')
if err != nil {
return sedExpr{}, err
}
expr.addr = addr
return expr, nil
}
case 'i':
if len(remaining) >= 2 && !isAlphanumeric(remaining[1]) {
expr, err = parseAddressedAICommand(remaining, 'i')
if err != nil {
return sedExpr{}, err
}
expr.addr = addr
return expr, nil
}
}
}
// Otherwise parse as s// or other standard command
expr, err = parseFullExprInner(remaining)
if err != nil {
return sedExpr{}, err
}
expr.addr = addr
return expr, nil
}
// Address with no remaining command — bare addressed command
if addr != nil && remaining == "" {
return sedExpr{}, fmt.Errorf("address without command: %q", raw)
}
// No address — parse normally
return parseFullExprInner(raw)
}
// parseFullExprInner is the original parseFullExpr logic, extracted so parseFullExpr
// can handle address prefixes before delegating.
func parseFullExprInner(raw string) (sedExpr, error) {
if len(raw) == 0 {
return sedExpr{}, fmt.Errorf("empty expression")
}
// Check for non-substitution commands: d, a, i, y
// Only treat as command if followed by a non-alphanumeric delimiter (like /)
if len(raw) >= 2 && !isAlphanumeric(raw[1]) {
@ -457,6 +611,20 @@ func parseDCommand(raw string) (sedExpr, error) {
return sedExpr{pattern: pattern, command: 'd'}, nil
}
// parseAddressedAICommand parses append/insert when used with a paragraph address: Na/text/.
// Unlike pattern-matched a/i, addressed a/i takes a single field (the text to insert),
// since the address already specifies where.
func parseAddressedAICommand(raw string, cmd byte) (sedExpr, error) {
if len(raw) < 3 || raw[0] != cmd {
return sedExpr{}, fmt.Errorf("invalid %c command", cmd)
}
parts := splitByDelim(raw[2:], raw[1])
if len(parts) < 1 || parts[0] == "" {
return sedExpr{}, fmt.Errorf("invalid addressed %c command (expected %c/text/)", cmd, cmd)
}
return sedExpr{replacement: parts[0], command: cmd}, nil
}
// parseAICommand parses append/insert commands: a/pattern/text/ or i/pattern/text/
// 'a' appends text after the matched line; 'i' inserts text before the matched line.
func parseAICommand(raw string, cmd byte) (sedExpr, error) {