|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "fmt" |
| 6 | + "os" |
| 7 | + "reflect" |
| 8 | + "regexp" |
| 9 | + "strings" |
| 10 | + |
| 11 | + yaml3 "gopkg.in/yaml.v3" |
| 12 | + |
| 13 | + "github.com/databricks/cli/libs/dyn" |
| 14 | + "github.com/databricks/cli/libs/dyn/convert" |
| 15 | + "github.com/databricks/cli/libs/dyn/merge" |
| 16 | + "github.com/databricks/cli/libs/dyn/yamlloader" |
| 17 | + "github.com/databricks/cli/libs/dyn/yamlsaver" |
| 18 | + "github.com/databricks/cli/libs/jsonschema" |
| 19 | +) |
| 20 | + |
| 21 | +type annotation struct { |
| 22 | + Description string `json:"description,omitempty"` |
| 23 | + MarkdownDescription string `json:"markdown_description,omitempty"` |
| 24 | + Title string `json:"title,omitempty"` |
| 25 | + Default any `json:"default,omitempty"` |
| 26 | + Enum []any `json:"enum,omitempty"` |
| 27 | +} |
| 28 | + |
| 29 | +type annotationHandler struct { |
| 30 | + // Annotations read from all annotation files including all overrides |
| 31 | + parsedAnnotations annotationFile |
| 32 | + // Missing annotations for fields that are found in config that need to be added to the annotation file |
| 33 | + missingAnnotations annotationFile |
| 34 | +} |
| 35 | + |
| 36 | +/** |
| 37 | + * Parsed file with annotations, expected format: |
| 38 | + * github.com/databricks/cli/bundle/config.Bundle: |
| 39 | + * cluster_id: |
| 40 | + * description: "Description" |
| 41 | + */ |
| 42 | +type annotationFile map[string]map[string]annotation |
| 43 | + |
| 44 | +const Placeholder = "PLACEHOLDER" |
| 45 | + |
| 46 | +// Adds annotations to the JSON schema reading from the annotation files. |
| 47 | +// More details https://json-schema.org/understanding-json-schema/reference/annotations |
| 48 | +func newAnnotationHandler(sources []string) (*annotationHandler, error) { |
| 49 | + prev := dyn.NilValue |
| 50 | + for _, path := range sources { |
| 51 | + b, err := os.ReadFile(path) |
| 52 | + if err != nil { |
| 53 | + return nil, err |
| 54 | + } |
| 55 | + generated, err := yamlloader.LoadYAML(path, bytes.NewBuffer(b)) |
| 56 | + if err != nil { |
| 57 | + return nil, err |
| 58 | + } |
| 59 | + prev, err = merge.Merge(prev, generated) |
| 60 | + if err != nil { |
| 61 | + return nil, err |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + var data annotationFile |
| 66 | + |
| 67 | + err := convert.ToTyped(&data, prev) |
| 68 | + if err != nil { |
| 69 | + return nil, err |
| 70 | + } |
| 71 | + |
| 72 | + d := &annotationHandler{} |
| 73 | + d.parsedAnnotations = data |
| 74 | + d.missingAnnotations = annotationFile{} |
| 75 | + return d, nil |
| 76 | +} |
| 77 | + |
| 78 | +func (d *annotationHandler) addAnnotations(typ reflect.Type, s jsonschema.Schema) jsonschema.Schema { |
| 79 | + refPath := getPath(typ) |
| 80 | + shouldHandle := strings.HasPrefix(refPath, "github.com") |
| 81 | + if !shouldHandle { |
| 82 | + return s |
| 83 | + } |
| 84 | + |
| 85 | + annotations := d.parsedAnnotations[refPath] |
| 86 | + if annotations == nil { |
| 87 | + annotations = map[string]annotation{} |
| 88 | + } |
| 89 | + |
| 90 | + rootTypeAnnotation, ok := annotations[RootTypeKey] |
| 91 | + if ok { |
| 92 | + assignAnnotation(&s, rootTypeAnnotation) |
| 93 | + } |
| 94 | + |
| 95 | + for k, v := range s.Properties { |
| 96 | + item := annotations[k] |
| 97 | + if item.Description == "" { |
| 98 | + item.Description = Placeholder |
| 99 | + |
| 100 | + emptyAnnotations := d.missingAnnotations[refPath] |
| 101 | + if emptyAnnotations == nil { |
| 102 | + emptyAnnotations = map[string]annotation{} |
| 103 | + d.missingAnnotations[refPath] = emptyAnnotations |
| 104 | + } |
| 105 | + emptyAnnotations[k] = item |
| 106 | + } |
| 107 | + assignAnnotation(v, item) |
| 108 | + } |
| 109 | + return s |
| 110 | +} |
| 111 | + |
| 112 | +// Writes missing annotations with placeholder values back to the annotation file |
| 113 | +func (d *annotationHandler) syncWithMissingAnnotations(outputPath string) error { |
| 114 | + existingFile, err := os.ReadFile(outputPath) |
| 115 | + if err != nil { |
| 116 | + return err |
| 117 | + } |
| 118 | + existing, err := yamlloader.LoadYAML("", bytes.NewBuffer(existingFile)) |
| 119 | + if err != nil { |
| 120 | + return err |
| 121 | + } |
| 122 | + missingAnnotations, err := convert.FromTyped(&d.missingAnnotations, dyn.NilValue) |
| 123 | + if err != nil { |
| 124 | + return err |
| 125 | + } |
| 126 | + |
| 127 | + output, err := merge.Merge(existing, missingAnnotations) |
| 128 | + if err != nil { |
| 129 | + return err |
| 130 | + } |
| 131 | + |
| 132 | + err = saveYamlWithStyle(outputPath, output) |
| 133 | + if err != nil { |
| 134 | + return err |
| 135 | + } |
| 136 | + return nil |
| 137 | +} |
| 138 | + |
| 139 | +func getPath(typ reflect.Type) string { |
| 140 | + return typ.PkgPath() + "." + typ.Name() |
| 141 | +} |
| 142 | + |
| 143 | +func assignAnnotation(s *jsonschema.Schema, a annotation) { |
| 144 | + if a.Description != Placeholder { |
| 145 | + s.Description = a.Description |
| 146 | + } |
| 147 | + |
| 148 | + if a.Default != nil { |
| 149 | + s.Default = a.Default |
| 150 | + } |
| 151 | + s.MarkdownDescription = convertLinksToAbsoluteUrl(a.MarkdownDescription) |
| 152 | + s.Title = a.Title |
| 153 | + s.Enum = a.Enum |
| 154 | +} |
| 155 | + |
| 156 | +func saveYamlWithStyle(outputPath string, input dyn.Value) error { |
| 157 | + style := map[string]yaml3.Style{} |
| 158 | + file, _ := input.AsMap() |
| 159 | + for _, v := range file.Keys() { |
| 160 | + style[v.MustString()] = yaml3.LiteralStyle |
| 161 | + } |
| 162 | + |
| 163 | + saver := yamlsaver.NewSaverWithStyle(style) |
| 164 | + err := saver.SaveAsYAML(file, outputPath, true) |
| 165 | + if err != nil { |
| 166 | + return err |
| 167 | + } |
| 168 | + return nil |
| 169 | +} |
| 170 | + |
| 171 | +func convertLinksToAbsoluteUrl(s string) string { |
| 172 | + if s == "" { |
| 173 | + return s |
| 174 | + } |
| 175 | + base := "https://docs.databricks.com" |
| 176 | + referencePage := "/dev-tools/bundles/reference.html" |
| 177 | + |
| 178 | + // Regular expression to match Markdown-style links like [_](link) |
| 179 | + re := regexp.MustCompile(`\[_\]\(([^)]+)\)`) |
| 180 | + result := re.ReplaceAllStringFunc(s, func(match string) string { |
| 181 | + matches := re.FindStringSubmatch(match) |
| 182 | + if len(matches) < 2 { |
| 183 | + return match |
| 184 | + } |
| 185 | + link := matches[1] |
| 186 | + var text, absoluteURL string |
| 187 | + |
| 188 | + if strings.HasPrefix(link, "#") { |
| 189 | + text = strings.TrimPrefix(link, "#") |
| 190 | + absoluteURL = fmt.Sprintf("%s%s%s", base, referencePage, link) |
| 191 | + |
| 192 | + // Handle relative paths like /dev-tools/bundles/resources.html#dashboard |
| 193 | + } else if strings.HasPrefix(link, "/") { |
| 194 | + absoluteURL = strings.ReplaceAll(fmt.Sprintf("%s%s", base, link), ".md", ".html") |
| 195 | + if strings.Contains(link, "#") { |
| 196 | + parts := strings.Split(link, "#") |
| 197 | + text = parts[1] |
| 198 | + } else { |
| 199 | + text = "link" |
| 200 | + } |
| 201 | + } else { |
| 202 | + return match |
| 203 | + } |
| 204 | + |
| 205 | + return fmt.Sprintf("[%s](%s)", text, absoluteURL) |
| 206 | + }) |
| 207 | + |
| 208 | + return result |
| 209 | +} |
0 commit comments