? .cache
? .settings
? modules/field/modules/list/tests
Index: list.test
===================================================================
RCS file: list.test
diff -N list.test
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/field/modules/list/tests/list.test	21 Nov 2009 22:15:48 -0000
@@ -0,0 +1,89 @@
+<?php
+// $Id: list.test,v 1.3 2009/11/20 23:29:28 webchick Exp $
+
+class ListFieldTestCase extends DrupalWebTestCase {
+  public static function getInfo() {
+    return array(
+      'name'  => 'List field',
+      'description'  => "Test the List field type.",
+      'group' => 'Field Types'
+    );
+  }
+
+  function setUp() {
+    parent::setUp('field_test');
+
+    $this->card_1 = array(
+      'field_name' => 'card_1',
+      'type' => 'list',
+      'cardinality' => 1,
+      'settings' => array(
+        'allowed_values' => "1|One\n2|Two\n3|Three\n",
+      ),
+    );
+    $this->card_1 = field_create_field($this->card_1);
+
+    $this->instance_1 = array(
+      'field_name' => $this->card_1['field_name'],
+      'object_type' => 'test_entity',
+      'bundle' => 'test_bundle',
+      'widget' => array(
+        'type' => 'options_buttons',
+      ),
+    );
+    $this->instance_1 = field_create_instance($this->instance_1);
+  }
+
+  /**
+   * Test that allowed values can be updated and that the updates are
+   * reflected in generated forms.
+   */
+  function testUpdateAllowedValues() {
+    // All three options appear.
+    $entity = field_test_create_stub_entity();
+    $form = drupal_get_form('field_test_entity_form', $entity);
+    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][1]), t('Option 1 exists'));
+    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][2]), t('Option 2 exists'));
+    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][3]), t('Option 3 exists'));
+
+    // Removed options do not appear.
+    $this->card_1['settings']['allowed_values'] = "2|Two";
+    field_update_field($this->card_1);
+    $entity = field_test_create_stub_entity();
+    $form = drupal_get_form('field_test_entity_form', $entity);
+    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][1]), t('Option 1 does not exist'));
+    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][2]), t('Option 2 exists'));
+    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][3]), t('Option 3 does not exist'));
+
+    // Completely new options appear.
+    $this->card_1['settings']['allowed_values'] = "10|Update\n20|Twenty";
+    field_update_field($this->card_1);
+    $form = drupal_get_form('field_test_entity_form', $entity);
+    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][1]), t('Option 1 does not exist'));
+    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][2]), t('Option 2 does not exist'));
+    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][3]), t('Option 3 does not exist'));
+    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][10]), t('Option 10 exists'));
+    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][20]), t('Option 20 exists'));
+
+    // Options are reset when a new field with the same name is created.
+    field_delete_field($this->card_1['field_name']);
+    unset($this->card_1['id']);
+    $this->card_1['settings']['allowed_values'] = "1|One\n2|Two\n3|Three\n";
+    $this->card_1 = field_create_field($this->card_1);
+    $this->instance_1 = array(
+      'field_name' => $this->card_1['field_name'],
+      'object_type' => 'test_entity',
+      'bundle' => 'test_bundle',
+      'widget' => array(
+        'type' => 'options_buttons',
+      ),
+    );
+    $this->instance_1 = field_create_instance($this->instance_1);
+    $entity = field_test_create_stub_entity();
+    $form = drupal_get_form('field_test_entity_form', $entity);
+    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][1]), t('Option 1 exists'));
+    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][2]), t('Option 2 exists'));
+    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][3]), t('Option 3 exists'));
+  }
+}
+
Index: list_test.info
===================================================================
RCS file: list_test.info
diff -N list_test.info
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/field/modules/list/tests/list_test.info	21 Nov 2009 22:15:48 -0000
@@ -0,0 +1,8 @@
+;$Id: field_test.info,v 1.1 2009/11/20 23:29:28 webchick Exp $
+name = "List Test"
+description = "Support module for the List module tests."
+core = 7.x
+package = Testing
+files[] = list_test.module
+version = VERSION
+hidden = TRUE
Index: list_test.module
===================================================================
RCS file: list_test.module
diff -N list_test.module
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/field/modules/list/tests/list_test.module	21 Nov 2009 22:15:48 -0000
@@ -0,0 +1,33 @@
+<?php
+// $Id: $
+
+/**
+ * @file
+ * Helper module for the List module tests.
+ */
+
+/**
+ * Allowed values callback.
+ */
+function list_test_allowed_values_callback($field, $optgroups) {
+  if ($optgroups) {
+    $values = array(
+      'Group 1' => array(
+        0 => 'Zero',
+      ),
+      1 => 'One',
+      'Group 2' => array(
+        2 => 'Some <script>dangerous</script> & unescaped <strong>markup</strong>',
+      ),
+    );
+  }
+  else {
+    $values = array(
+      0 => 'Zero',
+      1 => 'One',
+      2 => 'Some <script>dangerous</script> & unescaped <strong>markup</strong>',
+    );
+  }
+
+  return $values;
+}
Index: modules/field/modules/list/list.info
===================================================================
RCS file: /cvs/drupal/drupal/modules/field/modules/list/list.info,v
retrieving revision 1.5
diff -u -p -r1.5 list.info
--- modules/field/modules/list/list.info	18 Oct 2009 18:46:11 -0000	1.5
+++ modules/field/modules/list/list.info	21 Nov 2009 22:15:48 -0000
@@ -5,5 +5,5 @@ package = Core - fields
 version = VERSION
 core = 7.x
 files[]=list.module
-files[]=list.test
+files[]=tests/list.test
 required = TRUE
Index: modules/field/modules/list/list.module
===================================================================
RCS file: /cvs/drupal/drupal/modules/field/modules/list/list.module,v
retrieving revision 1.17
diff -u -p -r1.17 list.module
--- modules/field/modules/list/list.module	18 Oct 2009 18:46:11 -0000	1.17
+++ modules/field/modules/list/list.module	21 Nov 2009 23:43:52 -0000
@@ -131,78 +131,66 @@ function list_field_settings_form($field
 }
 
 /**
- * Implement hook_field_create_field().
- */
-function list_field_create_field($field) {
-  if (array_key_exists($field['type'], list_field_info())) {
-    // Clear the static cache of allowed values for $field.
-    $allowed_values = &drupal_static('list_allowed_values', array());
-    unset($allowed_values[$field['field_name']]);
-  }
-}
-
-/**
  * Implement hook_field_update_field().
  */
 function list_field_update_field($field, $prior_field, $has_data) {
-  if (array_key_exists($field['type'], list_field_info())) {
-    // Clear the static cache of allowed values for $field.
-    $allowed_values = &drupal_static('list_allowed_values', array());
-    unset($allowed_values[$field['field_name']]);
-  }
+  drupal_static_reset('list_allowed_values');
 }
 
 /**
- * Create an array of allowed values for this field.
+ * Returns the set of allowed values for the field.
+ *
+ * The strings are not safe for output. Keys and values of the array should be
+ * sanitized through field_filter_xss() before being displayed.
+ *
+ * @param $field
+ *   The field definition.
+ * @return
+ *   The array of allowed values. Keys of the array are the raw stored values
+ *   (integer or text), values of the array are the display aliases.
  */
 function list_allowed_values($field) {
-  // This static cache must be cleared whenever $field['field_name']
-  // changes. This includes when it is created because a different
-  // field with the same name may have previously existed, as well
-  // as when it is updated.
   $allowed_values = &drupal_static(__FUNCTION__, array());
 
-  if (isset($allowed_values[$field['field_name']])) {
-    return $allowed_values[$field['field_name']];
-  }
+  if (!isset($allowed_values[$field['id']])) {
+    $values = array();
 
-  $allowed_values[$field['field_name']] = array();
+    $function = $field['settings']['allowed_values_function'];
+    if (!empty($function) && function_exists($function)) {
+      $values = $function($field);
+    }
+    elseif (!empty($field['settings']['allowed_values'])) {
+      $position_keys = $field['type'] == 'list';
+      $values = list_extract_allowed_values($field['settings']['allowed_values'], $position_keys);
+    }
 
-  $function = $field['settings']['allowed_values_function'];
-  if (!empty($function) && function_exists($function)) {
-    $allowed_values[$field['field_name']] = $function($field);
-  }
-  elseif (!empty($field['settings']['allowed_values'])) {
-    $allowed_values[$field['field_name']] = list_allowed_values_list($field['settings']['allowed_values'], $field['type'] == 'list');
+    $allowed_values[$field['id']] = $values;
   }
 
-  return $allowed_values[$field['field_name']];
+  return $allowed_values[$field['id']];
 }
 
 /**
- * Create an array of the allowed values for this field.
- *
- * Explode a string with keys and labels separated with '|' and with each new
- * value on its own line.
+ * Generates an array of values from a string.
  *
  * @param $string_values
- *   The list of choices as a string.
+ *   The list of choices as a string, in the format expected by the
+ *   'allowed_values' setting:
+ *    - Values are separated by a carriage return.
+ *    - Each value is in the format "value|label" or "value".
  * @param $position_keys
  *   Boolean value indicating whether to generate keys based on the position of
  *   the value if a key is not manually specified, effectively generating
  *   integer-based keys. This should only be TRUE for fields that have a type of
  *   "list". Otherwise the value will be used as the key if not specified.
  */
-function list_allowed_values_list($string_values, $position_keys = FALSE) {
-  $allowed_values = array();
+function list_extract_allowed_values($string_values, $position_keys = FALSE) {
+  $values = array();
 
   $list = explode("\n", $string_values);
   $list = array_map('trim', $list);
   $list = array_filter($list, 'strlen');
   foreach ($list as $key => $value) {
-    // Sanitize the user input with a permissive filter.
-    $value = field_filter_xss($value);
-
     // Check for a manually specified key.
     if (strpos($value, '|') !== FALSE) {
       list($key, $value) = explode('|', $value);
@@ -212,10 +200,10 @@ function list_allowed_values_list($strin
     elseif (!$position_keys) {
       $key = $value;
     }
-    $allowed_values[$key] = (isset($value) && $value !== '') ? $value : $key;
+    $values[$key] = (isset($value) && $value !== '') ? $value : $key;
   }
 
-  return $allowed_values;
+  return $values;
 }
 
 /**
@@ -271,6 +259,13 @@ function list_field_is_empty($item, $fie
 }
 
 /**
+ * Implements hook_options_list().
+ */
+function list_options_list($field) {
+  return list_allowed_values($field);
+}
+
+/**
  * Implement hook_field_formatter_info().
  */
 function list_field_formatter_info() {
@@ -287,22 +282,40 @@ function list_field_formatter_info() {
 }
 
 /**
+ * Processes variables for 'default' list field formatter.
+ */
+function template_preprocess_field_formatter_list_default(&$variables) {
+  $element = $variables['element'];
+  $value = $element['#item']['value'];
+
+  $field = field_info_field($element['#field_name']);
+  $allowed_values = list_allowed_values($field, FALSE);
+
+  // If the value is not found in allowed values (the list might have been
+  // updated since the value was entered), fall back to the key.
+  $variables['safe'] = isset($allowed_values[$value]) ? field_filter_xss($allowed_values[$value]) : field_filter_xss($value);
+}
+
+/**
  * Theme function for 'default' list field formatter.
  */
 function theme_field_formatter_list_default($variables) {
+  return $variables['safe'];
+}
+
+/**
+ * Processes variables for 'key' list field formatter.
+ */
+function template_preprocess_field_formatter_list_key(&$variables) {
   $element = $variables['element'];
-  $field = field_info_field($element['#field_name']);
-  if (($allowed_values = list_allowed_values($field)) && isset($allowed_values[$element['#item']['value']])) {
-    return $allowed_values[$element['#item']['value']];
-  }
-  // If no match was found in allowed values, fall back to the key.
-  return $element['#item']['safe'];
+  $value = $element['#item']['value'];
+
+  $variables['safe'] = field_filter_xss($value);
 }
 
 /**
  * Theme function for 'key' list field formatter.
  */
 function theme_field_formatter_list_key($variables) {
-  $element = $variables['element'];
-  return $element['#item']['safe'];
+  return $variables['safe'];
 }
Index: modules/field/modules/list/list.test
===================================================================
RCS file: modules/field/modules/list/list.test
diff -N modules/field/modules/list/list.test
--- modules/field/modules/list/list.test	20 Nov 2009 23:29:28 -0000	1.3
+++ /dev/null	1 Jan 1970 00:00:00 -0000
@@ -1,89 +0,0 @@
-<?php
-// $Id: list.test,v 1.3 2009/11/20 23:29:28 webchick Exp $
-
-class ListFieldTestCase extends DrupalWebTestCase {
-  public static function getInfo() {
-    return array(
-      'name'  => 'List field',
-      'description'  => "Test the List field type.",
-      'group' => 'Field Types'
-    );
-  }
-
-  function setUp() {
-    parent::setUp('field_test');
-
-    $this->card_1 = array(
-      'field_name' => 'card_1',
-      'type' => 'list',
-      'cardinality' => 1,
-      'settings' => array(
-        'allowed_values' => "1|One\n2|Two\n3|Three\n",
-      ),
-    );
-    $this->card_1 = field_create_field($this->card_1);
-
-    $this->instance_1 = array(
-      'field_name' => $this->card_1['field_name'],
-      'object_type' => 'test_entity',
-      'bundle' => 'test_bundle',
-      'widget' => array(
-        'type' => 'options_buttons',
-      ),
-    );
-    $this->instance_1 = field_create_instance($this->instance_1);
-  }
-
-  /**
-   * Test that allowed values can be updated and that the updates are
-   * reflected in generated forms.
-   */
-  function testUpdateAllowedValues() {
-    // All three options appear.
-    $entity = field_test_create_stub_entity();
-    $form = drupal_get_form('field_test_entity_form', $entity);
-    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][1]), t('Option 1 exists'));
-    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][2]), t('Option 2 exists'));
-    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][3]), t('Option 3 exists'));
-
-    // Removed options do not appear.
-    $this->card_1['settings']['allowed_values'] = "2|Two";
-    field_update_field($this->card_1);
-    $entity = field_test_create_stub_entity();
-    $form = drupal_get_form('field_test_entity_form', $entity);
-    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][1]), t('Option 1 does not exist'));
-    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][2]), t('Option 2 exists'));
-    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][3]), t('Option 3 does not exist'));
-
-    // Completely new options appear.
-    $this->card_1['settings']['allowed_values'] = "10|Update\n20|Twenty";
-    field_update_field($this->card_1);
-    $form = drupal_get_form('field_test_entity_form', $entity);
-    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][1]), t('Option 1 does not exist'));
-    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][2]), t('Option 2 does not exist'));
-    $this->assertTrue(empty($form['card_1'][FIELD_LANGUAGE_NONE][3]), t('Option 3 does not exist'));
-    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][10]), t('Option 10 exists'));
-    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][20]), t('Option 20 exists'));
-
-    // Options are reset when a new field with the same name is created.
-    field_delete_field($this->card_1['field_name']);
-    unset($this->card_1['id']);
-    $this->card_1['settings']['allowed_values'] = "1|One\n2|Two\n3|Three\n";
-    $this->card_1 = field_create_field($this->card_1);
-    $this->instance_1 = array(
-      'field_name' => $this->card_1['field_name'],
-      'object_type' => 'test_entity',
-      'bundle' => 'test_bundle',
-      'widget' => array(
-        'type' => 'options_buttons',
-      ),
-    );
-    $this->instance_1 = field_create_instance($this->instance_1);
-    $entity = field_test_create_stub_entity();
-    $form = drupal_get_form('field_test_entity_form', $entity);
-    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][1]), t('Option 1 exists'));
-    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][2]), t('Option 2 exists'));
-    $this->assertTrue(!empty($form['card_1'][FIELD_LANGUAGE_NONE][3]), t('Option 3 exists'));
-  }
-}
-
Index: modules/field/modules/options/options.api.php
===================================================================
RCS file: modules/field/modules/options/options.api.php
diff -N modules/field/modules/options/options.api.php
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/field/modules/options/options.api.php	21 Nov 2009 23:36:52 -0000
@@ -0,0 +1,46 @@
+<?php
+// $Id: $
+
+/**
+ * @file
+ * Hooks provided by the Options module.
+ */
+
+/**
+ * Returns the list of options to be displayed for a field.
+ *
+ * Option labels will be run through field_filter_xss() and should therefore
+ * not be already sanitized. The HTML tags defined in
+ * _field_filter_xss_allowed_tags() are allowed, other tags will be filtered.
+ *
+ * @param $field
+ *   The field definition.
+ * @return
+ *   The array of options. Array keys are the values to be stored, array values
+ *   are the labels to display.
+ */
+function hook_options_list($field) {
+  // Sample structure.
+  $options = array(
+    0 => t('Zero'),
+    1 => t('One'),
+    2 => t('Two'),
+    3 => t('Three'),
+  );
+
+  // Sample structure with groups. Only one level of nesting is allowed. This
+  // is only supported by the 'options_select' widget. Other widgets will
+  // flatten the array.
+  $options = array(
+    t('First group') => array(
+      0 => t('Zero'),
+    ),
+    t('Second group') => array(
+      1 => t('One'),
+      2 => t('Two'),
+    ),
+    3 => t('Three'),
+  );
+
+  return $options;
+}
\ No newline at end of file
Index: modules/field/modules/options/options.module
===================================================================
RCS file: /cvs/drupal/drupal/modules/field/modules/options/options.module,v
retrieving revision 1.18
diff -u -p -r1.18 options.module
--- modules/field/modules/options/options.module	20 Nov 2009 05:14:13 -0000	1.18
+++ modules/field/modules/options/options.module	21 Nov 2009 23:50:10 -0000
@@ -19,6 +19,12 @@ function options_theme() {
 
 /**
  * Implement hook_field_widget_info().
+ *
+ * Field type modules willing to use those widgets should:
+ * - Use hook_field_widget_info_alter() to append their field own types to the
+ *   list of types supported by the widgets,
+ * - Implement hook_options_list() to provide the list of options.
+ * See taxonomy.module.
  */
 function options_field_widget_info() {
   return array(
@@ -53,61 +59,64 @@ function options_field_widget(&$form, &$
   // Abstract over the actual field columns, to allow different field types to
   // reuse those widgets.
   $value_key = key($field['columns']);
+
+  $type = str_replace('options_', '', $instance['widget']['type']);
   $multiple = $field['cardinality'] > 1 || $field['cardinality'] == FIELD_CARDINALITY_UNLIMITED;
-  // Form API 'checkboxes' do not suport 0 as an option, so we replace it with
-  // a placeholder within the form workflow.
-  $zero_placeholder = $instance['widget']['type'] == 'options_buttons' && $multiple;
-  // Collect available options for the field.
-  $options = options_get_options($field, $instance, $zero_placeholder);
+  $required = $element['#required'];
+  $properties = _options_properties($type, $multiple, $required);
+
+  // Prepare the list of options.
+  $options = _options_get_options($field, $instance, $properties);
+
   // Put current field values in shape.
-  $default_value = _options_storage_to_form($items, $options, $value_key, $zero_placeholder);
+  $default_value = _options_storage_to_form($items, $options, $value_key, $properties);
 
-  switch ($instance['widget']['type']) {
-    case 'options_select':
+  switch ($type) {
+    case 'select':
       $element += array(
         '#type' => 'select',
         '#default_value' => $default_value,
         // Do not display a 'multiple' select box if there is only one option.
         '#multiple' => $multiple && count($options) > 1,
         '#options' => $options,
-        '#value_key' => $value_key,
-        '#element_validate' => array('options_field_widget_validate'),
       );
       break;
 
-    case 'options_buttons':
-      $type = $multiple ? 'checkboxes' : 'radios';
+    case 'buttons':
       // If required and there is one single option, preselect it.
-      if ($element['#required'] && count($options) == 1) {
+      if ($required && count($options) == 1) {
+        reset($options);
         $default_value = array(key($options));
       }
       $element += array(
-        '#type' => $type,
+        '#type' => $multiple ? 'checkboxes' : 'radios',
         // Radio buttons need a scalar value.
-        '#default_value' => ($type == 'radios') ? reset($default_value) : $default_value,
+        '#default_value' => $multiple ? $default_value : reset($default_value),
         '#options' => $options,
-        '#zero_placeholder' => $zero_placeholder,
-        '#value_key' => $value_key,
-        '#element_validate' => array('options_field_widget_validate'),
       );
       break;
 
-    case 'options_onoff':
+    case 'onoff':
       $keys = array_keys($options);
-      $off_value = (!empty($keys) && isset($keys[0])) ? $keys[0] : NULL;
-      $on_value = (!empty($keys) && isset($keys[1])) ? $keys[1] : NULL;
+      $off_value = array_shift($keys);
+      $on_value = array_shift($keys);
       $element += array(
         '#type' => 'checkbox',
-        '#title' => isset($options[$on_value]) ? $options[$on_value] : '',
         '#default_value' => (isset($default_value[0]) && $default_value[0] == $on_value) ? 1 : 0,
         '#on_value' => $on_value,
         '#off_value' => $off_value,
-        '#value_key' => $value_key,
-        '#element_validate' => array('options_field_widget_validate'),
       );
+      // Override the title from the incoming $element.
+      $element['#title'] = isset($options[$on_value]) ? $options[$on_value] : '';
       break;
   }
 
+  $element += array(
+    '#value_key' => $value_key,
+    '#element_validate' => array('options_field_widget_validate'),
+    '#properties' => $properties,
+  );
+
   return $element;
 }
 
@@ -132,54 +141,123 @@ function options_field_widget_validate($
 }
 
 /**
- * Prepares the options for a field.
+ * Describes the preparation steps required by each widget.
  */
-function options_get_options($field, $instance, $zero_placeholder) {
-  // Check if there is a module hook for the option values, otherwise try
-  // list_allowed_values() for an options list.
-  // @todo This should be turned into a hook_options_allowed_values(), exposed
-  // by options.module.
-  $function = $field['module'] . '_allowed_values';
-  $options = function_exists($function) ? $function($field) : (array) list_allowed_values($field);
+function _options_properties($type, $multiple, $required) {
+  $base = array(
+    'zero_placeholder' => FALSE,
+    'filter_xss' => FALSE,
+    'strip_tags' => FALSE,
+    'empty_value' => FALSE,
+    'optgroups' => FALSE,
+  );
 
+  switch ($type) {
+    case 'select':
+      $properties = array(
+        // Select boxes do not support any HTML tag.
+        'strip_tags' => TRUE,
+        'empty_value' => !$required,
+        'optgroups' => TRUE,
+      );
+      break;
+
+    case 'buttons':
+      $properties = array(
+        'filter_xss' => TRUE,
+        // Form API 'checkboxes' do not suport 0 as an option, so we replace it with
+        // a placeholder within the form workflow.
+        'zero_placeholder' => $multiple,
+        // Checkboxes do not need a 'none' choice.
+        'empty_value' => !$required && !$multiple,
+      );
+      break;
+
+    case 'onoff':
+      $properties = array(
+        'filter_xss' => TRUE,
+      );
+      break;
+  }
+
+  return $properties + $base;
+}
+
+/**
+ * Collects the options for a field.
+ */
+function _options_get_options($field, $properties) {
+  // Get the list of options.
+  $options = (array) module_invoke($field['module'], 'options_list', $field);
+
+  // Sanitize the options.
+  _options_prepare_options($options, $properties);
+
+  if (!$properties['optgroups']) {
+    $options = options_array_flatten($options);
+  }
+
+  if ($properties['empty_value']) {
+    $options = array('_none' => theme('options_none', array('instance' => $instance))) + $options;
+  }
+
+  return $options;
+}
+
+/**
+ * Sanitizes the options.
+ *
+ * The function is recursive to support optgroups.
+ */
+function _options_prepare_options(&$options, $properties) {
   // Substitute the '_0' placeholder.
-  if ($zero_placeholder) {
+  if ($properties['zero_placeholder']) {
     $values = array_keys($options);
+    $labels = array_values($options);
     // Use a strict comparison, because 0 == 'any string'.
     $index = array_search(0, $values, TRUE);
-    if ($index !== FALSE) {
+    if ($index !== FALSE && !is_array($options[$index])) {
       $values[$index] = '_0';
-      $options = array_combine($values, array_values($options));
+      $options = array_combine($values, $labels);
     }
   }
 
-  // Add an empty choice for
-  // - non required radios
-  // - non required selects
-  if (!$instance['required']) {
-    if (($instance['widget']['type'] == 'options_buttons' && ($field['cardinality'] == 1)) || ($instance['widget']['type'] == 'options_select')) {
-      $options = array('_none' => theme('options_none', array('instance' => $instance))) + $options;
+  foreach ($options as $value => $label) {
+    // Recurse for optgroups.
+    if (is_array($label)) {
+      _options_prepare_options($options[$value], $properties);
+    }
+    else {
+      if ($properties['strip_tags']) {
+        $options[$value] = strip_tags($label);
+      }
+      if ($properties['filter_xss']) {
+        $options[$value] = field_filter_xss($label);
+      }
     }
   }
-  return $options;
 }
 
 /**
  * Transforms stored field values into the format the widgets need.
  */
-function _options_storage_to_form($items, $options, $column, $zero_placeholder) {
+function _options_storage_to_form($items, $options, $column, $properties) {
   $items_transposed = options_array_transpose($items);
   $values = (isset($items_transposed[$column]) && is_array($items_transposed[$column])) ? $items_transposed[$column] : array();
 
   // Substitute the '_0' placeholder.
-  if ($zero_placeholder) {
+  if ($properties['zero_placeholder']) {
     $index = array_search('0', $values);
     if ($index !== FALSE) {
       $values[$index] = '_0';
     }
   }
 
-  // Discard values that are not in the current list of options.
+  // Discard values that are not in the current list of options. Flatten the
+  // array if needed.
+  if ($properties['optgroups']) {
+    $options = options_array_flatten($options);
+  }
   $values = array_values(array_intersect($values, array_keys($options)));
   return $values;
 }
@@ -189,6 +267,7 @@ function _options_storage_to_form($items
  */
 function _options_form_to_storage($element) {
   $values = array_values((array) $element['#value']);
+  $properties = $element['#properties'];
 
   // On/off checkbox: transform '0 / 1' into the 'on / off' values.
   if ($element['#type'] == 'checkbox') {
@@ -196,7 +275,7 @@ function _options_form_to_storage($eleme
   }
 
   // Substitute the '_0' placeholder.
-  if (!empty($element['#zero_placeholder'])) {
+  if ($properties['zero_placeholder']) {
     $index = array_search('_0', $values);
     if ($index !== FALSE) {
       $values[$index] = 0;
@@ -205,9 +284,11 @@ function _options_form_to_storage($eleme
 
   // Filter out the 'none' option. Use a strict comparison, because
   // 0 == 'any string'.
-  $index = array_search('_none', $values, TRUE);
-  if ($index !== FALSE) {
-    unset($values[$index]);
+  if ($properties['empty_value']) {
+    $index = array_search('_none', $values, TRUE);
+    if ($index !== FALSE) {
+      unset($values[$index]);
+    }
   }
 
   // Make sure we populate at least an empty value.
@@ -250,6 +331,28 @@ function options_array_transpose($array)
 }
 
 /**
+ * Flattens an array of allowed values.
+ *
+ * @param $array
+ *   A single or multidimensional array.
+ * @return
+ *   A flattened array.
+ */
+function options_array_flatten($array) {
+  $result = array();
+  if (is_array($array)) {
+    foreach ($array as $key => $value) {
+      if (is_array($value)) {
+        $result += options_array_flatten($value);
+      }
+      else {
+        $result[$key] = $value;
+      }
+    }
+  }
+  return $result;
+}
+/**
  * Implement hook_field_widget_error().
  */
 function options_field_widget_error($element, $error) {
Index: modules/field/modules/options/options.test
===================================================================
RCS file: /cvs/drupal/drupal/modules/field/modules/options/options.test,v
retrieving revision 1.5
diff -u -p -r1.5 options.test
--- modules/field/modules/options/options.test	20 Nov 2009 23:29:28 -0000	1.5
+++ modules/field/modules/options/options.test	21 Nov 2009 22:15:48 -0000
@@ -11,7 +11,7 @@ class OptionsWidgetsTestCase extends Dru
   }
 
   function setUp() {
-    parent::setUp('field_test');
+    parent::setUp('field_test', 'list_test', 'devel');
 
     // Field with cardinality 1.
     $this->card_1 = array(
@@ -20,7 +20,7 @@ class OptionsWidgetsTestCase extends Dru
       'cardinality' => 1,
       'settings' => array(
         // Make sure that 0 works as an option.
-        'allowed_values' => "0|Zero\n1|One\n2|Two\n",
+        'allowed_values' => "0|Zero\n1|One\n2|Some <script>dangerous</script> & unescaped <strong>markup</strong>\n",
       ),
     );
     $this->card_1 = field_create_field($this->card_1);
@@ -32,7 +32,7 @@ class OptionsWidgetsTestCase extends Dru
       'cardinality' => 2,
       'settings' => array(
         // Make sure that 0 works as an option.
-        'allowed_values' => "0|Zero\n1|One\n2|Two\n",
+        'allowed_values' => "0|Zero\n1|One\n2|Some <script>dangerous</script> & unescaped <strong>markup</strong>\n",
       ),
     );
     $this->card_2 = field_create_field($this->card_2);
@@ -44,7 +44,7 @@ class OptionsWidgetsTestCase extends Dru
       'cardinality' => 1,
       'settings' => array(
         // Make sure that 0 works as a 'on' value'.
-        'allowed_values' => "1|No\n0|Yes\n",
+        'allowed_values' => "1|No\n0|Some <script>dangerous</script> & unescaped <strong>markup</strong>\n",
       ),
     );
     $this->bool = field_create_field($this->bool);
@@ -57,7 +57,7 @@ class OptionsWidgetsTestCase extends Dru
   /**
    * Tests the 'options_buttons' widget (single select).
    */
-  function testRadioButtons() {
+  function atestRadioButtons() {
     // Create an instance of the 'single value' field.
     $instance = array(
       'field_name' => $this->card_1['field_name'],
@@ -81,6 +81,7 @@ class OptionsWidgetsTestCase extends Dru
     $this->assertNoFieldChecked("edit-card-1-$langcode-0");
     $this->assertNoFieldChecked("edit-card-1-$langcode-1");
     $this->assertNoFieldChecked("edit-card-1-$langcode-2");
+    $this->assertRaw('Some dangerous &amp; unescaped <strong>markup</strong>', t('Option text was properly filtered.'));
 
     // Select first option.
     $edit = array("card_1[$langcode]" => 0);
@@ -98,7 +99,7 @@ class OptionsWidgetsTestCase extends Dru
     $this->drupalPost(NULL, $edit, t('Save'));
     $this->assertListValues($entity_init, 'card_1', $langcode, array());
 
-    // Required radios with one option is auto-selected.
+    // Check that required radios with one option is auto-selected.
     $this->card_1['settings']['allowed_values'] = '99|Only allowed value';
     field_update_field($this->card_1);
     $instance['required'] = TRUE;
@@ -110,7 +111,7 @@ class OptionsWidgetsTestCase extends Dru
   /**
    * Tests the 'options_buttons' widget (multiple select).
    */
-  function testCheckBoxes() {
+  function atestCheckBoxes() {
     // Checkboxes do not support '0' as an option, the widget internally
     // replaces it with '_0'.
 
@@ -137,6 +138,7 @@ class OptionsWidgetsTestCase extends Dru
     $this->assertNoFieldChecked("edit-card-2-$langcode--0");
     $this->assertNoFieldChecked("edit-card-2-$langcode-1");
     $this->assertNoFieldChecked("edit-card-2-$langcode-2");
+    $this->assertRaw('Some dangerous &amp; unescaped <strong>markup</strong>', t('Option text was properly filtered.'));
 
     // Submit form: select first and third options.
     $edit = array(
@@ -223,6 +225,7 @@ class OptionsWidgetsTestCase extends Dru
     $this->assertNoOptionSelected("edit-card-1-$langcode", 0);
     $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
     $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+    $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
 
     // Submit form: select first option.
     $edit = array("card_1[$langcode]" => 0);
@@ -248,6 +251,38 @@ class OptionsWidgetsTestCase extends Dru
 
     // We do not have to test that a required select list with one option is
     // auto-selected because the browser does it for us.
+
+    // Test optgroups.
+
+    $this->card_1['settings']['allowed_values'] = NULL;
+    $this->card_1['settings']['allowed_values_function'] = 'list_test_allowed_values_callback';
+    field_update_field($this->card_1);
+    $instance['required'] = FALSE;
+    field_update_instance($instance);
+
+    // Display form: with no field data, nothing is selected
+    $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 0);
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+    $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
+    $this->assertRaw('Group 1', t('Option groups are displayed.'));
+
+    // Submit form: select first option.
+    $edit = array("card_1[$langcode]" => 0);
+    $this->drupalPost(NULL, $edit, t('Save'));
+    $this->assertListValues($entity_init, 'card_1', $langcode, array(0));
+
+    // Display form: check that the right options are selected.
+    $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
+    $this->assertOptionSelected("edit-card-1-$langcode", 0);
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+
+    // Submit form: Unselect the option.
+    $edit = array("card_1[$langcode]" => '_none');
+    $this->drupalPost('test-entity/' . $entity->ftid .'/edit', $edit, t('Save'));
+    $this->assertListValues($entity_init, 'card_1', $langcode, array());
   }
 
   /**
@@ -277,6 +312,7 @@ class OptionsWidgetsTestCase extends Dru
     $this->assertNoOptionSelected("edit-card-2-$langcode", 0);
     $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
     $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+    $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
 
     // Submit form: select first and third options.
     $edit = array("card_2[$langcode][]" => array(0 => 0, 2 => 2));
@@ -331,12 +367,45 @@ class OptionsWidgetsTestCase extends Dru
 
     // We do not have to test that a required select list with one option is
     // auto-selected because the browser does it for us.
+
+    // Test optgroups.
+
+    // Use a callback function defining optgroups.
+    $this->card_2['settings']['allowed_values'] = NULL;
+    $this->card_2['settings']['allowed_values_function'] = 'list_test_allowed_values_callback';
+    field_update_field($this->card_2);
+    $instance['required'] = FALSE;
+    field_update_instance($instance);
+
+    // Display form: with no field data, nothing is selected.
+    $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 0);
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+    $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
+    $this->assertRaw('Group 1', t('Option groups are displayed.'));
+
+    // Submit form: select first option.
+    $edit = array("card_2[$langcode][]" => array(0 => 0));
+    $this->drupalPost(NULL, $edit, t('Save'));
+    $this->assertListValues($entity_init, 'card_2', $langcode, array(0));
+
+    // Display form: check that the right options are selected.
+    $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
+    $this->assertOptionSelected("edit-card-2-$langcode", 0);
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+
+    // Submit form: Unselect the option.
+    $edit = array("card_2[$langcode][]" => array('_none' => '_none'));
+    $this->drupalPost('test-entity/' . $entity->ftid .'/edit', $edit, t('Save'));
+    $this->assertListValues($entity_init, 'card_2', $langcode, array());
   }
 
   /**
    * Tests the 'options_onoff' widget.
    */
-  function testOnOffCheckbox() {
+  function atestOnOffCheckbox() {
     // Create an instance of the 'boolean' field.
     $instance = array(
       'field_name' => $this->bool['field_name'],
@@ -358,6 +427,7 @@ class OptionsWidgetsTestCase extends Dru
     // Display form: with no field data, option is unchecked.
     $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
     $this->assertNoFieldChecked("edit-bool-$langcode");
+    $this->assertRaw('Some dangerous &amp; unescaped <strong>markup</strong>', t('Option text was properly filtered.'));
 
     // Submit form: check the option.
     $edit = array("bool[$langcode]" => TRUE);
Index: modules/taxonomy/taxonomy.module
===================================================================
RCS file: /cvs/drupal/drupal/modules/taxonomy/taxonomy.module,v
retrieving revision 1.540
diff -u -p -r1.540 taxonomy.module
--- modules/taxonomy/taxonomy.module	20 Nov 2009 05:14:13 -0000	1.540
+++ modules/taxonomy/taxonomy.module	22 Nov 2009 00:54:45 -0000
@@ -958,16 +958,6 @@ function taxonomy_field_info() {
 
 /**
  * Implement hook_field_widget_info().
- *
- * We need custom handling of multiple values because we need
- * to combine them into a options list rather than display
- * cardinality elements. We will use the field module's default
- * handling for default values.
- *
- * Callbacks can be omitted if default handing is used.
- * They're included here just so this module can be used
- * as an example for custom modules that might do things
- * differently.
  */
 function taxonomy_field_widget_info() {
   return array(
@@ -994,6 +984,13 @@ function taxonomy_field_widget_info_alte
 }
 
 /**
+ * Implements hook_options_list().
+ */
+function taxonomy_options_list($field) {
+  return taxonomy_allowed_values($field);
+}
+
+/**
  * Implement hook_field_schema().
  */
 function taxonomy_field_schema($field) {
@@ -1094,12 +1091,12 @@ function theme_field_formatter_taxonomy_
 }
 
 /**
- * Create an array of the allowed values for this field.
+ * Returns the set of valid terms for a taxonomy field.
  *
- * Call the field's allowed_values function to retrieve the allowed
- * values array.
- *
- * @see _taxonomy_term_select()
+ * @param $field
+ *   The field definition.
+ * @return
+ *   The array of valid terms for this field, keyed by term id.
  */
 function taxonomy_allowed_values($field) {
   $options = array();
