1313import java .io .IOException ;
1414import java .io .InputStream ;
1515import java .net .HttpURLConnection ;
16+ import java .net .InetSocketAddress ;
17+ import java .net .Proxy ;
18+ import java .net .URI ;
1619import java .net .URL ;
20+ import java .nio .charset .StandardCharsets ;
21+ import java .nio .file .AtomicMoveNotSupportedException ;
1722import java .nio .file .Files ;
1823import java .nio .file .Path ;
1924import java .nio .file .StandardCopyOption ;
2429import java .sql .PreparedStatement ;
2530import java .sql .ResultSet ;
2631import java .sql .SQLException ;
32+ import java .sql .Statement ;
2733import java .time .Duration ;
2834import java .time .Instant ;
2935import java .util .ArrayList ;
36+ import java .util .Base64 ;
3037import java .util .Comparator ;
38+ import java .util .HashSet ;
3139import java .util .LinkedHashMap ;
3240import java .util .List ;
3341import java .util .Locale ;
3442import java .util .Map ;
3543import java .util .Optional ;
44+ import java .util .Set ;
3645import java .util .concurrent .ConcurrentHashMap ;
3746import java .util .concurrent .atomic .AtomicBoolean ;
3847import java .util .function .Function ;
3948import java .util .stream .Stream ;
4049
4150public final class JassDocService {
4251
52+ // Optional JassDoc configuration (environment variable or system property):
53+ // WURST_JASSDOC_DB_AUTO_UPDATE=false keeps an existing latest DB indefinitely.
54+ // WURST_JASSDOC_DB_PROXY overrides HTTPS_PROXY/HTTP_PROXY for JassDoc requests.
55+
4356 public enum SymbolKind {
4457 FUNCTION , VARIABLE
4558 }
@@ -247,7 +260,7 @@ private Optional<String> lookupDocumentation(LookupKey key) {
247260 }
248261 try (Connection conn = open (dbPath .get ())) {
249262 List <TableSchema > schemas = discoverSchemas (conn );
250- boolean hasLegacySchema = hasLegacyJassdocSchema (conn );
263+ boolean hasLegacySchema = hasCompatibleLegacyJassdocSchema (conn );
251264 if (schemas .isEmpty () && !hasLegacySchema ) {
252265 WLogger .warning ("JassDoc DB found, but no compatible documentation tables were detected." );
253266 initFailed = true ;
@@ -355,7 +368,7 @@ private void triggerAsyncInit() {
355368 }
356369
357370 private @ Nullable String lookupFromLegacyJassdocTables (Connection conn , LookupKey key ) throws SQLException {
358- if (!tableExists (conn , "parameters" )) {
371+ if (!hasCompatibleLegacyJassdocSchema (conn )) {
359372 return null ;
360373 }
361374 Map <String , String > params = readKeyValueRows (conn , "parameters" , "fnname" , "param" , "value" , key .symbolName ());
@@ -527,7 +540,7 @@ private Optional<Path> ensureDbAvailable() throws IOException {
527540 }
528541
529542 boolean needsDownload = !Files .exists (dbPath );
530- if (!needsDownload && "latest" .equals (revision )) {
543+ if (!needsDownload && "latest" .equals (revision ) && autoUpdateEnabled () ) {
531544 needsDownload = isStaleLatest (dbPath );
532545 }
533546
@@ -590,7 +603,7 @@ private List<String> resolveLatestReleaseAssetUrls() {
590603 private List <String > readNewestReleaseAssetUrlsFromList () {
591604 List <String > urls = new ArrayList <>();
592605 try {
593- HttpURLConnection con = ( HttpURLConnection ) new URL (RELEASES_API ). openConnection ( );
606+ HttpURLConnection con = openHttpConnection ( new URL (RELEASES_API ));
594607 con .setConnectTimeout (10_000 );
595608 con .setReadTimeout (20_000 );
596609 con .setRequestMethod ("GET" );
@@ -625,7 +638,7 @@ private List<String> readNewestReleaseAssetUrlsFromList() {
625638 private List <String > readReleaseAssetUrls (String apiUrl ) {
626639 List <String > urls = new ArrayList <>();
627640 try {
628- HttpURLConnection con = ( HttpURLConnection ) new URL (apiUrl ). openConnection ( );
641+ HttpURLConnection con = openHttpConnection ( new URL (apiUrl ));
629642 con .setConnectTimeout (10_000 );
630643 con .setReadTimeout (20_000 );
631644 con .setRequestMethod ("GET" );
@@ -710,6 +723,14 @@ private boolean isStaleLatest(Path dbPath) throws IOException {
710723 return modified .toInstant ().isBefore (cutoff );
711724 }
712725
726+ boolean autoUpdateEnabled () {
727+ return Utils .getEnvOrConfig ("WURST_JASSDOC_DB_AUTO_UPDATE" )
728+ .map (value -> !value .equalsIgnoreCase ("false" )
729+ && !value .equalsIgnoreCase ("no" )
730+ && !value .equals ("0" ))
731+ .orElse (true );
732+ }
733+
713734 private Duration parseDurationOrDefault (String text ) {
714735 try {
715736 return Duration .parse (text );
@@ -720,22 +741,191 @@ private Duration parseDurationOrDefault(String text) {
720741
721742 private void download (String urlString , Path target ) throws IOException {
722743 URL url = new URL (urlString );
723- HttpURLConnection con = ( HttpURLConnection ) url . openConnection ( );
744+ HttpURLConnection con = openHttpConnection ( url );
724745 con .setConnectTimeout (10_000 );
725746 con .setReadTimeout (20_000 );
726747 con .setInstanceFollowRedirects (true );
727748 con .setRequestMethod ("GET" );
749+ con .setRequestProperty ("User-Agent" , "WurstScript-LSP" );
728750 int code = con .getResponseCode ();
729751 if (code < 200 || code >= 300 ) {
730752 throw new IOException ("HTTP " + code + " for " + urlString );
731753 }
732754 Path tmp = Files .createTempFile (target .getParent (), "jassdoc-" , ".tmp" );
733- try (InputStream in = con .getInputStream ()) {
734- Files .copy (in , tmp , StandardCopyOption .REPLACE_EXISTING );
755+ try {
756+ try (InputStream in = con .getInputStream ()) {
757+ Files .copy (in , tmp , StandardCopyOption .REPLACE_EXISTING );
758+ }
759+ installDownloadedDatabase (tmp , target );
735760 } finally {
736761 con .disconnect ();
762+ Files .deleteIfExists (tmp );
737763 }
738- Files .move (tmp , target , StandardCopyOption .REPLACE_EXISTING , StandardCopyOption .ATOMIC_MOVE );
764+ }
765+
766+ void installDownloadedDatabase (Path downloaded , Path target ) throws IOException {
767+ validateDownloadedDatabase (downloaded );
768+
769+ Path backup = target .resolveSibling (target .getFileName () + ".bak" );
770+ if (Files .exists (target )) {
771+ Path backupTmp = Files .createTempFile (target .getParent (), "jassdoc-backup-" , ".tmp" );
772+ try {
773+ Files .copy (target , backupTmp , StandardCopyOption .REPLACE_EXISTING ,
774+ StandardCopyOption .COPY_ATTRIBUTES );
775+ replaceAtomically (backupTmp , backup );
776+ } finally {
777+ Files .deleteIfExists (backupTmp );
778+ }
779+ }
780+
781+ try {
782+ replaceAtomically (downloaded , target );
783+ } catch (IOException installFailure ) {
784+ if (Files .exists (backup )) {
785+ restoreBackup (backup , target , installFailure );
786+ }
787+ throw installFailure ;
788+ }
789+ }
790+
791+ void restoreBackup (Path backup , Path target , IOException installFailure ) {
792+ Path restoreTmp = null ;
793+ try {
794+ restoreTmp = Files .createTempFile (target .getParent (), "jassdoc-restore-" , ".tmp" );
795+ Files .copy (backup , restoreTmp , StandardCopyOption .REPLACE_EXISTING ,
796+ StandardCopyOption .COPY_ATTRIBUTES );
797+ replaceAtomically (restoreTmp , target );
798+ } catch (IOException restoreFailure ) {
799+ installFailure .addSuppressed (restoreFailure );
800+ } finally {
801+ if (restoreTmp != null ) {
802+ try {
803+ Files .deleteIfExists (restoreTmp );
804+ } catch (IOException cleanupFailure ) {
805+ installFailure .addSuppressed (cleanupFailure );
806+ }
807+ }
808+ }
809+ }
810+
811+ private void validateDownloadedDatabase (Path downloaded ) throws IOException {
812+ try (Connection conn = open (downloaded )) {
813+ if (!passesIntegrityCheck (conn )) {
814+ throw new IOException ("Downloaded JassDoc database failed SQLite integrity check" );
815+ }
816+ if (discoverSchemas (conn ).isEmpty () && !hasCompatibleLegacyJassdocSchema (conn )) {
817+ throw new IOException ("Downloaded file is not a compatible JassDoc database" );
818+ }
819+ } catch (SQLException e ) {
820+ throw new IOException ("Downloaded file is not a valid JassDoc database" , e );
821+ }
822+ }
823+
824+ private boolean passesIntegrityCheck (Connection conn ) throws SQLException {
825+ try (Statement statement = conn .createStatement ();
826+ ResultSet result = statement .executeQuery ("PRAGMA integrity_check" )) {
827+ return result .next ()
828+ && "ok" .equalsIgnoreCase (result .getString (1 ))
829+ && !result .next ();
830+ }
831+ }
832+
833+ private void replaceAtomically (Path source , Path target ) throws IOException {
834+ try {
835+ Files .move (source , target , StandardCopyOption .REPLACE_EXISTING ,
836+ StandardCopyOption .ATOMIC_MOVE );
837+ } catch (AtomicMoveNotSupportedException e ) {
838+ Files .move (source , target , StandardCopyOption .REPLACE_EXISTING );
839+ }
840+ }
841+
842+ private HttpURLConnection openHttpConnection (URL url ) throws IOException {
843+ Optional <String > proxySetting = selectProxySetting (url , Utils ::getEnvOrConfig );
844+ if (proxySetting .isEmpty ()) {
845+ return (HttpURLConnection ) url .openConnection ();
846+ }
847+
848+ URI proxyUri = parseHttpProxyUri (proxySetting .get ());
849+ int port = proxyUri .getPort () >= 0 ? proxyUri .getPort () : 80 ;
850+ Proxy proxy = new Proxy (Proxy .Type .HTTP , new InetSocketAddress (proxyUri .getHost (), port ));
851+ HttpURLConnection connection = (HttpURLConnection ) url .openConnection (proxy );
852+ if (proxyUri .getUserInfo () != null ) {
853+ String credentials = Base64 .getEncoder ().encodeToString (
854+ proxyUri .getUserInfo ().getBytes (StandardCharsets .UTF_8 ));
855+ connection .setRequestProperty ("Proxy-Authorization" , "Basic " + credentials );
856+ }
857+ return connection ;
858+ }
859+
860+ static Optional <String > selectProxySetting (URL url ,
861+ Function <String , Optional <String >> lookup ) {
862+ Optional <String > proxySetting = lookup .apply ("WURST_JASSDOC_DB_PROXY" );
863+ if (proxySetting .isPresent ()) {
864+ return proxySetting ;
865+ }
866+ Optional <String > noProxy = firstConfigured (lookup , "NO_PROXY" , "no_proxy" );
867+ if (noProxy .isPresent () && shouldBypassProxy (url .getHost (), noProxy .get ())) {
868+ return Optional .empty ();
869+ }
870+ if ("https" .equalsIgnoreCase (url .getProtocol ())) {
871+ return firstConfigured (lookup , "HTTPS_PROXY" , "https_proxy" );
872+ }
873+ if ("http" .equalsIgnoreCase (url .getProtocol ())) {
874+ return firstConfigured (lookup , "HTTP_PROXY" , "http_proxy" );
875+ }
876+ return Optional .empty ();
877+ }
878+
879+ static URI parseHttpProxyUri (String value ) throws IOException {
880+ URI proxyUri ;
881+ try {
882+ proxyUri = URI .create (value .contains ("://" ) ? value : "http://" + value );
883+ } catch (IllegalArgumentException e ) {
884+ throw new IOException ("Invalid JassDoc proxy URL" , e );
885+ }
886+ if (proxyUri .getHost () == null ) {
887+ throw new IOException ("Invalid JassDoc proxy URL: missing host" );
888+ }
889+ if (!"http" .equalsIgnoreCase (proxyUri .getScheme ())) {
890+ throw new IOException ("Unsupported JassDoc proxy scheme '" + proxyUri .getScheme ()
891+ + "'; use an http:// proxy URL" );
892+ }
893+ return proxyUri ;
894+ }
895+
896+ static boolean shouldBypassProxy (String host , String noProxySetting ) {
897+ String normalizedHost = host .toLowerCase (Locale .ROOT );
898+ for (String rawEntry : noProxySetting .split ("," )) {
899+ String entry = rawEntry .trim ().toLowerCase (Locale .ROOT );
900+ if (entry .equals ("*" )) {
901+ return true ;
902+ }
903+ int portSeparator = entry .lastIndexOf (':' );
904+ if (portSeparator > 0 && entry .indexOf (':' ) == portSeparator ) {
905+ entry = entry .substring (0 , portSeparator );
906+ }
907+ if (entry .startsWith ("*." )) {
908+ entry = entry .substring (1 );
909+ }
910+ if (entry .startsWith ("." )) {
911+ if (normalizedHost .endsWith (entry )
912+ || normalizedHost .equals (entry .substring (1 ))) {
913+ return true ;
914+ }
915+ } else if (!entry .isEmpty () && (normalizedHost .equals (entry )
916+ || normalizedHost .endsWith ("." + entry ))) {
917+ return true ;
918+ }
919+ }
920+ return false ;
921+ }
922+
923+ private static Optional <String > firstConfigured (
924+ Function <String , Optional <String >> lookup , String ... names ) {
925+ return Stream .of (names )
926+ .map (lookup )
927+ .flatMap (Optional ::stream )
928+ .findFirst ();
739929 }
740930
741931 private Connection open (Path dbPath ) throws SQLException {
@@ -778,8 +968,32 @@ private List<TableSchema> discoverSchemas(Connection conn) throws SQLException {
778968 return result ;
779969 }
780970
781- private boolean hasLegacyJassdocSchema (Connection conn ) throws SQLException {
782- return tableExists (conn , "parameters" );
971+ private boolean hasCompatibleLegacyJassdocSchema (Connection conn ) throws SQLException {
972+ return tableHasColumns (conn , "parameters" , "fnname" , "param" , "value" )
973+ && (!tableExists (conn , "annotations" )
974+ || tableHasColumns (conn , "annotations" , "fnname" , "anname" , "value" ))
975+ && (!tableExists (conn , "params_extra" )
976+ || tableHasColumns (conn , "params_extra" , "fnname" , "param" , "anname" , "value" ));
977+ }
978+
979+ private boolean tableHasColumns (Connection conn , String tableName , String ... requiredColumns )
980+ throws SQLException {
981+ Set <String > columns = new HashSet <>();
982+ DatabaseMetaData md = conn .getMetaData ();
983+ try (ResultSet result = md .getColumns (null , null , tableName , "%" )) {
984+ while (result .next ()) {
985+ String name = result .getString ("COLUMN_NAME" );
986+ if (name != null ) {
987+ columns .add (name .toLowerCase (Locale .ROOT ));
988+ }
989+ }
990+ }
991+ for (String required : requiredColumns ) {
992+ if (!columns .contains (required )) {
993+ return false ;
994+ }
995+ }
996+ return true ;
783997 }
784998
785999 private boolean tableExists (Connection conn , String tableName ) throws SQLException {
0 commit comments