11package lint
22
33import (
4+ "bufio"
45 "errors"
6+ "os"
7+ "path/filepath"
58 "regexp"
69 "strings"
10+ "sync"
711
812 "github.com/errata-ai/vale/v3/internal/core"
913 "github.com/errata-ai/vale/v3/internal/system"
@@ -30,6 +34,125 @@ var rstArgs = []string{
3034 "--no-section-numbering" ,
3135}
3236
37+ // Converting a document with Docutils takes a few milliseconds; starting
38+ // Python and importing Docutils takes seventy. Vale paid the latter once per
39+ // file, so on a corpus of reStructuredText nearly all of the time went to
40+ // starting the same interpreter over and over.
41+ //
42+ // rstServer keeps one interpreter up and converts documents as they arrive. It
43+ // parses the flags through Docutils' own command-line handling rather than
44+ // mapping them to settings by hand, so the pooled settings cannot drift from
45+ // what a per-file rst2html invocation would use.
46+ const rstServer = `import sys
47+ from docutils.core import Publisher, publish_string
48+
49+ _pub = Publisher()
50+ _pub.set_components("standalone", "restructuredtext", "html4css1")
51+ _pub.process_command_line(argv=sys.argv[1:])
52+ SETTINGS = _pub.settings
53+
54+ buf = sys.stdin.buffer
55+ out = sys.stdout.buffer
56+
57+ while True:
58+ header = buf.readline()
59+ if not header:
60+ break
61+ n = int(header)
62+ if n < 0:
63+ break
64+ doc = buf.read(n) if n else b""
65+ try:
66+ b = publish_string(
67+ source=doc.decode("utf-8"),
68+ source_path="<stdin>",
69+ writer_name="html4css1",
70+ settings=SETTINGS,
71+ )
72+ out.write(b"ok %d\n" % len(b))
73+ out.write(b)
74+ except Exception as e:
75+ m = str(e).encode("utf-8", "replace")
76+ out.write(b"err %d\n" % len(m))
77+ out.write(m)
78+ out.flush()`
79+
80+ var (
81+ rstOnce sync.Once
82+ rstDirect []string // <python> -c <server>, or nil
83+ )
84+
85+ // rstInterpreter finds the Python that can import Docutils.
86+ //
87+ // It is not necessarily the `python3` on PATH: rst2html is installed with a
88+ // shebang naming the interpreter of the environment Docutils was installed
89+ // into, and on a machine with several Pythons the one on PATH often cannot
90+ // import it. So the script is asked which interpreter it runs on.
91+ func rstInterpreter (exe string ) string {
92+ resolved , err := filepath .EvalSymlinks (exe )
93+ if err != nil {
94+ return ""
95+ }
96+
97+ file , err := os .Open (resolved )
98+ if err != nil {
99+ return ""
100+ }
101+ defer file .Close ()
102+
103+ line , err := bufio .NewReader (file ).ReadString ('\n' )
104+ if err != nil || ! strings .HasPrefix (line , "#!" ) {
105+ return ""
106+ }
107+
108+ fields := strings .Fields (strings .TrimPrefix (strings .TrimSpace (line ), "#!" ))
109+ if len (fields ) == 0 {
110+ return ""
111+ }
112+
113+ // `#!/usr/bin/env python3` names the interpreter in the second field.
114+ if filepath .Base (fields [0 ]) == "env" {
115+ if len (fields ) < 2 {
116+ return ""
117+ }
118+ return system .Which ([]string {fields [1 ]})
119+ }
120+
121+ return fields [0 ]
122+ }
123+
124+ // rstFastPath returns the argv prefix for converting through a long-lived
125+ // interpreter, or nil when that could not be established.
126+ func rstFastPath (exe string ) []string {
127+ rstOnce .Do (func () {
128+ python := rstInterpreter (exe )
129+ if python == "" {
130+ return
131+ }
132+
133+ candidate := []string {python , "-c" , rstServer }
134+
135+ // Trust it only after a document has made the round trip. The probe
136+ // carries a non-ASCII character on purpose: a mismatched default
137+ // encoding is what broke the first version of the AsciiDoc pool, and
138+ // an ASCII-only probe would have passed anyway.
139+ probe , err := startExtProc (candidate , rstArgs )
140+ if err != nil {
141+ return
142+ }
143+ defer probe .close ()
144+
145+ got , err := probe .convert ("naïve body\n " )
146+ if err != nil || ! strings .Contains (got , "naïve body" ) {
147+ return
148+ }
149+
150+ rstDirect = candidate
151+ })
152+
153+ return rstDirect
154+ }
155+
33156func (l * Linter ) lintRST (f * core.File ) error {
34157 var html string
35158
@@ -53,19 +176,44 @@ func (l *Linter) lintRST(f *core.File) error {
53176 s = reSphinx .ReplaceAllString (s , ".. code::" )
54177 s = reCodeBlock .ReplaceAllString (s , "::" )
55178
56- html , err = callRst (s , rst2html )
179+ html , err = l . callRst (s , rst2html )
57180 if err != nil {
58181 return core .NewE100 (f .Path , err )
59182 }
60183
61184 return l .lintHTMLTokens (f , []byte (html ), 0 )
62185}
63186
64- func callRst (text , lib string ) (string , error ) {
65- html , err := system .ExecuteWithInput (lib , text , rstArgs ... )
187+ // callRst converts one document, over a pooled interpreter when Docutils can
188+ // be reached directly.
189+ func (l * Linter ) callRst (text , exe string ) (string , error ) {
190+ if direct := rstFastPath (exe ); direct != nil {
191+ l .rstOnce .Do (func () {
192+ pool , err := newProcPool (direct , rstArgs , adocConcurrency )
193+ if err == nil {
194+ l .rst = pool
195+ }
196+ })
197+
198+ if l .rst != nil {
199+ html , err := l .rst .convert (text , direct , rstArgs )
200+ if err != nil {
201+ return "" , err
202+ }
203+ return rstBody (html ), nil
204+ }
205+ }
206+
207+ html , err := system .ExecuteWithInput (exe , text , rstArgs ... )
66208 if err != nil {
67209 return "" , err
68210 }
211+
212+ return rstBody (html ), nil
213+ }
214+
215+ // rstBody takes the document body out of a full rst2html page.
216+ func rstBody (html string ) string {
69217 html = strings .ReplaceAll (html , "\r " , "" )
70218
71219 bodyStart := strings .Index (html , "<body>\n " )
@@ -80,5 +228,5 @@ func callRst(text, lib string) (string, error) {
80228 }
81229 }
82230
83- return html [bodyStart + 7 : bodyEnd ], nil
231+ return html [bodyStart + 7 : bodyEnd ]
84232}
0 commit comments